* 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 commit 1da8d16740.
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 commit e4504d2843. The counts go back to separate badges and
"Review changes" to a button of its own.
Co-Authored-By: Claude Opus 5 (1M context) <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>
* feat(sessions): show an item's deployed page in the preview panel
The preview panel showed an item's editor and nothing else. It now shows the
deployed page too, as a tab of its own.
**Two sides, two tabs.** The editor and the deployed page are different things
to look at, so `isItemTabFor` keys on the side rather than the item: asking for
a side already open focuses it, asking for the other opens it alongside. Two
editors for one item still cannot coexist — they would race its single
(kind, path) cell — but an editor beside a viewer is safe, since the viewer
reads the deployed version over the API and holds no cell. The tab breadcrumb
keeps re-pointing in place; `Exit & see details` and the detail page's `Edit`
open the other side instead of consuming the one you are on. A tab's label
carries `(edit)` when it is the editor side.
**The detail pages moved out of their routes.** `/scripts/get` and `/flows/get`
are now thin wrappers over `ScriptDetail` and `FlowDetail`, which the panel
renders too. Everything they reach — drawers, triggers, saved inputs — is scoped
to the viewer's workspace through `setOperatingWorkspace`, and every navigation
they attempt is caught by `interceptNav` and turned into a move inside the
panel, so nothing takes the browser out of the session. A plain click is
intercepted; ⌘-click and middle-click still open a real tab, which is why the
pages keep their `href`s.
**The picker opens what exists.** A row opens the deployed page, or the editor
when nothing is deployed there, and carries a Draft / Draft only badge in the
review dock's words. `WorkspaceItem` gained `draftOnly` and `hasDraft` from the
listers, which already returned both; deploys now invalidate the picker cache
through `itemDeployed`, so a just-deployed item stops reading as a draft. Rows
also carry a hover Edit action, reachable from the keyboard with the pick
modifier.
`open_preview` gained a `mode`, narrowed out of the advertised schema for a
session that cannot write drafts and re-checked per path in the handler, where
a refusal reports "couldn't check" apart from "denied". The mode is resolved
before those checks, so a call that omits it is gated as the editor it will
become.
Alongside, the workspace a detail page acts on is now the one it is showing
rather than the one the browser is navigated to: `MoveDrawer`,
`toggleWorkspaceErrorHandler` and the pages' own permission gates read the
operating workspace and its acting user, and `RunForm` mints a password
argument's ephemeral variable there too — in a fork session all of these
previously answered for the parent. `InWorkspaceAppViewer` hands the app's
`ctx` user down as a prop instead of writing the global `userStore`, which from
a session tab was re-pointing every permission check on the page.
Fixes found on the way: `DetailPageLayout` claimed `h-screen` inside a panel
that is not the viewport; Edit on a historical script version dropped the
version from the intercepted click; the Run button stayed spinning after a run
that left the page mounted; and the window-level run shortcut fired from a
collapsed panel and from keys another handler had already claimed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sessions): historical edits, stale not-found, shared edit rights, tab labels
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): keep deployed-page links and workspace reads in the session
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): scope detail hrefs to the session, re-point the viewer's own tab
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): keep a previewed raw app's route out of the session URL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): resolve edit-in-fork against the session workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): read advanced run tags from the session workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): fork from the session workspace, star only navigation items
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): mount only the side a preview tab shows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): address CI review round 1 findings
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): route links in a deployed page's drawers through the panel
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): tell the chat which deployed page the panel shows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): only a 404 reads as an undeployed raw app
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): scope the unparseable-JSON run gate to the form
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): a flow deleted from the panel stays in its tab
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): the picker's edit shortcut works on the current row
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): load-error toasts say what failed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(sessions): keep an oversized invalid JSON editor in its form's run gate
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* revert: keep main's draft_only description in openapi.yaml
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: replace ag-grid in the db manager table viewer with a native grid
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: add user-managed data, diagram and sql editor tabs to the db manager
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: add schema autocomplete to the db manager sql editor and polish its layout
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: add joined foreign key columns and draggable tabs to the db manager
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: move db manager tabs on drop instead of mid-drag
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: open followed foreign keys in a new db manager tab
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: scroll large tables, drop stale joins and return to the last tab on close
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: tolerate the db manager tabs going away while switching data table
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: keep saved joined columns while metadata loads, test joins in every dialect
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: put the db manager grid on the input surface in dark mode
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: address db manager review nits on joins, boolean keys and sql quoting
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: keep the dark border color on the left pinned column edge
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: tone down the db manager tree menu icons
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: write json and jsonb values from the db manager through a text cast
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: dim unrelated diagram tables less on hover
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: match json columns as text when deleting a db manager row
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: compare postgres json columns as text in exact db manager filters
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: pick the columns the db manager grid shows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: toggle every db manager column from one checkbox
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: word joined columns as a view in the db manager
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: keep data table roles and access visible when unavailable, with the reason
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: keep data table and instance roles visible in settings when unavailable
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: hide a db manager column from its header menu
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: format db manager columns with a unit, significant digits and color rules
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: drop the before/after hints from the db manager unit picker
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: write the euro after the amount and keep units off non-numeric values
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: color rule presets, bold and italic, layered and reorderable rules in the db manager
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: decimals, thousands separator, compact notation and alignment in db manager column formats
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: compact db manager format controls, a notation toggle group and a reset button
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: db manager format and filter nits
Keep the value's scale when decimals are auto, read boolean color-rule conditions as
booleans, let a rule's text color reach foreign-key links, close the formatter when the
columns picker opens, and filter BigQuery complex columns through TO_JSON_STRING.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: edit variables, resources and triggers in their own session tab
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: take a trigger's flow kind from its config and name the written trigger
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: one operating-workspace context for editors acting on a session's workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: reload page item tabs only in sessions acting on the tool's workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: resolve open in workspace for every preview tab kind in one place
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: guard every component under a session editor against the navigation workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: show a page item tab whose item is gone instead of mounting its editor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: follow a saved page item in the tab's own workspace and restore in-frame rows
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: close a page item tab from its editor header
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: judge trigger permissions by the user acting in the session workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: judge every session-editor permission by the user acting in that workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep session permissions reactive to the acting role and restore tabs as last seen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: default Path's owner controls to the user acting in its workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
* fix: route legacy AI entry points to sessions instead of the unmounted chat
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep createSession's workspace choice and revert pipeline hand-off
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: guard in-session step generation and restore AI action labels
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep AI Fix usable in-session and stop silent no-op hand-offs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: neutral AI form assistant heading to match both branches
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: state the AI form assistant branch rationale once
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: auto-send AI hand-offs and keep in-session step generation in global mode
* fix: name the AI session in the entry point labels
* fix: claim auto-send reactively and queue programmatic sends mid-turn
* test: pin the auto-send claim going stale
* fix: stop the script drawer hand-off from abandoning its unsaved script
* fix: keep a stale hand-off prompt and close the pre-loading send window
* fix: only blank the composer for an intent this wrapper can claim
* fix: report composer edits only, never the mount-time draft
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Picking a new language seeds the new template into `script.content`, which
`ScriptEditor` writes straight into Monaco. That write goes through
`onDidChangeModelContent`, which arms the keystroke debounce as if the user had
typed — so when the `{#key effectiveLang}` block then tears the editor down, the
unmount flush sees a pending timer, reads a stale `code`, and dispatches a change
that puts the previous language's template back. The editor kept showing the old
content under the new language.
`alignCodeWithEditor` now clears the timer its own write armed, restoring the
premise the unmount flush is guarded on: a pending timer means Monaco holds
something newer than `code`.
`ScriptEditor` also takes the change payload instead of re-reading `editorCode`.
A destroyed component's `bind:` writes no longer reach the parent, so the
re-read returned the value from before the change — which is what actually
wrote the old template back, and would equally have made the unmount flush
save stale text after real typing.
Fixes WIN-2330
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): stop swallowing Ctrl/Cmd+Shift+S in the editors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make Ctrl/Cmd+S from a focused Monaco flush the draft
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): broadcast the Monaco save shortcut after the effect flush
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: responsive modal step panel for the flow editor in sessions
On narrow layouts the flow editor's step-details pane opens as a modal
(double-click a graph node) instead of a split pane, with a dock/float
toggle. Scoped to sessions via allowModalPanel; the full-page editor is
unchanged.
- FlowEditor: modal/docked modes gated by mount width + allowModalPanel,
small header (step-id Badge + subtle dock/close), standing
double-click hint, and a per-step hint in the name tooltip
- selectionManager: onSelectIntent hook so flow-level panels (settings,
input, triggers…) open the modal on single click
- PropPickerWrapper: collapse the prop picker until connect and animate
it in via AnimatedPane (runs-page pattern), no blue connect ring in
modal mode
- StepInputGen: drop the TAB/Wand autocompletion button + spinner
(feature still works via focus + Tab)
- InputTransformForm: decouple the Help dropdown from the AI suggestion
- FlowModuleHeader: move 'Save to workspace' into an ellipsis dropdown
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: loop editor rendering and nested splitpanes splitters in the sessions modal
- Loop iterator/parallelism: keep the picker split pane (forceExpanded) so
the editor fills its box and the picker shows; the collapse-until-connect
mode stays for the step inputs
- Remove the intrusive AI TAB/Wand autocompletion button from IteratorGen
(generation still runs headless via focus + Tab)
- Size the iterator connect plug and restyle the loop header/labels/toggles
- Scope the global `.splitter-hidden` splitter-hiding rule to direct children
so it no longer leaks into nested Splitpanes under the sessions preview
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: redesign flow step advanced settings as a single toggle-first column
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: taller step test pane by default and restyle advanced section titles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: show flow run-settings params disabled when a setting is toggled off
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: single-column for-loop panel reusing the run-settings accordion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: single-column while-loop panel reusing the run-settings accordion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: single-column branch panels reusing the run-settings accordion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: auto-open modal panel when creating an AI agent tool
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: redesign branch panels with card layout and shared predicate editor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: remove per-setting status badges from flow map nodes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: sync package-lock after windmill-utils-internal bump
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style: polish prop-picker plug button and branch panel layouts
* fix: persist skip-if-stopped toggles in early stop settings
* fix: open the step panel modal on demand and cap its width
* fix: restore graph step setting badges, strip panel header chips instead
* feat: docked panel header with detach action and open-details step menu
* feat: width-based panel mode on every surface with inline detach action
* refactor: single source for flow step settings and their defaults
* docs: pin flow editor vocabulary in CONTEXT.md
* fix: open the trigger panel on double click or a specific trigger
* fix: keep module pickers inside their pane and dismissable
* fix: drop the misleading chevron on the MCP tool entry
* fix: resolve flow approvals against the job's workspace, not the nav one
* refactor: derive the approval workspace from the job, not from callers
* fix: restore S3 snippets and gate params while their setting is off
* fix: restore branch mock controls and address review findings
* chore: drop stray debug log from the flow map item
* feat: pinned output section for loop and branch panels
* fix: open the panel for deliberate navigation from the flow header
* perf: mount branch predicate editors on demand
* fix: skip predicate picker previews the previous step's result
* fix: flow-level graph nodes open their panel on a single click
* fix: open the step panel for AI chat selections, not for undo
* chore: drop dead console.log and duplicated modalPanel doc
* fix: re-sync expression editors and scope error-handler settings
* fix: match the failure module exactly and ignore unselectable nodes
* fix: keep concurrency editable, honour module cache_ttl, tighten panel ids
* fix: open panel from indirect selections, use presence for value-driven toggles
* fix: don't open settings on error-handler delete, flush editors on unmount
* fix: guard editor destroy flush, keep retry kind reachable
* refactor: name the run settings panel after the domain vocabulary
* fix: only write editor flushes to the step they belong to
* fix: bind step panels by id so a delete can't retarget editor writes
* fix: don't let the trigger picker's escape close the drawer beneath it
* docs: condense two comments to the constraint they record
* fix: arbitrate escape through the overlay stack instead of deferring to it
* fix: key nested step blocks by identity so anchored bindings can't go stale
* fix: untrack the overlay-stack push and drop the frozen branch binding
* chore: state the escape rationale once, key branch lists, format
* fix: let the topmost overlay own escape instead of the graph
* fix: keep the dynamic-input help box out of static template fields
* fix: restore the graph connect on the for-loop iterator
* fix: end connect mode with the modal and keep it to docked panels
* fix: never enter graph connect mode from the modal panel
* fix: reveal inserted steps, restore editor pane size, unleak the drawer stack
* fix: keep the enable-AI popover reachable in session panes
* feat: add the connect policy and its single armed slot
* refactor: one picker for every expression input
* refactor: route every connect through one armed slot
* fix: give every connect button the same footprint
* fix: keep the connect ring from showing through the button
* fix: keep flow card actions right-aligned beside the detach button
* fix: give the connect ring an opaque ground to mask against
* feat: dock the panel back without reopening it
* feat: dock the panel from the graph control bar
* style: round the graph control bar and size its glyphs
* style: customize the graph controls through their supported api
* style: build the graph control bar from lucide icons
* fix: use the graph's tooltip component in the zoom controls
* style: pad the graph controls and enlarge their glyphs
* style: pad the graph controls and put dock at the bar's end
* refactor: give settings rows the same popover picker as other expressions
* fix: pass the wrapper's pickable properties to nested inputs
* refactor: stack step settings and render every expression through the step input form
* feat: split loop panels into tabs and rework the approval form
* feat: anchor drawers to their host pane and give them a size floor
* fix: mark the loop iterator expression as required
* refactor: badge ee-only toggles instead of a warning line
* fix: flag an empty loop iterator expression as an error
* refactor: pick the early-stop flow status from one toggle group
* fix: keep parallel loops uncapped unless a limit is opted into
* fix: scope the overlay stack to its host and disarm connect on dismissal
* fix: anchor the trigger picker to its host pane
* feat: move diff into the menu when the top bar is narrow
* fix: gate the result logs toggle to the graph popover
* feat: raise the modal-panel breakpoint to 1280
* fix: anchor flow editor popovers and fullscreen to their host pane
* fix: anchor overlays to their host pane and mute them when hidden
* fix: portal hosted modals and menus into the pane they anchor to
* fix: keep non-listening dialogs off the overlay stack
* fix: drop the topmost gate from confirmation dialogs
* fix: silence overlays in a collapsed preview panel
* feat: rework the branch panels with tabs, reordering and add/delete
* refactor: fold the detached-panel chrome into the card header
* fix: give every flow panel a titled card header
* fix: stop the step panel oscillating on an auto-height editor
* feat: consolidate script panel actions and restore branch predicate AI
* fix: restore the logs toggle on the flow result popover
* fix: collapse the idle property picker in modal step panels
* fix: stop the docked pane scrolling alongside its panel
* fix: space the last settings row off the panel bottom
* revert: always show the property picker pane in step panels
* chore: keep the inline script AI button identical to main
* fix: ask for AI input suggestions on click, not on hover
* fix: keep graph connects armed and remount the parallelism input
* style: reveal the predicate AI button on row hover
* style: give branch cards a handle and delete column
* refactor: arbitrate flow overlay escape through Disposable
* fix: give the popover picker its results and re-narrow the EE badge
* docs: correct loopSubset and guard the modal width measurement
* fix: insert picked properties at the cursor in expression inputs
* fix: give the expanded-subflow panel the shared header chrome
* style: rename the suspend setting to Suspend until approval/resume
* feat: open a step's modal when clicking the step already selected
* feat: add an auto/attached/detached toggle for the step panel
* refactor: pick the step panel's placement from one named menu
* refactor: keep the panel-mode module's exports to what is consumed
* feat: show each configured setting's value on its badge
* fix: carry the suspend rename into the step settings registry
* docs: name both gestures in the step explore hint
* test: pin where the step panel goes for a given width and preference
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: add datatable_migrations table
* feat: add route to run datatable migrations
* feat: sync datatable migrations as .up.sql/.down.sql files
* feat: add datatable migrate up/down commands and post-push run prompt
* feat: add datatable migrate new command to scaffold migrations
* feat: add datatable migrations management UI
* feat: prompt to create migration on DDL in datatable SQL editors
* feat: support running a single specific datatable migration
* feat: view migration content, run single migration, fix stacked modal
* feat: per-row revert button with out-of-order warning
* fix: avoid migrations list flicker on refresh after an action
* feat: generate initial datatable migration via pg_dump
* fix: surface datatable migration API error details in toasts
* fix: revert created migration if create-and-run fails to run
* fix: include postgres error detail in migration run/rollback failures
* feat: sync datatable migrations as files via the workspace export
* refactor: move datatable migrations to migrations/datatable/ path
* fix: drop redundant datatable_migration label in sync output
* fix: exclude datatable migration sql files from script metadata generation
* feat: run datatable migrations as user-permissioned labeled jobs
* feat: reject invalid datatable migrations on sync push
* feat: datatable migrate up/down default to all datatables, --datatable to target one
* fix: surface postgres error detail when datatable migrations fail to run
* chore: regenerate CLI docs for datatable migrate commands
* feat: default new datatable migration to a BEGIN/END transaction template
* fix: validate datatable migration name and datatable at the API boundary
* fix: ensure detected DDL ends with semicolon when wrapped in transaction
* fix: re-prompt instead of stripping DDL when new-migration modal is cancelled
* feat: refresh datatable schema after running a migration from the SQL REPL
* feat: record db manager DDL on data tables as migrations
* feat: make datatable migrations opt-in per data table
* fix: make migration view editor read-only so its code can scroll
* fix: don't re-prompt DDL guard when creating a migration without running
* feat: generate down migrations for db manager DDL (postgres)
* fix: correct down migration for db manager alters (no double-wrap, serial)
* feat: explain migrations purpose with a tooltip in the migrations modal
* compare paeg
* feat: add datatable_migration kind to workspace diff pipeline
* chore: point ee-repo-ref at datatable_migration git-sync companion
* fix: harden datatable migration version allocation and initial-migration bookkeeping, add tests
* feat: deploy and run datatable migrations on workspace merge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Refactor + handle datatable setting delete/rename
* refactor: move datatable migration rename/delete cascade into module
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(windmill-utils-internal): bump to 1.7.1 for datatable migration deploy provider methods
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(db-manager): add Migrations button to top bar, make Refresh icon-only
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* BEGIN/END placeholder in down migration
* feat: autofocus migration name input and flag it red when empty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(datatable-migrations): allow non-admins to create/run/revert migrations, gate only opt in/out
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* border nits
* refresh db manager schema on migrations
* BEGIN/END scaffold in CLI
* feat(cli): push local datatable migrations before running on migrate up
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: flag invalid migration name with red border, not just empty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: drop random slug from auto-generated migration names
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: offer revert-and-delete when deleting an installed migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: record fork merge as a migration when target datatable opts in
* nit
* clone migrations on fork
* windmill-utils-internal
* fix(datatable-migrations): serialize run/rollback with a per-db advisory lock
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(db-manager): fail closed when migrations-status check errors on DDL apply
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: fix generate_initial migration ordering comment to match code
* chore(datatable-migrations): remove unused update_datatable_migrations endpoint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: run DDL migration guard on the script editor Test button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* split
* ee-repo-ref
* chore(frontend): sync package-lock with package.json (@emnapi deps)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(datatable-migrations): never resolve instance credentials into migration job args
datatable_database_arg eagerly resolved instance data-table credentials
(including the shared instance-wide Postgres password) and passed them as the
migration job's plaintext `database` arg, landing in v2_job.args. Since the
run route has no admin gate, a non-admin could run a migration and read
args.database to recover the password, granting cross-workspace psql access to
all instance data-table DBs.
Pass a `datatable://<name>` reference for both resource-backed and instance
data tables instead; the pg executor already resolves it to real credentials
server-side at run time, so nothing sensitive is ever stored in the job args.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* nit
* fix: handle dollar-quoting and comments when splitting SQL statements
* feat: deploy datatable migrations on merge with explicit opt-in error
* fix(frontend): sync package-lock with npm 11 peer-dep resolution
npm ci failed with 'Missing: @emnapi/core@1.11.2 / @emnapi/runtime@1.11.2 from
lock file'. @napi-rs/wasm-runtime declares @emnapi/core|runtime ^1.7.1 as
peerDependencies while @rolldown/binding-wasm32-wasi pins them to exactly
1.10.0. Newer npm (bundled with node 24 in CI) installs the peer deps at the
highest match (1.11.2) alongside rolldown's nested 1.10.0, so the ideal tree
needs both versions; the committed lock only had 1.10.0.
Regenerate the lock with npm 11.18 so it carries both 1.11.2 (top-level, for
the peer deps) and 1.10.0 (nested, for rolldown's pin). Verified npm ci passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* nit npm publish
* fix: fail closed on migrations-status error in fork schema merge
* nit CI emnapi/core version
* prevent initial_datatable_migration if migrations already exist
* fix(datatable-migrations): validate persisted data table names as path segments
edit_datatable_config only validated rename segments, not the actual
settings.datatables keys, so a data table could be saved directly under a name
like '..' or one containing '/'. Since new tables default to
migrations_enabled = true, generate_initial_datatable_migration would then
insert a migration row and the sync export would build
migrations/datatable/<name>/... paths from that name, producing malformed or
directory-escaping export paths.
Validate every persisted data table name in edit_datatable_config (alongside
the existing rename checks) and add validate_datatable_path_segment to
generate_initial_datatable_migration for defense in depth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: scope datatable _wm_migrations by data table and cascade renames/deletes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(system_prompts): resolve nested local command groups in CLI docs generator
The CLI docs generator anchored on the first `new Command()` in a file and
never resolved locally-defined command groups passed as
`.command("name", localCmd)`. For datatable this flattened the nested
`migrate` group: it emitted `datatable new/up/down` plus a bare
`datatable migrate`, and mislabeled the datatable command with the migrate
group's description. jobs was broken the same way (its description was pull's,
and pull/push rendered empty).
Anchor block extraction on the `export default`ed command, recurse into
locally-defined `const x = new Command()` groups mounted as subcommands, and
render nested sub-subcommands. Regenerated docs now show
`datatable migrate new/up/down` and `jobs pull/push` with their real
options.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: drop unreleased _wm_migrations legacy-upgrade handling
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: return datatable migration SQL from getItemValue for the diff drawer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(frontend): use windmill-utils-internal 1.8.2 for migration diff drawer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* nit
* nit
* fix: handle datatable migration renames on push and dedupe timestamps
* fix: reject rewriting an already-applied datatable migration on upsert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(frontend): add missing @emnapi/core and @emnapi/runtime lockfile entries
Resolves npm ci EUSAGE failure: the optional cpu:wasm32 @rolldown/binding-wasm32-wasi
declares deps on @emnapi/core@1.11.2 and @emnapi/runtime@1.11.2 that had no resolved
lockfile entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): datatable migrate up/down default to main datatable, not all
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: fail closed when applied status unreadable on datatable migration rewrite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: surface full error detail in Database Manager DDL/query errors
* "See migration" button in the toast
* feat: add Enter shortcut to Create-a-migration in the DDL guard
* fix(frontend): warn before running a newly-created datatable migration out of order
The row-level Run action warns when earlier migrations are still pending, but
the create-and-run paths ran a just-created migration with `only` directly,
applying it ahead of older pending migrations without that confirmation.
Reuse the same "Run migration out of order" confirmation across all
create-and-run paths via a shared helper (datatableMigrationUtils):
- NewDataTableMigrationModal "Create and run" (and the DDL guard path)
- DatatableSchemaDiff fork→parent merge
- dbOps schema ops (DB manager create/alter/drop) — the pure factory throws a
MigrationRunCancelled sentinel on decline, which DBTableEditor treats as a
silent cancel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: keep renamed datatable migrations visible in compare view
* fix: record per-migration deployment on datatable migrations disable
* fix(cli): run deployed datatable migrations after workspace merge
The merge command upserted datatable_migration definitions into the target
workspace and reported the item as successfully deployed, but never ran the
migrations. For forked datatables backed by separate databases, this left the
target schema unchanged until someone manually ran `wmill datatable migrate up`,
while the CLI reported a successful merge.
Collect the datatable migrations deployed (not deleted) into the target and,
after the deploy loop, offer to run them via the existing offerToRunNewMigrations
helper — the same post-deploy run prompt the push/sync path uses (interactive
only; `--yes`/non-TTY skip the mutating run, matching push behavior). Export
parseDatatableMigrationDeployPath so the merge path can parse the deployed items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(backend): serialize datatable migration edits/deletes with the run lock
A migration run snapshots a migration's code_up from datatable_migrations and
only records its version in the data table's _wm_migrations after the job
succeeds. upsert_datatable_migration checked _wm_migrations before allowing an
edit but took no lock, so a concurrent edit could read "not applied yet",
rewrite code_up/code_down, and then the in-flight run would record the version
for the old SQL — leaving _wm_migrations pointing at SQL that was never applied
(migrate up then skips it; rollback runs a down that doesn't match).
Serialize definition rewrites and deletes with the same per-database advisory
lock the run/rollback paths use:
- Factor the connect+advisory-lock into lock_datatable_migration_runs and the
applied-versions read into read_applied_versions_on_client.
- run_datatable_migrations now snapshots the definitions AFTER taking the lock,
so code_up can't change between snapshot and version-record.
- upsert (when changing an existing def) and delete take the lock across the
applied-check and the write; delete now rejects deleting an already-applied
migration (would orphan its _wm_migrations record), symmetric with upsert.
Both fail closed if the data table database is unreachable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(frontend): stack the out-of-order migration confirm above the DB editor preview
Creating a table on a migrations-enabled data table opened the DB table editor's
"Confirm running the following" preview modal, whose confirm triggers applyDdl,
which then asks for out-of-order confirmation. Both are ConfirmationModals with a
hardcoded z-[9999]; the out-of-order one lives in DBManagerContent (mounted before
the editor), so it rendered behind the still-open preview modal.
Add an optional zIndexClass prop to ConfirmationModal (default z-[9999],
backward-compatible) and give the DB-manager out-of-order confirm z-[10000] so it
stacks on top.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 27672e37df5d9dfde94f19963d5ffcdf8dd5448c
This commit updates the EE repository reference after PR #623 was merged in windmill-ee-private.
Previous ee-repo-ref: 6c287041cd7edd4a77a4bc07ad0e156cec32cce4
New ee-repo-ref: 27672e37df5d9dfde94f19963d5ffcdf8dd5448c
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* fix(frontend): debounce external code→Monaco sync in Editor
Make the external `code` prop → Monaco sync always-on and 500ms
debounced, replacing the opt-in `syncExternalCode` prop. Removes the
prop from the two inline rawscript call sites in FlowModuleComponent.
Includes temporary debug scaffolding (A→B executeEdits button and
console logs) for diagnosing successive-edit behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(frontend): share alignCodeWithEditor + bump debounce to 800ms
Extract the full-range executeEdits sync into alignCodeWithEditor() and
reuse it from both setCode and the debounced external-code effect. Bump
the external-sync debounce 500ms -> 800ms. ScriptEditor now calls
editor.setCode when syncing external code in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* nits
* nits
* Fix AI not seeing latest code
* remvoe debug button
* nit types
* nits
* Nits
* Check timeoutModel is undefined
* fix(frontend): suppress editor echo in external code sync to prevent typing clobber
* fix(frontend): cancel pending keystroke debounce in setCode to prevent clobber
* fix(frontend): preserve pending external code write in updateCode
* Revert "fix(frontend): preserve pending external code write in updateCode"
This reverts commit 731d877730.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Imbert <diego@windmill.dev>
* feat(frontend): add user-level toggle to disable Windmill AI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(frontend): hide AI sessions sidebar section when AI is disabled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Db draft removal
* refactor: drop unsaved-changes confirmation modal from editors
* fix: remove nodraft from flow row edit link
* fix: remove nodraft from app and raw app edit buttons
* fix: remove nodraft from all edit links
* fix: merge backend defaults into legacy autosaves to avoid spurious restore toast on raw apps
* feat: add username column to draft table for user-scoped drafts
* feat: add sync_drafts and list_users_with_draft_on_path endpoints
* feat: add UserDraftDbSyncer service for bi-directional draft sync
* feat: wire UserDraft.save through DbSyncer + conflict modal
* refactor: gate useLocalStorageValue nested-update effect behind opt-in flag
* refactor: move sync force flag from request-level to per-entry
* feat: sync all userdraft kinds, switch draft owner to email FK, add id PK, scope draft list to readable paths
* refactor: route draft permission check through authed.folders + RLS, drop client-supplied email
* feat: support draft deletion via sync (value: null) with same conflict semantics
* feat: surface other users' drafts in editors with diff+fork action
* refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum
* perf: add (workspace_id, email, created_at) partial index for sync hot path
* chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd
This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private.
Previous ee-repo-ref: 55c19293232be379a3044eb78f677b545882ffd6
New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd
Automated by sync-ee-ref workflow.
* fix(userdraft): trigger sync on deep mutations via readFieldsRecursively
* Rollback UserDraft
* remove queuing logic
* pushDrafts
* refactor: remove draft sync layer and conflict modal
* feat: add save_draft, list_drafts, get_draft routes
* feat: add get_draft overlay to getScriptByPath
* feat: extend get_draft overlay to flow, app, resource, variable, schedule, triggers
* feat: support null value in save_draft for deletes
* readLastSyncMap
* feat: redirect /add pages to /edit/draft_uuid with new_draft flag
* fix: inline get_draft query field instead of flattening
* fix: drop dangling nobackenddraft assignment in flows edit
* feat: include user drafts in list endpoints with is_draft flag
* fix: prefix draft paths with u/{user} and seed editor state on new_draft
* fix: route draft-only deletes through UserDraftDbSyncer on home page
* feat: delete user drafts when their underlying item is deleted
* fix: empty path seed on new_draft so friendly auto-name fires
* feat: re-add Draft and Draft only badges on home page rows
* fix: synthesize value wrapper on draft-only raw_app response
* fix: tolerate missing latest-version on draft-only flow reload
* fix: skip first observable change in DB sync effect to match LS persist
* fix: remove URL-hash sync from script editor (already marked TEMP)
* refactor: drop localStorage layer from UserDraft
* refactor: drop vestigial LS-era code from UserDraft
* feat: migrate localStorage drafts to DB on layout mount
* fix: migrate session runtime + script view to per-user draft API
* feat: add 'Reset to deployed' action on draft-loaded toast
* feat: hide 'Reset to deployed' action when no deployed version exists
* createCoalescingKeyedRunner
* example ts doc
* createDebouncerByKey
* refactor: drop await on draft-delete in reset flows, refetch deployed directly
* fix: bridge saved-draft shape to wire shape in apps/resources/variables loaders
* feat: route UserDraftDbSyncer.save through debouncer + coalescing runner
* feat: add immediate-save bypass that cancels pending debouncer + runner tasks
* fix: seed UserDraft cell from spec defaultValue on acquire
* fix: redirect /add routes at load phase to eliminate white flash
* fix: drop +page.js files in /add routes that conflicted with +page.ts
* refactor: send draft as separate .draft field instead of deep-merging onto deployed
* feat: surface draft path in home list when user typed one different from URL
* feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init
* fix: thread URL path into ScriptBuilder.stopSync (was using empty initialPath)
* fix: also stopSync in route's new_draft branch + queue pre-acquire suspensions
* feat: add AutosaveIndicator backed by reactive UserDraftDbSyncer.getState
* refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed
* fix: gate per-user draft-only rows in listings on include_draft_only flag
* feat: flush pending draft saves via keepalive fetch on tab hide / pagehide
* autosave indicator nits
* fix: route create-vs-update on /add deploys; seed policy.execution_mode; sync script template
* chore: add [draft-sync] console logs to trace script bootstrap autosave
* fix: seed auto-generated path in script new-draft route to suppress Path widget's autosave-triggering mutation
* fix: defer script restartSync until script.path lands (Path widget gated on $userStore + $workspaceStore)
* fix: poll script.path via tick() until Path widget settles before restartSync
* chore: log inferArgs underlying error on deploy to diagnose 'Could not parse code' toast
* fix: wait for script.path to stabilize across two ticks before restartSync
* revert: drop unsuccessful path-stabilization heuristics + leftover [draft-sync] logs
* fix: seed new-draft script schema as emptySchema() so inferArgs doesn't trip on undefined properties
* fix: heal legacy drafts with schema={} (no .properties) on deploy
* autosave indicator
* refactor(editors): drop UnsavedConfirmationModal mount + Show diff button
* feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker
- Other-users-drafts banner (Modal2): the deployed-overlay response now
carries `other_drafts_users` (workspace usernames only, never emails);
each row offers View JSON + Fork. Drops the standalone
`listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a
workspace `username` query param (resolved to email server-side).
- Cross-tab/browser save conflict detection: the syncer attaches
`last_sync` to every save (defaults to non-force); on a `conflict`
response it parks a snapshot in a reactive map. Each route mounts a
`DraftSyncConflictModal` and seeds the per-tab `last_sync` via
`recordRemoteSync(query, draft_saved_at)` on every `get_draft` load.
Keepalive flush also respects optimistic concurrency.
- Raw app template picker re-added after the /add ⇒ /edit refactor:
framework (React 19 / 18 / Svelte 5), data table + schema config, and
optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and
driven by `new_draft=true` on the edit route.
* fix(drafts): suppress autosave during /add template seeding on script + raw app editors
- ScriptBuilder: delay `restartSync` 500ms past `initContent` + stores-
ready so the Path widget's `$workspaceStore && $userStore`-gated
`initPath → reset → onMetaChange → bind:path` cascade lands inside
the suspension window. Two `tick()` waits weren't enough — the
bind:path mutation fired ~100ms after the prior `restartSync` and
posted as a "user edit".
- apps_raw route: suspend autosave on `new_draft=true` and resume only
after the framework picker closes (via `onStart` or X dismissal),
with a two-tick settle so the picker's seeded
`files/runnables/data/policy` mirror to `draftHandle.draft` observably
advances `lastSerialized` before sync re-arms.
* fix(drafts): land /add redirects on the real workspace username, not "me"
The `/add` → `/edit/u/{username}/draft_{uuid}` redirects ran during
SvelteKit's load phase, BEFORE the (logged) layout's async `getUserExt`
populated `userStore`. `get(userStore)?.username` returned undefined and
fell back to the `'me'` placeholder on every fresh nav, producing
`u/me/draft_{uuid}` paths instead of the user's real namespace — broke
ownership checks against `authed.username` and silently scoped autosaves
under the wrong path.
Layout now persists `username` to localStorage on every successful
`getUserExt`, and `getUsernameForNamespace` (new shared helper, used by
all four `/add/+page.ts` files) reads the live store first, falls back
to the cached value, and only then to `'me'` for true first-ever loads.
* fix(drafts): key low-code app autosave on the URL path, not the empty string
`AppEditor` keyed its `UserDraft.use` handle on `newApp ? '' : path` —
a legacy leftover from when `/apps/add` was its own URL (no path). With
the `/add` ⇒ `/edit/u/{user}/draft_{uuid}` redirect, `newApp=true` made
autosaves land on the `('app', '')` row instead of the URL path:
- The `apps/list?include_draft_only=true` query joins drafts onto
`app.path`, surfacing drafts at the URL path. The empty-path row
didn't match the user's URL so the draft never appeared in the home
list.
- Refreshing `/apps/edit/u/{user}/draft_{uuid}` re-fetches at the URL
path with `?get_draft=true`, finds nothing, and 404s.
Drop the ternary so the handle always uses `path` — the same as
scripts/flows/raw_apps. The route's `?new_draft=true` branch already
seeds the empty-template baseline, so there's no longer a "the
draft sits under '' until first save" race to worry about.
* fix(raw_app): propagate template picker X / Esc dismissal so autosave resumes
The picker mounted `<Modal kind="X" open ...>` (one-way prop, not
`bind:open`). When the user dismissed via X / Esc / click-outside, the
inner Modal flipped its own local `open` to false (hiding the UI) but
never wrote back to the picker's `open` $bindable. The route's
`templatePicker → false` watcher — the one that calls `restartSync`
two ticks after the picker closes — never fired, so autosave stayed
suspended and the user's edits after dismissal were silently dropped.
Switch the inner Modal to `bind:open` so the dismissal bubbles all the
way up to the route's state. "Start without AI" already worked because
its `onStart` handler explicitly sets the picker's `open = false`.
* nit unused
* fix(drafts): make the home-page View/Edit JSON action work on draft-only apps
The "View/Edit JSON" entry on the home page called `AppService.getAppByPath`
without `get_draft=true`, so for draft-only items at `u/{user}/draft_{uuid}`
the backend 404'd with "App not found at path …". Pass `get_draft=true`
and render the synthesized stand-in's editable shape:
- App drafts come back as `{summary, value, path, policy, ...}` — `value`
is the App definition the editor was working on; show that.
- Raw-app drafts come back as the flattened
`{files, runnables, data, summary, policy, ...}` with no nested `value`;
show the whole shape.
On save, draft-only items can't go through `updateApp` (no deployed row).
Route the edit through `UserDraftDbSyncer.save` (with `immediate: true`
so `await` resolves after the POST lands) and relabel the button
"Save draft" + Save icon. Deployed items keep the existing "Deploy"
flow unchanged.
* fix(drafts): render the right shape in View/Edit JSON for draft-only items
The previous fix landed `fapp.value` into the editor, but the
deployed-overlay flattens the bare editable shape into `inner`/the
top-level response — drafts have no nested `.value`. So:
- App drafts (`{grid, breakpoints, hiddenInlineScripts, …}`) rendered
as empty (`fapp.value` was undefined).
- Raw-app drafts 404'd outright: `get_draft=true` with no `rawApp` flag
can't tell which draft kind to look up, defaults to `app`, doesn't
find one.
Thread the row's `raw_app` flag from AppRow → `appExport.open(path,
rawApp)` → `getAppByPath({..., rawApp})` so raw-app drafts resolve to
the right `UserDraftItemKind`. Read `fapp.draft` (the bare editable
shape from `fetch_draft_only`) into the JSON editor for draft-only
items — clean payload, no `is_draft` / `no_deployed` / overlay noise.
Save the same bare shape back through the syncer so the regular
editor reads it unchanged on the next mount.
* fix(drafts): skip public-secret-URL fetch in the Deploy drawer for draft-only apps
Opening the Deploy drawer on a `/edit/u/{user}/draft_{uuid}` app fired
`AppService.getPublicSecretOfApp` immediately because the gating effect
only checked `appPath != ''` + `savedApp`. The `/secret_of/{path}` route
plain-SELECTs `app.id`, so a draft-only path 404'd with
"App not found at name …" and the public-URL ClipboardPanel spun
forever waiting on `secretUrl`.
Thread the existing `newApp` signal (already on `AppEditorHeader` /
`RawAppEditorHeader`) into `AppEditorHeaderDeploy`, gate the fetch
behind `!newApp`, and render the existing "Deploy this app once to get
the public secret URL" placeholder instead of the spinner for
draft-only items.
* fix(drafts): disable Diff button on draft-only items across the 4 editors
Diff has no baseline to compare against on draft-only items — the
button used to be gated by the pre-PR `/add` route's own state, but the
`/add → /edit` redirect landed everything under the regular `/edit`
page where the gate was missing.
- ScriptBuilder: gate the topbar Diff on `savedScript.no_deployed`;
seed `no_deployed: true` on the route's `new_draft` empty NewScript
so the gate fires before the first deploy.
- FlowBuilder: gate the topbar Diff on `newFlow` (route already sets
it from `backendFlow.no_deployed` and the new-draft branch).
- AppEditorHeader: gate both the "Diff" dropdown action and the
Deploy-drawer's "Diff" button on `newApp`.
- RawAppEditorHeader: gate the topbar Diff + the Deploy-drawer's "Diff"
button on `newApp`.
Each gate also rewrites the tooltip ("Deploy this … once to compare
against the deployed version") so the hover state explains why.
* fix(drafts): disable the "No login required" toggle on draft-only apps
Flipping the toggle called `setPublishState`, which POSTs the new
`policy` through `AppService.updateApp` — that handler's
`UPDATE app ... RETURNING path` finds nothing on a draft-only path
and `not_found_if_none` 404s with "App not found at name …"
(apps.rs:1975). Gate the Toggle on `!newApp` too so the user has to
deploy once before configuring the publish state.
* refactor(drafts): drop dead draft_path field from list responses
The draft-only listing branches in scripts/flows/apps computed a
`draft_path` from the draft JSON (when the user-typed path differed from
the URL's autogenerated `u/{user}/draft_{uuid}`), and `{Script,Flow,App}
Row.svelte` preferred it over `path` for the row title. In practice
that path is never written: the app, raw-app and flow editors all warn
"Deploy the X to make the path change effective" — the rename only
lands on deploy, never in the draft. So the field is always None and
the home rows always show the autogenerated slot anyway.
Drop the field from the three `Listable*` structs, the three draft-only
push sites, the three OpenAPI response schemas, and the three frontend
row components. Client regenerated.
* fix(drafts): seed a friendly name on /flows/add
The flow route passed `initialPath={page.params.path ?? ''}` to
FlowBuilder, so on the `/flows/add → /flows/edit/u/{user}/draft_{uuid}`
redirect the Path widget's `initPath` saw a non-empty `initialPath` and
skipped the `reset()` branch that auto-generates the friendly
`<random_adj>_flow` name. The other three editors all clear
`initialPath` in their `new_draft` branch for exactly this reason.
Track `initialPath` as route-owned state (defaults to the URL path) and
clear it to '' inside the `new_draft` branch, then bind it through to
FlowBuilder so any post-deploy update from the editor still propagates.
* feat(drafts): render friendly user-typed path on home list for all 4 kinds
Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows
prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}`
URL slot, with two source rules — one per how each editor wires the
Path widget:
- Scripts already work: `ScriptBuilder` binds the Path widget directly
to `script.path`, so the typed path round-trips through the draft
JSON's own `path` field. Backend extracts `v["path"]` when it differs
from `row.path`.
- Flows / apps / raw apps don't write the typed path into the
autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the
bare `App` / raw-app value has no `path` field at all). Introduce an
explicit `draft_path` field on the draft JSON, written by the editor
ONLY when the typed path differs from the deployed/seeded
`savedX.path`:
- FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`.
- AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`.
- RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the
bind chain (RawAppEditor → route); the route's draftHandle.draft
spread includes `draft_path` when set.
Backend extracts `v["draft_path"]` and `None` when unchanged or after
deploy (deploy clears the whole draft, so the field naturally
disappears post-deploy without bookkeeping).
Flow route's `new_draft` branch now stops sync around the Path widget
cascade, with a 700ms scheduled `restartSync` (mirrors the existing
scripts/apps/raw_apps stoppers) — the new draft_path mutation lands
inside that window so `/flows/add` no longer fires an autosave before
the user's first edit. openapi/sqlx regenerated.
* fix(drafts): preserve the user-typed draft_path on reload of draft-only items
The flow / app / raw-app editors all dropped the saved `draft_path`
back to the URL's `u/{user}/draft_{uuid}` slot the moment the user
reloaded a draft-only edit page: the route sourced the Path widget's
initial path from `page.params.path` instead of the previously-saved
`draft_path`, and the first user edit then mirrored that URL path
back into the autosaved draft — silently overwriting the friendly
name in both the row and the editor.
- Flow route: after computing `effectiveFlow`, override `flowInitialPath`
with `effectiveFlow.draft_path` when set.
- App route: pass `newPath={(app.value as any)?.draft_path ?? app.path}`
through to `AppEditor`; AppEditorHeader's `newEditedPath` default now
prefers a non-empty `newPath` over the random `<adj>_app` seed (the
`newApp && !newPath` branch keeps the `/apps/add` friendly auto-name).
- Raw-app route: surface `savedRawAppDraft.draft_path` onto `backendApp`
so the `extractRawApp` path seeds `newPath` with the friendly name.
Reload + a subsequent edit now leaves `draft_path` intact for all three
kinds; verified end-to-end via the `/drafts/get_draft/...` endpoint.
* fix(ui): default Modal2 target to 'body' so omitting the prop doesn't throw
Modal2 defaulted `target = ''` and forwarded it to `Portal`, which calls
`document.querySelector(target)` — an empty selector throws
"Failed to execute 'querySelector' on 'Document': The provided selector
is empty" and the modal silently fails to mount.
That's why `OtherUsersDraftsModal` (and `DraftSyncConflictModal`) never
appeared on editors where another user had a draft — both omit the
`target` prop. Other Modal2 callers (StorageSettings, CriticalAlert,
CustomInstanceDbWizardModal, …) pass an explicit `target="#content"`
and were unaffected.
Match Portal's own default of `'body'` so omitting the prop is now a
no-op rather than a runtime throw.
* fix(drafts): Reset to deployed no longer resurrects the draft
The toast's "Reset to deployed" callback POSTed `value: null` to the
syncer, then handed control to the route's `onResetToDeployed` (which
wipes the in-memory handle and reloads the deployed payload via
`getDraft: false`). Both writes flowed through the reactive sync
effect: the wipe scheduled a delete, the reload scheduled a re-save of
the deployed value as the new draft. Coalescing collapsed them and the
draft came back — making the "discard" action effectively a no-op.
Wrap the whole callback in `UserDraft.stopSync` / `restartSync`. The
explicit `value: null` POST still goes through (it's a direct
`UserDraftDbSyncer.save` that doesn't depend on the reactive effect),
the route's wipe-then-reload mutations advance `lastSerialized` silently
under suspension, and the next user edit (after two ticks past the
deployed-seed write) is the first real save again.
* ui nit
* feat(drafts): autosave-indicator popover with Reset-to-deployed action
Click the cloud icon → popover with "All changes are saved as a draft on
the server. The draft is per-user — your teammates' editors keep their
own." When the editor isn't on a draft-only path AND the user has a
draft (UserDraft.has returns true), a "Reset to deployed" button
mirrors the load-time toast action — stops sync, POSTs `value: null`,
runs the route's reload-without-draft callback, restarts sync past two
ticks so the deployed-seed write doesn't resurrect the draft.
Threaded `onResetToDeployed` from each route down to its builder
(ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader)
and into the indicator. `draftOnly` is wired from `savedScript.no_deployed`
/ `newFlow` / `newApp` so the action hides where there's nothing to fall
back to. The indicator's trigger now has a hover affordance + matches
Portal's default target ('body') via Modal2's earlier fix.
* fix(drafts): wait for the fork POST to land before navigating
OtherUsersDraftsModal's Fork action called UserDraft.save, which routes
through the autosave debouncer (1500ms). The subsequent goto fired
within the same tick, so the destination editor's get_draft=true read
ran before the POST landed and 404'd — refreshing worked because by
then the debounced save had fired.
Call UserDraftDbSyncer.save with immediate: true and await it. The
syncer cancels any queued debouncer task for the key and resolves the
promise only after the POST completes, so the route load can find the
forked draft on the first try.
* fix(drafts): conflict detection — keep last_sync map tab-local instead of in localStorage
Two tabs editing the same draft both load with last_sync = T0.
Tab-1 saves; the server accepts, returns T1, and the syncer wrote T1
into localStorage. Tab-2 then tries to save: it reads the SHARED
localStorage map, sees T1 instead of its own baseline T0, sends
last_sync = T1, and the backend's WHERE clause (`created_at <=
last_sync`) is true → tab-2 clobbers tab-1's edit without ever seeing
a conflict.
Move the map to tab-local memory (`new Map<string, …>`). Reload of the
tab now starts with an empty map; that's fine because the editor's
load path calls `recordRemoteSync(query, draft_saved_at)` right after
`get_draft=true` returns, reseeding from the authoritative server
timestamp before any user edit could fire a save.
* fix(drafts): OtherUsersDraftsModal — close on Fork, don't leak clicks through nested JSON
Two bugs in the per-editor "another user has a draft" banner:
- Fork landed the immediate save but didn't close the banner before
navigating. Svelte hadn't torn down the previous route's components
by the time goto returned, so the banner lingered on top of the
destination editor. Comment the explicit isOpen=false on the
happy path so it's clear it MUST run before goto.
- Clicking anywhere on the screen while the View JSON drilldown was
open closed the underlying banner too. Modal2's clickOutside
action fired on every Modal2 instance — both the JSON modal and
the underlying banner — because both attach their own listener at
the document level. Add `closeOnOutsideClick` opt-out on Modal2
and pass `closeOnOutsideClick={!jsonOpen}` to the outer modal so
clicks outside the JSON drilldown only close the drilldown.
Drive-by: Modal2's keydown handler now ignores Escape when its own
isOpen is false (was a no-op closer that would still preventDefault
on every key press, swallowing key events for any siblings).
* fix(drafts): conflict modal wording — drafts are user-scoped, not teammate-scoped
* fix(drafts): defer reset-to-deployed restart until first user interaction
Two-tick `restartSync` was too aggressive: editor remounts emit a tail
of cascading writes (Monaco setValue acks, schema re-infer, UI Builder
iframe handshakes, schedule-config recomputes, …) that land well after
two ticks and would clobber the just-deleted draft with an upsert of
the deployed value — making "Reset to deployed" a no-op in practice,
the user kept seeing the draft come back.
Centralise the suspension lifecycle in a new `runResetToDeployed`
helper. It stopSyncs around the reset, POSTs the explicit delete, runs
the route's wipe-and-reload, and then arms a one-shot listener on
document keydown / input / pointerdown that restartSyncs on the user's
next real interaction. A 5-second fallback re-arms sync if the user
walks away without touching the editor, so suspensions don't leak.
Use it from both the load-time toast (`notifyDraftLoaded`) and the
autosave-indicator popover so the two stay in sync — fixes both
entry points.
* indicator ui nits
* fix(drafts): split tab-switch and unload flushes — kill self-conflict on visibility change
The single keepalive flush bound to both `visibilitychange → hidden`
and `pagehide` self-conflicted on tab switch: visibilitychange fires
on every tab/app switch with the page still alive, the keepalive POST
advanced the server's `created_at` to a fresh `now()`, the client
discarded the response (no listener), the local `lastSync` stayed at
the old value, and the next foreground autosave sent that stale
timestamp → server saw `created_at > last_sync` → conflict modal for
the user's own background-tab write. A still-pending debouncer task
made it worse: it fired a second runner POST after the keepalive with
the same stale `last_sync`, the second self-conflicted too.
Split into two paths:
- `visibilitychange → hidden` → `flushOnVisibilityHidden`: route
through the normal runner pipeline. The page is alive, so the
response can land and `setLastSync` keeps the baseline current. Call
`debouncer.cancel(key)` first so a queued keystroke can't double-fire
with the same stale `last_sync`.
- `pagehide` → `flushOnPageHide`: keep the `keepalive: true` raw fetch
for the genuinely-going-away case (the JS context is torn down, the
response is necessarily discarded). Same `debouncer.cancel(key)`
guard. On the next mount, the route's `recordRemoteSync(query,
draft_saved_at)` reseeds `lastSync` from authoritative server state
before any user edit can fire a save.
* fix(drafts): drop the visibilitychange flush — debouncer keeps running on hidden tabs
Tab switching just hides the page; the JS context survives and the
debouncer's `setTimeout` keeps counting down. When it fires, the runner
POSTs normally and the server's response updates `lastSync`. There's
nothing left for a visibilitychange-driven flush to do that the
ordinary pipeline doesn't already handle, and adding one only creates
extra POSTs to reason about.
`pagehide` remains the single trigger for the keepalive flush — that's
the case where the JS context is actually being torn down and the
runner's pending fetch would otherwise be killed mid-flight.
* nit
* refactor(drafts): drop LS-era pipeline; backend is canonical on load
The PR's iteration left behind a meta/staleness pipeline carried over
from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a
'Restored from local storage' toast, and a localDraft-vs-backend
comparison branch in every editor loader. With drafts now living in
the DB and the optimistic-concurrency lastSync check handling
divergence, that whole stack is dead weight.
Worse, the comparison branch caused 'Load from server' in the conflict
modal to do nothing: the loader preferred the in-memory cell over the
backend, so the user-clicked 'load from server' just re-displayed the
local edits AND fired two confusing toasts (Restored from local
storage + Loaded your saved draft).
The rip:
* userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta,
checkStaleness, UserDraftStalenessCause, normalizeForCompare,
localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta,
handle.meta/setDraftAndMeta/setMeta, force option. Handle is now
just { draft }.
* userDraftToast.ts: drop notifyRestoredFromLocal +
RestoreFromLocalActions. Update copy.
* LocalDraftStaleModal.svelte: deleted.
* AppEditor.svelte: drop initialRevs prop and the firstMirror
wipe-then-restore dance (it existed only to consume the meta-mismatch
skip slot).
* All 4 editor routes: backend is canonical on load — the in-memory
cell is overwritten with the deployed+draft overlay, the syncer's
seed guard swallows the first write so we don't POST it back.
* VariableEditor / ResourceEditor: drop the staleness pipeline + rev
bookkeeping; backend wins on open.
* useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual
utility as a private cfgDiffers helper (kept for the form-vs-deployed
dirty check, which is a genuine semantic compare, not LS legacy).
* copilot core.ts / userDraftAdapter.ts: drop meta argument from
saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on
getMeta dropped.
Net: -22 typecheck errors, fewer moving parts, conflict modal works.
EOF
)
* refactor(drafts): remove dead endpoints + UserDraftDbSyncer.getLastSync
The list_drafts and get_draft (own) routes were added during PR
iteration and never wired up to any frontend caller — the editor
overlay path uses the per-kind get-by-path getDraft query parameter,
and the home page lists drafts via the per-kind list endpoints, not
via /drafts. Drop both routes (+ sqlx caches + OpenAPI entries).
UserDraftDbSyncer.getLastSync was a peep-hole for callers that never
materialised — the per-tab lastSync map is only ever read by postSave
internally, where the bookkeeping already lives inline.
* refactor(drafts): extract DraftEditorModals trailer block
The four editor routes (scripts/flows/apps/apps_raw) mounted an
identical pair of trailer modals — DraftSyncConflictModal +
OtherUsersDraftsModal — wrapped in the same guard chain and {#key path}
remount. Lift the markup into one component; routes thread their
itemKind, path, editPathFor, and loader callback.
Pure markup extraction, no state ownership change. Drops the unused
userStore import where the trailer was the only consumer.
* refactor(drafts): UserDraft.useReactive — kill array-of-one boilerplate
The script + flow routes both wanted a handle that re-keys when the URL
path changes. UserDraft.use() can't do that (its opts getter is
untracked), so each route hand-rolled the same useMany-array-of-one +
proxy idiom:
const handles = useMany(() => [{ kind, path: reactive }])
const handle = { get draft() { return handles[0]?.draft }, ... }
Add UserDraft.useReactive(getSpec) that internally wraps useMany with a
single spec and returns the stable proxy. Callers collapse to one line.
* refactor(drafts): unify bootstrap suspension via armRestartOnFirstInteraction
The flow and raw-app routes each rolled their own end-of-bootstrap
resume: a 700ms setTimeout for flows and a templatePicker watcher with
double-tick gating for raw-apps. Both are timing-fragile (the comments
admit it) and drift from each other.
armRestartOnFirstInteraction already existed in userDraftToast.ts for
reset-to-deployed: keydown/input/pointerdown listeners (capture phase)
that fire restartSync on the first real user touch, with a 5s
belt-and-braces fallback. Export it and use it everywhere we'd previously
have picked a magic number.
For raw-apps this is a tiny behavioural change: the user's template
choice now POSTs immediately (the pointerdown that picks the template
also resumes sync, so the picker's onStart write rides the wake-up).
Previously the choice only persisted on the user's NEXT edit. That's
strictly better — navigating away preserves the choice now.
* refactor(drafts): type App.draft_path; drop the as-any cast
The audit asked for the three editors to converge on one draft_path
injection pattern. For App and Flow, the in-builder $effect-mutates-
the-store idiom is wedged into a shape that doesn't natively own the
field — App's editor type genuinely has no draft_path so the writer
had to cast through `as any`, and consumers downstream did the same.
The minimum viable fix: declare draft_path on the local App type
(it's already a field on the autosaved JSON). Lifting the writes
upward into a route-side merger would mean restructuring the
AppEditor mirror $effect and the FlowBuilder pathStore plumbing —
larger change for the same shape, deferred to a follow-up.
Flow already has the typed cast localised at one site. Will get the
OpenAPI-level draft_path field as part of task 47 (drop as-any
casts on backend overlay reads).
* refactor(drafts): extract makeDraftAddLoad helper
Four identical /add/+page.ts files differing only by the edit-route
prefix. Lift the redirect into a factory, slim each entry point to
two lines.
* refactor(drafts): type UserDraftOverlay.other_drafts_users in the OpenAPI
The backend response carried other_drafts_users on every get-by-path
that supports the draft overlay, but the OpenAPI schema didn't declare
the field. Each route had to cast the typed response to `any` to read
it (and the sibling draft_saved_at), which obscured the real shape from
the type system and rotted the discoverability of the draft surface.
Add it to UserDraftOverlay. Frontend casts collapse to plain property
reads in the three editor routes.
* feat(drafts): list & open draft-only items for variables, resources, schedules, triggers
For scripts/flows/apps the list and get-by-path endpoints already
surface per-user drafts that have no deployed counterpart — that's
what gates the home page from 404'ing on an AI-agent-created draft.
Extend the same support to the other UserDraftItemKinds:
Backend (list endpoints):
- Add include_draft_only to ListVariableQuery, ListResourceQuery,
ListScheduleQuery, StandardTriggerQuery (the latter covers the
11 trigger kinds via the generic TriggerCrud).
- Append per-user draft rows whose path has no deployed row. Same
gate as scripts/flows/apps: non-operators, page 0, no narrowing
filters. Synthesis is per-kind: ListableVariable/Resource get
field-for-field synthesis; ScheduleLight reads NewSchedule shape;
Trigger<T> uses a best-effort JSON merge + serde_json::from_value
(rows skipped on deserialize failure rather than failing the list).
- Add draft_only: Option<bool> with sqlx(default) to each row type
so it serializes as the column is opt-in.
Backend (get-by-path endpoints):
- get_variable, get_resource, get_schedule, get_trigger<T> fall back
to fetch_draft_only when the deployed row is missing and the
caller passed get_draft=true. Mirrors scripts/flows/apps.
OpenAPI:
- Shared IncludeDraftOnly parameter under components/parameters,
wired into the 11 trigger list endpoints + listRawApps. Inline
declarations on listVariable / listResource / listSchedules /
listAzureTriggers.
- draft_only field on ListableVariable, ListableResource,
Schedule, TriggerExtraProperty.
Frontend:
- variables, resources, schedules, and the 10 trigger list pages
(routes + 9 *_triggers) pass includeDraftOnly: true on the
initial fetch and render <DraftBadge draft_only> on synthesized
rows. Trigger pages got a sed/perl bulk update — pattern is the
same across kinds.
* fix(drafts): swap crypto.randomUUID() for the project's randomUUID helper
crypto.randomUUID() is gated on a secure origin (HTTPS or localhost).
Self-hosted Windmill instances often run on a bare HTTP origin or a
LAN IP where the WebCrypto API is unavailable, so the /add redirect
would throw before issuing the 307. Use the existing RFC4122 v4 helper
in FlowChatManager that the rest of the codebase already imports for
this exact reason.
* fix(editor): leading-edge fire + max-wait cap on Monaco debounce
The Editor debounced `onDidChangeModelContent` purely on the trailing
edge — every keystroke rescheduled a 500ms timer, and uninterrupted
typing held the bindable `code` prop stale until a pause. Stacked
behind our 1.5s autosave debouncer that meant our clock didn't even
start ticking until 500ms after the user paused, and the `code`
binding never updated mid-burst for downstream consumers (lint,
live preview, change listeners).
Switch to leading + trailing + max-wait:
* First keystroke of a burst fires `updateCode` synchronously, then
stamps a wall-clock chain start.
* Each subsequent keystroke (re)arms a trailing timer at
`min(now + changeTimeout, chainStart + maxChangeTimeout)` — the cap
is what makes continuous typing materialize at least once per
maxChangeTimeout window instead of indefinitely.
* When the trailing fires it resets the chain so the next keystroke
after a pause is a fresh leading fire.
New prop `maxChangeTimeout` (default 1000ms) sits next to the
existing `changeTimeout` (default 500ms). Dispose path clears the
chain stamp alongside the timer.
* feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately
Each builder already had a Ctrl/Cmd+S keybinding routed through a
saveDraft() no-op left over from the LS-era — the comment said
"persistence happens via the page-level UserDraft autosave" but the
shortcut was the user's only way to actually force a save without
waiting for the 1.5s debounce. Restore the intent.
* UserDraftDbSyncer.flush({ workspace, itemKind, path }) — new method
that re-submits whatever's queued in pendingSaveOpts with
immediate: true. No-op when nothing's pending.
* Editor.svelte.flushPendingChanges() — exposes a synchronous
updateCode() with chain reset, so callers can drain Monaco's own
trailing debounce before asking the syncer to flush. Without this
step a Ctrl+S within ~500ms of typing would POST the pre-burst
content.
* ScriptBuilder.saveDraft() — editor?.flushPendingChanges() →
await tick() → UserDraftDbSyncer.flush(). Toast on result.
* FlowBuilder.saveDraft() — no direct Monaco ref (flows have many
per-module editors); just flushes the syncer. Editor.svelte's new
1s max-wait cap means at most the last <1s of typing in a module
Monaco won't be in this POST; it follows in the next autosave
round.
* RawAppEditor.handleKeydown — adds a 's' case that flushes before
the focus guard, so the shortcut fires regardless of where focus
is in the editor pane.
* fix(drafts): low-code apps — drop spurious autosave on /edit + remount on Load from server
Two bugs in low-code app editor (raw apps use a separate code path):
1. Every /edit visit looked like an autosave because loadApp() called
UserDraft.discard('app', path, undefined). The comment claimed
"this load doesn't POST" but discard always POSTs value: null
server-side — that surfaced as a DELETE-my-draft on every page
load AND a flash in the AutosaveIndicator.
The discard was originally intended to wipe the in-memory cell so
AppEditor remounts "fresh". But the path-change $effect upstream
already sets app = undefined before each loadApp, which unmounts
AppEditor and releases the handle's entry — so a remount via
app = backendApp naturally starts with an empty handle. Drop the
discard.
2. The conflict modal's "Load from server" called loadApp() but
didn't remount AppEditor. Since AppEditor's stateApp is captured
once at mount and doesn't react to prop changes, the editor kept
showing the conflicting local edits even after a successful reload.
Wrap the onLoadFromServer to await loadApp() then bump redraw to
force a fresh mount.
* feat(drafts): home-page Draft badge — show user-initial circles, drop the '+'
The home-page Draft badge previously showed '+Draft' as a flat label.
Add per-user awareness: up to 3 user-initial circles render to the left
of the label, ordered alphabetically; with 4+ users we collapse to the
first 2 + a '+N' overflow circle so rows stay compact.
Backend:
* New `DraftUserRef { username: Option<String> }` in
windmill-types::user_drafts, re-exported from windmill-common so the
list endpoints in scripts/flows/apps crates share one import path
(windmill-types/windmill-common can't be reordered without a cycle).
* ListableScript / ListableFlow / ListableApp gain a
`draft_users: Option<sqlx::types::Json<Vec<DraftUserRef>>>`
field. The list SQL adds a per-row subquery
`SELECT json_agg(...) FROM draft d LEFT JOIN usr u ...` that
aggregates the workspace users with a per-user draft at this path.
NULL (no drafts) decodes to None; LEFT JOIN against `usr` lets
orphaned drafts (user removed from workspace) still surface with
username = None.
* Synthesized draft-only rows set draft_users to a single-element
vector with the authed user (those rows come from `email = $2`).
OpenAPI: `draft_users` added to listScripts / listFlows / ListableApp
response shapes as an array of `{ username }` with nullable username.
Frontend DraftBadge:
* Accepts `draft_users: { username?: string | null }[]`. Renders up
to MAX_CIRCLES (3) initial circles; at 4+ users renders first 2 +
a gray '+N' overflow circle.
* Initials: 'john.doe'/'john_doe' → 'JD', 'alice' → 'AL', the legacy
NULL-email row → '?'.
* Color picked deterministically from a 6-entry palette so the same
user gets the same circle color across rows.
* Label is now just 'Draft' (dropped the '+'). 'Draft only' is
unchanged.
* Tooltip lists every user in full.
ScriptRow / FlowRow / AppRow thread `draft_users` through their
prop types and pass it to DraftBadge.
* fix(drafts): suppress 'You have unsaved changes' banner when deployed baseline is null
A brand-new variable/resource/trigger (no deployed row yet) has
`getDeployed() == null`, but the caller's `show` prop is computed
off `current != deployed` which is trivially true while the user
types. Result: the banner appeared with 'Show diff' (no-op — the
drawer early-returns on null deployed) and a 'Discard' that's
semantically backwards (there's nothing to revert to).
Gate `show` internally on `getDeployed() != null`. The check sits
in the banner rather than each caller because every caller would
otherwise need the same boilerplate guard.
* fix(drafts): hide LocalDraftBanner when deployed and current match the DiffDrawer's compare
Earlier I gated the banner on `getDeployed() != null`, but the user
still saw it fire on entries where 'Show diff' opens to 'No changes
detected'. That means `show` (the caller's coarse dirty check) flagged
a difference the DiffDrawer treats as a no-op — typically toggle
defaults (`false ↔ undefined`), removed empty arrays, or key-ordering
noise that `cleanValueProperties + orderedYamlStringify` collapses.
Replicate the drawer's comparison inside the banner: stringify both
sides through the same pipeline and only render when the keys differ.
A single `diffKey()` helper keeps the logic local; the catch-and-empty
fallback survives a non-serializable side rather than throwing.
* ui(drafts): nest user-initial circles inside the Draft badge
Previously the circles sat alongside the Badge in a parent flex
container; the result read as two separate UI elements. The Badge
component already exposes its children as a snippet rendered inside
its own flex row, so moving the circles into it makes them feel like
part of the same chip.
Knock-on tweaks: shrunk the circles from h-4/w-4 to h-3.5/w-3.5 so the
badge stays compact, and tinted each circle's ring with the badge's
indigo palette (instead of plain white) so the overlap reads as a
deliberate stack rather than dots floating on top of the chip.
* feat(drafts): drop the authed user's circle, mark own drafts with a '*' suffix
Three tweaks to the home-page Draft badge:
1. Filter the authed user out of `draft_users` before rendering
circles. The row already signals 'this user has a draft' via the
asterisk (below), so a circle for them would be redundant noise.
New `currentUsername` prop on DraftBadge — pass
`$userStore?.username` from each row. The tooltip still lists every
user (with `(you)` next to the authed one) so the full picture is
one hover away.
2. The badge already showed whenever `is_draft || draft_users.length > 0`
(per-user OR any-user). Spelled the rationale out in a comment —
no logic change.
3. Append '*' to the displayed summary when `is_draft` is true. Falls
back to `draft_path`/`path` when summary is empty so the marker
never decorates an empty string. Threaded the same expression into
ScriptRow / FlowRow / AppRow.
Slice/overflow math now keys on the post-filter `otherUsers` list, so
dropping the authed user doesn't silently shrink the visible count
(e.g. 3 users incl. self → 2 circles, not 1 circle + a '+1' bubble).
* feat(drafts): clone per-user drafts when forking a workspace
`clone_workspace_data` clones every other workspace-scoped table on
fork creation (resources, variables, scripts, flows, apps, raw apps,
triggers, schedules) but quietly dropped the `draft` table. With
per-user drafts that meant any open editor in the parent lost its
pending edits the moment a fork was created — surprising and
inconsistent with how forks treat the deployed surface.
New `clone_drafts` mirrors the existing clone helpers: a single
INSERT...SELECT into the target workspace, preserving `path`, `typ`,
`value`, `created_at`, and `email`. The `email` FK targets
`password.email` which is instance-scoped so it carries across
workspaces without remap. `created_at` is preserved on purpose so the
per-tab `last_sync` baseline lines up with the parent's timeline —
otherwise the fork's next autosave would race a stale `last_sync`
and trip the conflict modal on every cloned draft.
Plain INSERT (not UPSERT) is safe because the fork target is empty at
create time; no conflict against the partial unique indexes
(`draft_pkey_with_user` / `draft_pkey_legacy`). The synthetic
BIGSERIAL `id` PK is regenerated by the default so it stays out of
the column list.
* ui(drafts): pin the authed user to the first circle instead of hiding them
Previously the authed user was filtered out of the circle row entirely
on the theory that the row's '*' suffix already signalled 'this user
has a draft'. New requirement: they should always lead the circle row
when they have a draft so the visual half of the signal lines up
across rows (consistent leading-slot identity, easy scan).
Switch from a filter to a sort: `orderedUsers` finds the authed user
in `draft_users` and splices them to index 0; everyone else keeps the
backend's alphabetical order behind. Slice/overflow math now keys on
`orderedUsers`, which guarantees the authed user never falls into
the '+N' bubble — they're at position 0 and the slice keeps the head.
The popover's '(you)' annotation moves to the circle's title attr too,
so hovering the leading circle confirms the identity.
* feat(drafts): drop draft_only column from script/flow/app
Drafts now live in the `draft` table exclusively — `draft_only` stubs in
script/flow/app are redundant. Migration `INSERT INTO draft ... ON
CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING` so
real per-user drafts already at the same path are preserved; only rare
stubs that lost their draft get a synthesised workspace-level row.
Stubs are then deleted (FKs cascade to *_version) and the column is
dropped. List endpoints keep a synthesised `draft_only: true` on rows
sourced from the draft table itself (sqlx default on the struct field).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ui(drafts): surface draft state in AutosaveIndicator instead of toast+auto-modal
The "Loaded your saved draft" toast and the auto-opening
OtherUsersDraftsModal both surprised users on every editor mount. Move
both signals into the AutosaveIndicator label: "Loaded from draft" or
"Others are working on this {kind}" (priority) sits where Saving/Saved
do, with a one-shot light-green flash behind the indicator that fades
to transparent. Saving/Saved still win when they fire. The popover
gains a "See others' drafts" button that flips the modal open on
demand; the modal itself is now externally controlled via a bindable
\`isOpen\` threaded through DraftEditorModals.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ui(drafts): per-user View JSON / Fork actions in DraftBadge popover
Hover popover used to be a plain text list of usernames. Now each row
gets a colored circle icon + name + "(you)" for the authed user, and
every OTHER user's row carries View JSON / Fork buttons mirroring the
OtherUsersDraftsModal. For draft-only entries owned solely by the
authed user, the popover ends with "Only you can see this {kind}" so
the row's privacy is obvious. ScriptRow / FlowRow / AppRow thread
workspace + itemKind + path + editPathFor through; AppRow switches
between app / raw_app on app.raw_app.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* nit
* fix(drafts): clone only the forker's per-user drafts on workspace fork
clone_drafts copied every user's drafts, but only the forker gets added
to the fork's usr table. Drafts owned by absent users LEFT-JOIN to NULL
in the home page's draft_users aggregate, surfacing as multiple
legacy-style rows at one path and crashing the popover with
each_key_duplicate. Filter the clone to email = forker OR email IS NULL,
and key the popover's #each by index defensively so future legacy
collisions can't crash the page either.
Also re-adds `draft_only: None` to NewScript/CreateFlowBody literals in
tests — the auto-generated windmill-api-client still carries the field
and the previous commit dropped them too aggressively.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): always populate other_drafts_users in maybe_overlay_draft
Reset-to-deployed reloads the deployed payload with get_draft=false,
which made the backend return other_drafts_users=[]. The route then
reassigned otherDraftsUsers to the empty list, dropping the count to
0 and hiding "See others' drafts" in the AutosaveIndicator popover —
but the other users' drafts hadn't actually gone anywhere. Fetch the
list independently of get_draft so the popover stays accurate across
reset reloads.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(drafts): alert user when their draft is older than the latest deploy
Open a modal on editor mount when the per-user draft was saved before
the latest deploy at the same path — i.e. a teammate deployed a new
version while this user's draft was sitting. Two choices: discard the
stale draft and pick up the deploy, or keep editing the older draft.
DraftEditorModals computes the staleness from the timestamps each route
threads in (script.created_at, flow.edited_at, app_version.created_at)
and the "Load latest deploy" callback reuses the route's existing
reset-to-deployed logic. Wired for script / flow / app / raw_app
editors; trigger / resource / variable drawer editors follow a
different pattern and aren't covered here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): deploy only wipes the deployer's draft, not everyone else's
Script / flow / app deploys ran an unconditional DELETE on every draft
at the path, so a teammate's deploy silently destroyed any other
user's pending draft. After the wipe, the other user's tab kept
auto-saving — re-creating the row at a NOW timestamp newer than the
deploy — and StaleDraftModal never fired because draft_saved_at had
been bumped past the deploy. Filter the DELETE to email = deployer
(plus the legacy NULL row), so other users' drafts persist and the
stale-draft prompt actually fires on their next reload.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): surface save failures in AutosaveIndicator instead of pretending Saved
postSave caught network errors with `console.error` and let the runner
finish normally. The indicator read the saving → none transition as a
successful save and flashed "Saved" even when the request had thrown.
Track failed keys in a SvelteMap, expose `'failed'` as a new
UserDraftSyncState, render "Save failed" in red with a CloudOff icon.
Failure clears on the next successful save for the same key, or when
recordRemoteSync seeds a fresh authoritative timestamp.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): surface 'Save failed' inside the AutosaveIndicator popover too
The popover used to repeat the cheerful "All changes are saved as a
draft on the server..." copy even when the inline label said
"Save failed", which read as contradictory. Add a red, text-xs warning
at the top of the popover body when the sync state is `failed`,
explaining that the latest edits didn't reach the server and that
editing again retries the save.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): surface the actual error message in the AutosaveIndicator popover
Replace the generic "your latest changes did not reach the server" copy
with the real failure detail. The syncer now stores the extracted
message in the failures map (formatSaveError walks body / message /
statusText) and exposes it via the state handle's `failureMessage`
getter. Popover renders it in red, monospaced, scrollable so a long
server traceback doesn't blow out the popover.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard
A `value: null` POST is a discard, not a save, but it ran through the
same runner the indicator watched — so resetting to deployed flashed
"Saving..." → "Saved", reading as "your draft just landed" while we
were actually wiping it. Track in-flight discards in a SvelteSet,
expose a distinct `'discarding'` UserDraftSyncState, and the indicator
stays quiet for it: no spinner, no label change, and the
`discarding → none` transition deliberately skips the "Saved" flash.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard"
This reverts commit 625a47c5d2.
* fix(drafts): flush pending autosaves when the editor hook unmounts
SPA navigation doesn't fire `pagehide`, so a debounced edit (up to
maxDebounceMs old) silently disappeared when the editor was unmounted
mid-typing. `UserDraft.useMany`'s onDestroy now walks every acquired
entry and fires `UserDraftDbSyncer.flush(query)` before releasing,
re-submitting the pending opts with `immediate: true`. The POST rides
the runner's own lifetime and survives the component teardown.
`use` / `useReactive` are thin wrappers around `useMany` so they
inherit the flush automatically. Editors that don't go through the
hook (sessions' `ScriptEditorView`, `AppJsonEditor`, copilot adapter,
DraftBadge fork action) only call `UserDraftDbSyncer.save` for
one-shot operations and don't need lifecycle flush.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* nit
* feat(ui): Modal2 fixedHeight='adaptive' sizes the modal to its content
The fixed-height steps force either wasted whitespace or clipped
content for small dialogs. `adaptive` emits no height rule (still
capped by max-h-screen-80) so the modal hugs its content. Use it in
StaleDraftModal, which only has two lines of copy and a button row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(drafts): 'Create test drafts' button on the home page
Dev/QA helper that seeds one per-user draft for every supported kind
(script, flow, app, raw_app, trigger_schedule, resource, variable) at
fixed u/{me}/draft_<kind> paths, so the draft surfaces (home badges,
editors, stale-draft modal, others' drafts modal) can be exercised
without hand-creating items. Re-clicking overwrites the same paths.
Value shapes mirror what each editor's autosave writes, matching the
backend list synthesizers that parse them back.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): dedupe app list rows when a path holds both app and raw_app drafts
The apps list LEFT JOINed draft with typ IN ('app', 'raw_app') for the
is_draft flag — a path holding BOTH kinds for the same user (easy to
hit: open a raw-app draft path in the regular app editor and its
autosave writes the second kind) fanned the row out into two identical
entries and crashed the home list with each_key_duplicate. Join a
DISTINCT (path, workspace_id) subquery instead. Same dedup for the
draft-only synthesis block via DISTINCT ON (path) keeping the most
recently saved kind.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(drafts): asterisk on resource/variable/schedule/trigger rows with own draft
Add an is_draft flag to ListableVariable / ListableResource /
ScheduleLight / BaseTrigger list rows — a scalar EXISTS subquery on the
draft table for the authed email (no join, so no row fan-out), plus
is_draft: true on the synthesized draft-only rows. The list pages
(variables, resources, schedules, all trigger kinds) append `*` to the
displayed name when set, mirroring the home page's convention.
Also fixes draft-only resources never appearing on the resources page:
the page always lists with resource_type_exclude=cache,state,app_theme
(its tab split) and the synthesis gate bailed on any type filter. The
gate now keeps synthesizing and applies resource_type /
resource_type_exclude per-row against the draft JSON instead.
list_triggers (trait default) takes an authed_email: Option<&str> —
Some from the list endpoint, None from workspace export.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "feat(drafts): 'Create test drafts' button on the home page"
This reverts commit 1f244a2a8b.
* fix(drafts): P1 hardening — save authz, secret scrubbing, hot-path index
1. save_draft had no authorization check (a regression from the old
create_draft's require_writer_of_path): any workspace member could
plant drafts in another user's u/ namespace or unwritable folders,
and those drafts get surfaced to every reader of the path (home
circles, others'-drafts modal, View JSON / Fork). New
require_can_write_path: admins; own u/ namespace; g/ namespace when
in the group; f/ folders with the write/owner bit (with the same
folder-claim refresh deploy endpoints use). Operators are rejected
outright — they're excluded from every other draft surface.
2. Secret variable values were persisted in the draft table in
plaintext. save_draft now blanks variable.value for is_secret drafts
at write time (the editor never round-trips secret values anyway —
it fetches with decrypt_secret=false), and a migration scrubs rows
persisted before the guard.
3. fetch_other_drafts_users runs on every get-by-path request with
(workspace_id, path, typ) and no email predicate — neither partial
unique index covers it, so it seq-scanned a table that accumulates
per-user autosaves across all workspaces. Add a plain btree index;
it also serves get_draft_for_user's IS NOT DISTINCT FROM lookup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): Ctrl/Cmd+S flush narrates via the indicator, not a toast
The "Draft saved" toast fired even with the network down — flush never
rejects (postSave catches errors internally and routes them to the
failures map), so the success branch always ran. Drop the toasts from
the script / flow / raw-app Ctrl+S handlers; the AutosaveIndicator
already narrates the flush truthfully (Saving... → Saved / Save failed
in red).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): Ctrl/Cmd+S always flashes Saved in the indicator
After dropping the toast, an explicit Ctrl/Cmd+S with nothing pending
(the common case — autosave already landed everything) gave zero
feedback: flush() no-ops when pendingSaveOpts is empty and no state
transition fires. flush() now bumps a reactive per-key counter on
completion (no-op path included), exposed as flushCount on the state
handle; the AutosaveIndicator flashes "Saved" on the bump when the
pipeline is idle. Real flushes keep narrating through Saving... →
Saved / Save failed as before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ui(drafts): Ctrl/Cmd+S replays the green backdrop flash on the indicator
Decouple the one-shot light-green → transparent backdrop from the load
hint label: triggerFlash() owns the keyed span (mounted only while the
animation runs), and both the on-mount hints and the Ctrl/Cmd+S
confirmation route through it. The flush bump fires after the POST
lands, so a real flush flashes too — not just the no-op path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(drafts): 'Create test drafts' button on the home page
Dev/QA helper that seeds one per-user draft for every supported kind
(script, flow, app, raw_app, trigger_schedule, resource, variable) at
fixed u/{me}/draft_<kind> paths, so the draft surfaces (home badges,
editors, stale-draft modal, others' drafts modal) can be exercised
without hand-creating items. Re-clicking overwrites the same paths.
Value shapes mirror what each editor's autosave writes, matching the
backend list synthesizers that parse them back.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): Ctrl/Cmd+S reaches the raw-app flush from every editor surface
The raw-app window keydown handler never fired in practice: the file
editor is a VS Code workbench in a same-origin iframe (keydowns don't
cross documents) and the inline-script / YAML Monacos swallow Ctrl+S
via addCommand. Two hooks:
- attach a capture-phase keydown listener inside the iframe document on
each load (no preventDefault — VS Code's own save still runs, we
flush the pending autosave alongside it);
- Editor.svelte / SimpleEditor.svelte re-broadcast their swallowed
Ctrl+S as a `wm-monaco-save-shortcut` window event, which
RawAppEditor listens for.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): editing a draft-only item opens create mode prefilled from the draft
Variable / resource / schedule / trigger editors treated every loaded
path as deployed and routed saves through the update endpoints, which
404 for draft-only items ("Resource not found at name ..."). The
get-by-path responses already mark the case (`no_deployed` from
fetch_draft_only) — editors now flip to create mode when it's set:
- VariableEditor / ResourceEditor: existedInitially = !no_deployed
- ScheduleEditorInner + all 10 trigger editor inners: loadTrigger /
loadSchedule return { overlay, noDeployed } and openEdit sets
edit = !noDeployed
The form opens prefilled from the draft and deploys via create, whose
endpoints already delete the creator's draft on success.
(The "Could not load schedule: Not Found" half of the report was a
stale dev backend — getSchedule?get_draft=true verified working on the
current build.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): leading-edge draft saves for raw apps (no double debounce)
Raw-app file changes reach the parent already coalesced — the UI
Builder iframe holds a ~1s trailing debounce on its rebuild and only
posts setFiles when it fires. The syncer then stacked its own 1.5s
trailing window on top, so the draft landed ~2.5s after the user
stopped typing. The debouncer now supports a leading edge (run
immediately when the key is idle and cooled down; later schedules in
the window coalesce trailing with the max-wait ceiling, mirroring the
classic editor's first-keystroke-materializes-immediately logic), and
raw_app saves opt into it. The app build keeps its own trailing
debounce inside the iframe — only draft persistence is affected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ui(drafts): blue flash for load hints, green for save confirmations
The backdrop flash now carries meaning: green = "your save landed"
(Ctrl/Cmd+S), blue = informational on-mount hints ("Loaded from
draft", "Others are working on this ..."). Color is passed as an
inline CSS custom property the keyframe reads, so the single keyframe
serves both variants.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "fix(drafts): leading-edge draft saves for raw apps (no double debounce)"
This reverts commit 1b996fd73a.
* feat(drafts): 'Enable auto-save' toggle in the AutosaveIndicator popover
Browser-wide preference (default on, persisted in localStorage). While
off, the reactive keystroke mirror never POSTs — saves marked
`auto: true` park their latest opts in pendingSaveOpts instead of
scheduling, and the unload keepalive flush is skipped, so nothing
leaves the tab except explicit actions: Ctrl/Cmd+S flush (sends the
parked latest content), discard / reset-to-deployed, fork, conflict
overwrite. The indicator shows a muted cloud-off while disabled (the
idle check-mark would otherwise read as "everything saved") and the
popover copy explains the Ctrl/Cmd+S-only behavior. Re-enabling
re-schedules every parked unsaved draft so edits made while off catch
up immediately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "feat(drafts): 'Create test drafts' button on the home page"
This reverts commit fd7013b399.
* feat(drafts): Review & Deploy covers variables/resources/schedules/triggers
The drafts review page only assembled scripts/flows/apps from three
paginated list endpoints, so drafts of every other kind were invisible.
New GET /w/{ws}/drafts/list returns every draft of the authed user in
one query over the draft table, with a per-kind draft_only flag
(deployed-table EXISTS per kind); getDraftItems switches to it, which
also drops the 3×N-page fan-out.
CompareDrafts renders the new kinds (icon via a UserDraftItemKind →
layout-Kind mapping, gray kind badge, list-page edit links for
drawer-based editors), diffs them through a generic overlay GET, and
deploys them by replaying the editor save: create/update for variables
and resources, saveScheduleFromCfg for schedules, the per-kind
save*TriggerFromCfg helpers for the ten standalone trigger kinds.
Also fixes two paths stale since the draft_only column removal:
draft-only flows/apps now deploy via create (update 404s — there is no
row anymore), and discard always deletes the draft row (the old
delete-the-item branch 404'd for the same reason).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(drafts): optimistic asterisk while editing in list-page drawers
The `*` suffix on variable/resource/schedule/trigger rows came from the
server's is_draft flag, which only updates on a refetch — editing an
item in the drawer didn't mark its row until much later. New
localDraftHints module (SvelteSet-backed): editors publish their dirty
state (the same condition that shows the "You have unsaved changes"
banner) and the 13 list pages OR the hint into the asterisk condition,
so the suffix appears the moment the form diverges and clears on
discard/teardown. Wired once in useTriggerDraftSync (covers the
schedule editor and all ten trigger editors) plus VariableEditor and
ResourceEditor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ee repo
* fix(drafts): draft hints persist past editor teardown, re-sync on reopen
Clearing the optimistic asterisk on drawer close was wrong: the
divergence the editor observed is autosaved server-side, so the draft
outlives the drawer and the asterisk should too. Hints are now
corrected rather than expired — while an editor is settled on an item
it publishes the observed truth in both directions (divergence sets,
sitting at the deployed baseline clears), so a draft discarded from
another tab loses its stale asterisk the next time the item is opened.
No teardown cleanup anywhere.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(drafts): list-page asterisk mirrors the editor's banner, not stale is_draft
The asterisk was `is_draft || hint` — an OR can turn the asterisk on
optimistically but can never turn it OFF, so after discarding a draft
(or editing back to the deployed value) the stale server flag kept the
asterisk until the next list refetch.
Make the local hint a tri-state override instead: the editor publishes
the live banner state (true/false) into a SvelteMap, and the list pages
read `getLocalDraftHint(...) ?? is_draft` — the editor's observed truth
wins over the stale server flag in both directions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): autosaves equal to the deployed value delete the draft instead
When the user edits back to exactly the deployed value, the reactive
autosave mirror used to persist a baseline-equal copy — a useless draft
row that kept `is_draft` (and the list asterisk) on after refetch.
Add a `discardIfEqualTo` baseline getter to `UserDraft.useMany` specs:
when the cell's value deep-equals the deployed baseline, the mirror
POSTs `value: null` (delete) instead of the value. The variable and
resource editors pass their `initialStates` baseline, guarded on
`existedInitially` — draft-only/new items have no deployed copy, so
equality must never delete their only data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Draft encryption for secret variables
* fix(drafts): discardIf predicate + deploys clear the asterisk and draft row
Two follow-ups on the baseline-equal-autosave-deletes change:
1. `discardIfEqualTo` (baseline getter + raw deepEqual) becomes
`discardIf` (predicate). Raw deepEqual reported spurious diffs after
a refresh: drafts round-trip through JSON, which strips
undefined-valued keys, so a restored draft (`{}`) never compared
equal to the freshly built baseline (`{ labels: undefined }`) and
the delete never fired. The editors now pass the SAME comparison
that drives their "unsaved changes" banner — a new exported
`draftValuesEqual` (JSON-normalized deep equality) used by both —
so the banner and the synced draft can never disagree.
2. Truly saving (deploying) clears the asterisk and the draft row:
- variable/resource editors: replace post-deploy `UserDraft.remove`
(blanks the cell to `undefined`, which reads as dirty and keeps
the banner + asterisk on) with `discard` to the just-saved state,
and refresh `initialStates`/`existedInitially` so the editor
settles clean.
- trigger editors: `useTriggerDraftSync.discard` publishes the hint
off explicitly — after a deploy the editor's `deployed()` baseline
is stale, so the hint effect alone would keep the asterisk on.
- Review & Deploy page: `deployDraft`/`discardDraft` clear the hint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* revert encryption just for the resources part
* fix(drafts): required const DRAFT_KIND on TriggerCrud; deploy/delete cover raw_app
The TriggerCrud::user_draft_item_kind() default matched on TRIGGER_TYPE
and panic!'d on any unmapped string — a runtime crash on the first draft
save for a trigger that forgot to map. Replace it with a required
associated const DRAFT_KIND, so a missing mapping is a compile error.
user_draft_item_kind() now just returns Self::DRAFT_KIND; every impl
(OSS + EE) declares the const.
Also fix the app deploy/delete draft cleanup to cover raw_app: raw apps
deploy and delete through the same internal path, but the cleanup
filtered typ = 'app' only, leaving raw_app drafts dangling
(create_app_internal apps.rs:1465, update path apps.rs:2077) or
un-archived on delete (apps.rs:1687).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): deleting an item wipes every user's draft, not just the caller's
Scripts/flows/apps already wiped all users' drafts on delete, but
resources/variables/schedules/triggers called delete_user_draft
(caller-scoped), so a teammate's draft on the just-deleted item lived on
forever — surfacing through fetch_other_drafts_users with no item left
to deploy onto. Add delete_all_drafts_for_path (all emails + the legacy
NULL row) and use it in every delete handler; keep delete_user_draft for
the discard-my-own-draft flow where the item lives on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(drafts): skip other-drafts query on non-editor reads (get_draft=false)
maybe_overlay_draft ran fetch_other_drafts_users (a usr join) on every
get-by-path, including worker/CLI reads of MB-scale flows & apps that
pass get_draft=false and never render the draft overlay or "others
editing" surfaces. Gate the query behind get_draft — only editor reads
pay for it. Reset-to-deployed editor reloads still get it (they pass
get_draft=true).
(Eliminating the serde_json::to_value materialization of the deployed
payload needs WithDraftOverlay to become generic over T, which is folded
into the get-by-path choreography refactor.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): single-source the kind→table mapping via deployed_table()
The kind→table dispatch lived in three places that could drift: the
TriggerCrud string-match (already replaced by const DRAFT_KIND), the
table_for_kind access-check map, and a hand-written draft_only CASE in
list_drafts.
Add UserDraftItemKind::deployed_table() as the single source (plus an
ALL enumerator). table_for_kind now delegates to it, and the list_drafts
draft_only CASE is generated from it at runtime (table names come from
the closed enum, never user input — no injection). Drift between the
access check and the existence check is now impossible by construction.
Webhook and the native triggers (poll/cli/nextcloud/google/github) map
to None: they have no path-keyed backing table and aren't draftable, so
they report draft_only=true and use a path-only access check. This also
fixes a latent bug where table_for_kind mapped native kinds to
native_trigger, which has no `path` column — the access query
`SELECT 1 FROM native_trigger WHERE path = $1` would have errored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ee repo
* fix(drafts): close variable draft-secret laundering oracle (sentinel + rehydrate)
save_draft encrypts secret variable values with the workspace key, but
the ciphertext was round-tripped to the client and the deploy endpoints
decrypted whatever $encrypted: ciphertext the client submitted
(variables.rs create/update). Any workspace member who can write a
variable path could take an arbitrary workspace-key ciphertext (another
user's secret draft via GET /drafts/get with only path-read, or a
deployed secret's stored value) and submit it as their own secret
variable's value — the server decrypted it and, since they own the path,
they read the plaintext back. That bypasses the audited decrypt_secret
permission.
Fix: the ciphertext never leaves the server. get_variable swaps a draft
secret's $encrypted: value for an opaque $draft_secret sentinel (both the
draft overlay and the draft-only inner stand-in). On deploy the client
sends the sentinel back and the server rehydrates the plaintext from the
caller's OWN draft row — the only ciphertext it ever decrypts is one it
encrypted for this exact (workspace, path, email). A raw $encrypted:
submitted by a client is now rejected outright.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): don't clobber a secret draft when autosaving the $draft_secret sentinel
After reload the client holds the $draft_secret sentinel for a secret
variable (never the ciphertext). Editing some OTHER field (description,
labels) triggers an autosave carrying value="$draft_secret" — and
save_draft's encrypt_secret_variable_value, seeing a non-empty,
non-$encrypted: string, encrypted the literal sentinel, overwriting the
real ciphertext in the draft row and losing the secret.
Treat the sentinel as "secret unchanged": restore the $encrypted:
ciphertext already stored in this user's draft row instead of encrypting
the placeholder (falling back to empty only if there's no prior
ciphertext). The new lookup reuses the same query shape as the deploy-
time rehydrate, so no new offline cache entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Revert "$draft_secret" sentinel approach for variable draft secrets
Reverts 339c259fce and b2c38ef407. Instead of round-tripping a sentinel
and rehydrating server-side, we close the laundering vector more simply
by disabling cross-user draft visibility for triggers/resources/variables
(next commit) — an attacker can no longer read another user's secret
draft ciphertext to launder it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(drafts): keep drafts private to their owner for resource/variable/trigger kinds
Replaces the reverted $draft_secret sentinel: instead of laundering-proofing
the ciphertext round-trip, simply don't expose other users' drafts for the
drawer kinds (resource/variable/triggers). A viewer can no longer obtain
another user's secret-variable draft ciphertext, so it can't be laundered
into plaintext via deploy.
UserDraftItemKind::shares_drafts_across_users() — true only for
script/flow/app/raw_app. maybe_overlay_draft skips other_drafts_users for
non-sharing kinds, and get_draft_for_user (View JSON / Fork) returns 404
for them. Own-draft load/save is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): make the list-page asterisk hint a shadow of UserDraftDbSyncer
The optimistic `*` hint was written by three open-editor publishers, so
draft deletions that didn't go through an editor (banner discard,
autosave-back-to-baseline, Review & Deploy) left a stale asterisk that a
server refetch couldn't clear (the hint overrides is_draft).
Move ownership to the syncer — the one choke point where a draft's
existence actually changes:
- postSave sets the hint on a saved write (value !== null) and clears it
on a delete (null), so every syncer-routed delete clears it for free.
- save() lights it optimistically when a real save is scheduled, so the
asterisk still tracks the editor's banner without the debounce lag.
The editors no longer SET the hint; they only CLEAR it when settled at
the deployed baseline (so a draft discarded from another tab disappears
on reopen). discardDraft drops its explicit clear (postSave covers it);
deployDraft keeps one (it deletes server-side, bypassing the syncer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): fold draft index + secret scrub into the base sync migration
Merge 20260610095349_draft_workspace_path_typ_index and
20260610100018_scrub_secret_variable_drafts into the base
20260528143710_draft_user_sync_schema migration (the index creation +
secret-draft scrub in .up, the index drop in .down; the scrub stays
irreversible). 20260609165313_remove_draft_only remains standalone.
Verified the full chain applies and reverts cleanly on a fresh DB.
(Rewrites an already-applied migration — existing dev DBs need a reset.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): promote the get-by-path draft choreography to one helper
The "Some(deployed) → overlay / None+get_draft → draft-only / None → 404"
dance was copy-pasted across the get-by-path handlers and had drifted
(different 404 text, the trigger one missing the draft-only fallback at
first). Promote it to windmill_common::overlay_or_draft_only<T>, which
takes the deployed entity as Option<T> and a per-route not_found closure.
Converts scripts, flows, apps, schedules, and triggers onto it. Resources
keeps its own (it runs an async explain_resource_perm_error on the 404
path) and variables keeps its own (secret-decrypt logic interleaved with
the draft fetch) — both genuinely diverge from the common shape.
(The serde_json::to_value elimination via a generic WithDraftOverlay<T>,
and the list-only draft synthesis dedup, remain as follow-ups.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(drafts): serialize the deployed overlay payload in one pass
maybe_overlay_draft materialized the deployed entity into a
serde_json::Value tree (serde_json::to_value) and then serialized that
tree again into the response — two passes plus a full Value allocation
over what can be an MB-scale flow or app, on every get-by-path
(including get_draft=false worker/CLI reads).
Hold WithDraftOverlay.inner as a boxed erased_serde::Serialize trait
object instead, so the deployed payload flattens straight into the
response in one pass. The struct stays non-generic, so the helper and
all seven handler return types are unchanged; only the deployed type now
needs Send + 'static (already true — they're owned rows; added 'static
to TriggerCrud::Trigger to say so).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): one helper for the draft-only list synthesis query
The "draft rows at paths with no deployed counterpart" query was
copy-pasted into the variable / resource / schedule / trigger list
handlers, each hardcoding its own typ literal and NOT EXISTS table — a
drift hazard. Promote it to windmill_common::fetch_draft_only_list_rows,
which derives the absence-check table from kind.deployed_table() (the
same single source as the access check and draft_only flag). Each
handler keeps its own include_draft_only gating and per-type row mapping
(genuinely entity-specific); only the shared SQL is deduped. The trigger
handler's prior generated-SQL version is folded in too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): route raw-app draft deploys through the raw-app endpoint [P1]
deployDraft's raw-app guard was `kind === 'app' && rawApp`, but Review &
Deploy passes `kind === 'raw_app'` (raw apps are their own DRAFT_KIND),
so the guard never fired and the row fell into the visual-app branch.
There `d.value` is undefined (a RawAppDraft has files/runnables/data, no
`value`), so AppService.updateApp did a partial update — resetting policy
to the publisher default, never bundling/deploying the files — the
backend then deleted the user's raw_app draft rows, and the UI reported
"deployed". The work-in-progress was destroyed without ever deploying.
Route `kind === 'raw_app'` (or the editor's `app` + rawApp) through
deployRawAppDraft. The now-unreachable `raw_app` arm of the visual-app
branch is dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): allow draft saves for item-level extra_perms writers [P1]
require_can_write_path only accepted namespace rules (own u/, member g/,
writable f/), dropping the item-level extra_perms check the old
create_draft had. A user granted write on e.g. u/alice/script via the
Share dialog could still deploy it (the update endpoints go through RLS)
but could no longer save a draft — and because the editors autosave
continuously with no permission gate, editing a shared item produced a
persistent "Save failed: you don't have write permission" and Ctrl/Cmd+S
failures.
Add the item-level fallback: when a deployed row exists at the path,
check its extra_perms for a write grant (every deployed table has
extra_perms; the table comes from the closed deployed_table() mapping).
Draft-only items have no row and stay governed by the namespace rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): pass rawApp on get-app for never-deployed raw-app drafts [P2]
A raw app that has only ever been drafted has no `app` row, so get_app
resolves the draft kind from the `rawApp` query param. getDraftDiffValues
("Show diff") and deployRawAppDraft both fetched with getDraft=true but
without rawApp, so the backend looked up the visual-app draft kind, found
nothing, and 404'd. Pass rawApp so the raw_app draft is found.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(drafts): surface the localStorage→DB migration with toasts
migrateUserDraftsToDb already uploaded legacy "userdraft/..." entries and
cleared them on success (and runs after the v1→userdraft normalizer).
Add the user-facing surface: when real legacy entries are detected, show
an info toast "Migrating local storage drafts ..."; on a per-draft
failure show an error toast "Could not migrate draft <path> in workspace
<X>" with a "Delete draft" action that drops the stuck localStorage entry
(otherwise it retries every mount). Unparseable junk is still cleared
silently up front, so the toast only fires for genuine drafts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(drafts): cover the autosave pipeline's pure-logic utilities [P2]
The deleted draft tests left the new debouncer + coalescing runner — the
core of the autosave pipeline — with zero coverage. Add vitest suites
(16 cases) for debouncerByKey (debounce window, latest-task-wins,
maxDebounceMs ceiling under a trickle, fresh-chain-after-fire, cancel,
key independence) and coalescingRunner (immediate run when idle, coalesce
burst to in-flight + latest, displaced-task drop, submitAndWait
resolve/reject/displaced, cancel semantics, key independence).
Broader replacement (save_draft conflict semantics + the require_can_*
checks as backend integration tests) still outstanding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(drafts): add UserDraft.seed — a one-shot baseline load that never POSTs
The page editors bracket their new-draft / deployed-baseline loads with
stopSync + restartSync so the programmatic write isn't synced as the
user's edit. Forgetting restartSync silently disables autosave for the
session — the footgun behind the three divergent resume strategies the
review flagged.
`UserDraft.seed(kind, path, value)` is the scoped alternative: it sets
the cell (all reactive readers update) and arms a single-shot
`seedNextWrite` flag the sync effect consumes — adopting the value as the
new baseline and skipping exactly that one POST, with no suspension to
resume. Additive: stopSync/restartSync are untouched and still used for
the writes that fan out across editor components (initContent cascades).
Foundation for converting the editor bootstraps off the bracket.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): extract usePageDraftSync; convert the scripts editor onto it
First step of unifying the four page editors' hand-rolled draft
orchestration (three divergent handle-ownership models + an
easy-to-forget recordRemoteSync). usePageDraftSync is the single model —
the page analogue of useTriggerDraftSync — owning the re-keyed autosave
handle, the live-editor-draft registry entry, recordRemoteSync (now a
method, not a per-page ritual), seedBaseline (via UserDraft.seed), and
draft removal.
The scripts editor is converted as the reference adoption: its inline
useReactive handle, live-editor-draft effect, recordRemoteSync, and the
two UserDraft.remove calls now go through draftSync. The new-draft
stopSync bracket stays (it spans ScriptBuilder's initContent cascade).
Verified in a real browser against the dev stack: load fires no spurious
save, a code edit triggers exactly one save_draft POST + a draft row,
and the draft persists across reload. Flows / apps_raw / apps conversions
follow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): convert the flows editor onto usePageDraftSync
Replace the inline useReactive handle + UserDraftDbSyncer.recordRemoteSync
+ UserDraft.remove with draftSync. effectivePath is omitted — flows
register their live-editor-draft entry through FlowBuilder
(liveEditorDraftStoragePath), so the composable doesn't double-register.
The new-draft stopSync + armRestartOnFirstInteraction bracket stays (it
spans FlowBuilder's seed cascade). flowStore reads/writes draftSync.draft.
Verified in a real browser: load fires no spurious save, a summary edit
triggers exactly one save_draft POST + a draft row, and the edit persists
across reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): convert the apps_raw editor onto usePageDraftSync
Replace the UserDraft.use handle + mirror, UserDraftDbSyncer.recordRemoteSync,
and UserDraft.remove with draftSync. `path` is a mount-scoped plain `let`
(the editor remounts per path), so the composable's useReactive re-keys
only on workspace change — equivalent to the prior capture-once use().
effectivePath omitted (RawAppEditor owns the live-editor-draft entry); the
new-draft stopSync + armRestartOnFirstInteraction bracket stays.
Type-checked and behavior-equivalent (handle mechanism unchanged; the
centralized recordRemoteSync/remove read the same `path`). Not
browser-exercised here — no existing raw app in the dev workspace and the
new-draft template-picker flow isn't scriptable quickly; scripts and flows
(same composable) were verified live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): remove app autosave at its canonical key after deploy/rename
AppEditor keys the app autosave on the URL draft path and passes it down
as userDraftPath, but AppEditorHeader's post-deploy cleanup re-derived
the key from the just-typed deploy path (createApp) / the live $appPath
(updateApp) instead. For a new app the autosave lives at
u/{user}/draft_{uuid} while the typed path is the user's chosen name, and
a rename leaves the autosave at the original key — so removing at
path/$appPath missed the real draft row and orphaned it. Use the
canonical userDraftPath AppEditor already provides.
This is the "children re-derive the UserDraft key" fragility from the
review, addressed without giving apps a page-level handle — apps
deliberately lets AppEditor own the handle so the entry is destroyed on
unmount (a page handle would keep it alive and reintroduce spurious
autosaves on every /edit visit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(drafts): integration tests for save_draft conflict semantics + authz [P2]
Replaces the deleted drafts.rs (which targeted the removed /drafts/create
API) with tests for the new surface:
- save_draft upsert → stale-last_sync conflict (rejected, value unchanged)
→ force overwrite → delete, the optimistic-concurrency contract.
- require_can_write_path: own namespace allowed, another user's namespace
rejected, operators rejected.
- the item-level extra_perms fallback — a user granted write on a deployed
item can save a draft on it (regression test for the authz drop).
- cross-user draft privacy: GET /drafts/get is 404 for the drawer kinds
(variable/resource/triggers), not blocked for script/flow/app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(sqlx): refresh offline cache after the main merge
The merge auto-combined both branches' additions inside the resource
get-by-path query_as! (our draft_only/is_draft columns + main's
folder_labels(...) inherited_labels), producing query text neither branch
had cached — so the offline build failed for it. Regenerate the entry
(rename to the new content hash) and refresh a re-described workspace
query. Feature-gated/EE entries the local prepare can't compile are left
as committed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ee repo ref
* chore(system_prompts): regenerate for draft_only/is_draft trigger schema fields
The openapi.yaml trigger/schedule schemas gained draft_only + is_draft,
but system_prompts/generate.py wasn't rerun, failing the freshness check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(drafts): defer save_draft write authz to RLS via a FOR UPDATE probe
require_can_write_path re-implemented the item-level extra_perms write
rule in Rust (SELECT extra_perms + get_perm_in_extra_perms_for_authed) —
a third copy of rules whose canonical home is the RLS policies, and the
exact lane that regressed once already.
Replace it with an RLS write-probe: `SELECT 1 FROM {deployed_table}
WHERE path/workspace ... FOR UPDATE` through UserDB. Postgres applies
UPDATE policies to rows locked via FOR UPDATE, so a returned row means
the canonical policies (see_own / see_member / folder-write /
see_extra_perms_*_update / admin_policy) would let this user UPDATE the
row — no write rule re-implemented, no drift possible. The probe's row
lock is released by the immediate commit.
The claim-based namespace checks stay, evaluated FIRST: they read the
same JWT claims RLS does (so outcomes are identical), they spare the
autosave hot path a DB round-trip for the common own-namespace case, and
they are the entire check for draft-only paths — where no deployed row
exists, so there is structurally nothing for RLS to evaluate. The u/own
+ folder-owner part now goes through the shared
windmill_api_auth::require_owner_of_path instead of bespoke code.
Adds a read-only-grant test case (extra_perms value false): the row is
visible under the SELECT policy but FOR UPDATE filters it under the
UPDATE policy — pinning the semantics the probe relies on. All 4 draft
integration tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: point ee-repo-ref at the EE branch merge (has DRAFT_KIND consts)
ee-repo-ref was set to main's EE commit (d45b9a6) while the EE branch
was unpushed; building OSS (which requires const DRAFT_KIND on
TriggerCrud) against that EE ref fails with E0046 on every EE trigger
impl. The EE branch head e936e9a — the merge of d45b9a6 into the EE
remove-workspace-drafts branch, carrying the DRAFT_KIND consts — is now
pushed; point at it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): ignore permissioned_as fields in the unsaved-changes comparison
The schedule cfg carries permissioned_as / preserve_permissioned_as —
run-as deploy directives, not user-edited draft content — and the editor
round-trips them asymmetrically (preserve_… is rebuilt as
!!cfg.permissioned_as on load but `|| undefined` on build), so the
banner comparison could report a phantom diff.
Extract the normalization into a shared normalizeDraftForCompare (JSON
round-trip + a DRAFT_COMPARE_IGNORED_FIELDS list with the two fields)
and use it from BOTH comparators: draftValuesEqual (variable/resource
banner + discardIf) and useTriggerDraftSync's cfgDiffers (schedule and
trigger banners, the persist-effect's at-baseline discard, restore) —
one ignore-list, no way for the two to disagree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* nit
* fix(drafts): at-baseline discard is auto-gated and only fires with a draft
Two related fixes to useTriggerDraftSync's persist-effect:
1. The reactive at-baseline discard bypassed the "Enable auto-save"
toggle: with autosave off, value saves were parked (correct) but the
discard's value:null still POSTed — so the editor never wrote drafts
yet kept reactively DELETING them, and the only network traffic was
discards. Thread `auto` through UserDraft.discard to the syncer; the
persist-effect passes auto:true (parked for Ctrl/Cmd+S when the
toggle is off), explicit discards (banner button, post-deploy
cleanup, reset-to-deployed) stay ungated.
2. The discard fired unconditionally whenever the form sat at the
deployed baseline — including a spurious value:null POST on every
drawer open. Guard on cfgDiffers(h.draft, deployed): undefined on a
fresh open (nothing to discard) and equal to deployed right after a
discard (no repeat per cfg recompute).
Verified live as a non-admin user on a schedule: toggle on → no POST on
open, edit → one value save, revert → one discard; toggle off → zero
POSTs (everything parked), banner still functional.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(drafts): scope the "Enable auto-save" toggle to the page editors
Add a canBeDisabled opt (default false) to UserDraft.use / useReactive /
useMany specs, threaded through acquireEntry into the reactive mirror's
save opts. The syncer's auto-save gate (and the pagehide-flush skip) now
only applies to saves whose handle opted in: the four full-page editors
— script / flow / raw app via usePageDraftSync, app via AppEditor's
use() — which are exactly the surfaces whose AutosaveIndicator carries
the toggle.
Drawer editors (variables / resources / schedules / triggers) keep the
default and always sync regardless of the toggle — previously a
toggle flipped off in some browser silently disabled their autosave and
the optimistic asterisk (both sit behind the same gate) with no toggle
UI anywhere on those surfaces to explain it.
Verified live: schedule edit with the toggle off now POSTs the value
save (and the discard on revert); script editor with the toggle off
still parks everything for Ctrl/Cmd+S.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): consume the import handoff stores in the new-draft bootstrap
The /add pages used to read importStore / importFlowStore /
importScriptStore / sessionStorage rawAppImport to seed the editor from
"Import from YAML/JSON", "Build app" (from a script/flow), and the
workflows-as-code import. Since /add became a pure redirect to
/{kind}/edit/u/{user}/draft_{uuid}?new_draft=true, the writers kept
firing but nothing consumed the payload — every import landed in an
empty editor.
Consume them (one-shot read + clear) in the four edit pages' new_draft
branches, layering the imported content over the empty template with
path kept '' so the friendly-name generation still runs:
- scripts: $importScriptStore spread over the empty script (non-empty
content also keeps ScriptBuilder's template bootstrap from overwriting
it — that cascade is gated on content == '').
- flows: $importFlowStore spread over the empty flow.
- apps: $importStore — wrapped exports ({summary, value, policy}) and
bare App values, mirroring main's /add.
- raw apps: $importStore then sessionStorage rawAppImport (the full page
reload for cross-origin isolation drops in-memory stores); honored
only when the payload carries files (rendering gates on them),
skipping the framework picker; otherwise the template seed.
Verified live: "Build app" from a script lands on /apps/edit with the
canvas seeded from the script instead of an empty editor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(drafts): remove dead delete_user_draft + its stale doc [C4]
The doc claimed item delete handlers call it, but those all moved to
delete_all_drafts_for_path (an item delete is for everyone); the
caller-scoped discard goes through the save_draft route with value:null.
That left delete_user_draft with zero callers (OSS and EE) — remove it
and its orphaned sqlx cache entry, and reword the contrast note on
delete_all_drafts_for_path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): retire the sync_drafts-era index comment + right-size it [C6]
The draft_user_sync_idx comment described the deleted sync_drafts
polling endpoint (editors polling created_at ranges every 2-10s) — that
design was replaced by recordRemoteSync + save_draft last_sync, and
nothing range-scans draft.created_at anymore. Since this migration only
exists on this branch, fix it before it ships: the index's real consumer
is GET /drafts/list (workspace_id + email equality, ORDER BY path), so
swap the vestigial trailing created_at for path (rows come back in
output order) and rename to draft_user_listing_idx. Chain re-verified
on a fresh DB. (Byte-for-byte migration edit — dev DBs that already
applied it need a reset, as with the earlier consolidation.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): discardDraft awaits the delete POST before refetching [I5]
UserDraftDbSyncer.save resolves at enqueue time for debounced saves, so
discardDraft's await finished ~1.5s before the value:null POST and the
invalidateWorkspaceDrafts refetch re-listed the just-discarded draft.
Use immediate: true (resolves after the POST lands), matching every
sibling delete-then-refetch path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): replace stale draft_only gates in the builders [I6]
draft_only was dropped from the get-by-path wire shape (the column is
gone; overlays carry no_deployed instead), so these four reads were
always undefined:
- ScriptBuilder "Exit & See details" gate and TriggersEditor's
isDeployed treated every draft-only script as deployed → now keyed on
savedScript.no_deployed like the sibling reads right next to them.
- FlowBuilder's deploy path never took the direct-save branch for
draft-only flows (no deployed version exists to compare against), and
"Exit & see details" was offered for draft-only flows (404 details
page) → both now keyed on the newFlow prop (driven by no_deployed),
which the rest of the file already uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): Ctrl/Cmd+S flushes the draft in the low-code app editor [I7]
The app editor's keydown handler swallowed the shortcut with a bare
preventDefault() — every other page editor flushes the pending autosave
(UserDraftDbSyncer.flush) so the AutosaveIndicator narrates Saving... →
Saved and parked edits (autosave toggle off) actually persist. Wire the
same flush, skipped in the AI session pane where no UserDraft handle
exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(drafts): AI tool strings no longer describe drafts as localStorage [C2]
The copilot tool results/messages still told the model drafts were
"saved to local storage" / "a browser-only local draft" — drafts are
per-user rows in the server-side draft table now. Misleading the model
about the storage medium produces wrong explanations to users (e.g.
"your draft will be lost if you clear your browser data"). Reword all
occurrences to "draft" / "per-user draft (saved server-side)".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(openapi): drop stale draft_only request props, fix OtherDraftUser, regen deref [D4][C5]
- The create-script (NewScript), createFlow, createApp and createAppRaw
request bodies still documented draft_only — the backend request
structs no longer read it, so an older CLI sending draft_only: true is
silently ignored and fully deploys. Remove the property from the spec
so generated clients can't offer it. (Response-side draft_only on the
Listable* rows stays — the list synthesis populates it.)
- UserDraftOverlay.other_drafts_users item schema declared email and a
required draft_saved_at; OtherDraftUser serializes only username
(nullable for the legacy row — emails never leave the server). Align
the schema. [C5]
- Regenerate openapi-deref.yaml/.json (served at runtime via
include_str!) — they still advertised getScriptByPathWithDraft and the
deleted draft surface, and now carry the drafts/save_draft routes.
Frontend gen client regenerated; check:fast clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sessions): stop session pane from clobbering server-side raw-app drafts [P1]
loadRawApp seeded the session runtime from result.value (the deployed
payload), ignoring the .draft pocket returned by the get-by-path overlay.
The subsequent UserDraft.save then POSTed deployed content with no
last_sync recorded, silently overwriting the user's server draft.
Now the no-draft branch consumes result.draft when present (matching the
flow/script branches) and records draft_saved_at via recordRemoteSync so
later session saves are conflict-checked instead of treated as fresh.
Also corrects the header and aiDraft-branch comments that claimed the
overlay merges drafts into top-level fields — it never does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rust-client): pass new get_draft arg to variable_api::get_variable
getVariable gained a GetDraft query parameter (per-user draft overlay),
so the generated client fn takes a sixth argument. Verified with the
same generate+check pipeline CI runs (rust-client/dev.nu --check).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* nit: Workspace fork mention
* fix(drafts): don't leak other_drafts_users on draft-only private kinds [P2]
fetch_draft_only built the other_drafts_users list unconditionally,
while the deployed-overlay path gates it on shares_drafts_across_users.
For the drawer kinds (resource/variable/triggers) drafts are private to
their owner, so a draft-only GET was the one route that still told a
viewer who else has a draft at the path. Apply the same kind gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(drafts): probe a single row in the RLS write-probe [P2]
The script table keeps one row per version at the same path, so the
FOR UPDATE probe locked the entire version history and serialized
against concurrent deploys. LIMIT 1 locks one row — any UPDATE-policy
visible row proves writability (same pattern as scripts.rs's
latest-version lock).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): consume the /add?param= seeding intents in new_draft branches [D2]
The /add routes' redirect preserves query params, but the edit pages'
new_draft branches only consumed the YAML/JSON import stores — every
other intent the old /add pages handled landed in a blank editor:
- scripts: ?hub= and ?template= forks (with locked language and a
`<source>_fork` path suggestion), ?wac=python|typescript (WAC editor
template + language), ?lang=, ?initial_args= (URL form), and the
base64-JSON #hash payload (run page "Fork", workspace_settings
handler-template buttons; WAC detection restored for imports too)
- flows: ?hub= (preprocessor placeholder replacement + env-variables
panel), ?template=/?template_id=, ?fork=true (fork_flow localStorage /
window.opener handoff), #state, ?tutorial=
- apps: ?hub= (fromHub inputs panel), ?template=/?template_id=,
?tutorial=
The redirect itself also dropped the URL hash — SvelteKit forbids
url.hash in load, so it forwards window.location.hash (correct for all
hash producers: they arrive as full page loads via window.open /
target=_blank).
Seeding priority and toasts mirror main's /add pages. Verified live:
hub/template/wac/hash/fork intents for scripts and flows, hub for apps
(dev hub returns empty payloads, code path confirmed via toast +
inputs panel); no autosave POSTs fire during seeding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): LS→DB migration no longer clobbers fresher server drafts [P2]
The one-off localStorage migration POSTed every entry with force: true,
unconditionally overwriting whatever the user had since saved server-side
from another browser. It now passes the LS copy's lastWrittenAt as
last_sync (epoch 0 when absent), so the server's conflict rule arbitrates:
empty slot → insert; server draft fresher → conflict, LS copy dropped;
LS copy fresher → upload wins. Verified all three outcomes against the
live save_draft endpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(raw_apps): drop banned $bindable(default) on template picker open [P2]
`open = $bindable(false)` on an optional prop is the AGENTS.md-banned
pattern (the default masks the undefined state). The only caller always
binds a boolean, so `open` is now a required prop with a plain
`$bindable()`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): fork others' drafts via the import handoff, not an eager save
The Fork actions (OtherUsersDraftsModal + DraftBadge popover) saved the
fetched draft server-side immediately and navigated to the fork path,
which surfaced three problems: a server draft existed before the user
edited anything, the Path widget treated the slot as an existing item
("Only the owner can change the path"), and the value's draft_path kept
the source path while the URL said X_owner_fork.
Forking now routes through the same one-shot import handoff as the
"Import from YAML/JSON" actions (new shared forkDraftToImport helper):
stash the value in the kind's import store, navigate to /add, and let
the new_draft branch seed a brand-new own item — nothing saved until the
first real edit, fresh renamable path, no source identity riding along.
The editPathFor/currentUserUsername plumbing that only served the old
flow is removed from both fork surfaces and their callers. The new_draft
branches also clear the previous path's draft-presence state
(otherDraftsUsers, loadedFromDraft, stale-draft timestamps) — the page
component is reused across same-route navigation, so forking from an
editor with collaborators used to carry the "Others are working on
this" hint onto the fresh draft.
Verified live: fork of a legacy draft seeds content+summary on a fresh
u/{user}/draft_{uuid} slot with zero save_draft requests and no
leftover collaborator hints.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(drafts): replace deprecated Popover with meltComponents Popover
- Migrate from old Popover.svelte to meltComponents/Popover.svelte
- Convert to new trigger/content snippet pattern with openOnHover=true
- Maintain hover behavior with debounceDelay=100
- Add key to visibleUsers each block for Svelte 5 compliance
* feat(drafts): seed forked drafts with the source path in the forker's namespace
Forking u/admin/myflow as guest now seeds the Path widget with
u/guest/myflow instead of a random friendly name — everything after the
source path's first two segments is kept, so f/folder/my/flow becomes
u/guest/my/flow. The re-homed path travels from forkDraftToImport to the
new_draft branches as a ?seed_path= param (the redirect preserves query
params; plain ?path= would be eaten in transit by ScriptBuilder's legacy
collab-param cleanup, which deletes path/collab from the live
searchParams object).
The script editor also passes initialPathChosen for any seeded path —
MetadataGen fires onChange for a non-empty summary at mount, and the
summary→path auto-slug would otherwise overwrite the explicit seed
(hub/template forks and URL-hash payloads included).
Verified live: forking a draft on u/admin/hard_working_script seeds
path u/admin/hard_working_script (with the "path already used" warning),
keeps the drafted summary/content, and still fires no save_draft until
the first edit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): DiffDrawer "Restore deployed" actually discards the draft [P1]
All four restoreDeployed implementations POSTed the delete through the
debounced pipeline and reloaded with getDraft defaulting to true: the
reload's draft write re-entered the autosave mirror (the one-shot seed
guard was consumed on first load), and debouncerByKey displaced the
queued value:null with the new save — the delete never reached the
server and the editor re-rendered the draft it was told to discard.
They now funnel through runResetToDeployed (the stopSync-bracketed
delete the AutosaveIndicator reset already uses) with each page's
proven reset body (getDraft: false reload), so the suspension mutes the
mirror while the delete flushes and sync re-arms on first interaction.
Also fixes the raw-app drawer navigating to the visual app editor
(/apps/edit) instead of /apps_raw/edit [P2].
Verified live on the script editor: Restore deployed issues exactly one
save_draft ({value:null} answered status=saved), the server row is
gone, and the editor re-renders the deployed content.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): deploying a draft-only item reliably deletes its draft
Two bugs left the slot draft (u/{user}/draft_{uuid}) alive after a
successful deploy:
- RawAppEditorHeader.createApp removed the draft at the just-typed
deploy path instead of the URL slot key (the visual header documents
exactly this trap), orphaning the real row for every draft-only
raw-app deploy.
- Everywhere else the delete went through bare UserDraft.remove, which
only QUEUES the value:null in the per-key debouncer. Editors that stay
mounted through the post-deploy navigation (AppEditor, RawAppEditor —
and timing-dependently the script/flow builders' post-deploy
draft_triggers mirror) keep mirroring their working value, and one
such write displaces the queued delete with a fresh save — observed
live: deploying a new visual app re-saved the full grid value at the
slot right after deploy.
New discardDraftAfterDeploy helper (userDraftToast.ts) applies the same
bracket runResetToDeployed uses: stopSync to mute the mirror, remove +
immediate flush so the displacement window closes, re-arm on first
interaction. Wired into the script/flow pages' onDeploy and both app
headers' create/update paths (session-pane guards preserved).
Verified live for all three kinds: draft-only deploy issues the
value:null (status saved), the slot row is gone, and no post-deploy
save re-creates it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): forks-compare deploy clears drawer-kind drafts too
The script/flow/app deploy endpoints delete the deployer's draft
server-side, but the drawer kinds' (variable / resource / schedule /
triggers) create/update endpoints never touch the draft table — their
editors discard client-side after a save. deployDraft replayed the save
but not the discard, so "Deploy n drafts" on /forks/compare deployed
those kinds correctly and left the drafts listed forever.
deployDraft now issues the canonical value:null delete (immediate) for
the drawer kinds after a successful save. Verified live: deploying a
draft-only variable from /forks/compare creates the variable and the
draft row is gone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): StaleDraftModal "Load latest deploy" actually discards the stale draft [P2]
The modal invoked onLoadLatestDeploy directly — the draft = undefined
write queued the delete and the reload's deployed-payload write
displaced it, overwriting the stale draft with a deployed-identical
copy (is_draft stuck on, asterisk persists, modal can't re-fire since
draft_saved_at moved past the deploy). All four pages now run the
callback through runResetToDeployed, same as the DiffDrawer restore.
Verified live: stale-draft scenario → Load latest deploy → exactly one
value:null POST, draft row gone, editor renders the newer deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): don't acquire a sync entry for empty-path specs [P2]
The read-only historical-hash view (/scripts/edit/x?hash=...) computes
draftPath '' but useMany still acquired a live entry at ws/script/ —
every edit mirror-POSTed to /drafts/save_draft/script/ (unroutable),
populating the failures map and pinning the AutosaveIndicator on "Save
failed" with a retry per debounce window. Empty-path specs now get a
detached local-only handle: bind: works, nothing syncs — which is what
usePageDraftSync's doc always claimed. Verified live: editing in the
hash view fires zero save_draft requests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): no spurious conflict after bfcache restore of a flushed page [P2]
flushOnPageHide advances the server rows with unreadable keepalive
POSTs and leaves lastSyncMap stale — correct when the document dies,
wrong when bfcache resurrects it: the next autosave carried the
pre-flush last_sync and the server rejected the user's own write as a
conflict, opening DraftSyncConflictModal. The flushed keys are now
remembered and dropped from lastSyncMap on pageshow with
event.persisted, so the first post-restore save takes first-push
semantics against this document's own flush.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ui(drafts): draft asterisk sits on the trigger row's main title
The draft hint rendered at the end of the secondary path line
(u/admin/item*) on the http/websocket/nats/kafka/email trigger lists —
easy to miss. It now renders at the end of the row's bold title, and on
the azure/gcp lists it moves from mid-title (after the path, before the
topic suffix) to the end of the line. mqtt/postgres/sqs/schedules
already had it on the title. Verified visually on the HTTP routes list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): trigger editors save the FIRST edit, not the second
Three interlocking fixes in the trigger autosave path:
- The entry's one-shot first-write seed guard (skipNextWrite) was never
consumed for trigger entries: the drawers don't write the cell on open
(the form holds the state, unlike variables/resources which pass a
defaultValue), so the guard stayed armed and silently swallowed the
user's FIRST edit — banner on, no asterisk, no save until a second
change. maybeRestore now seeds the cell with the post-load baseline
(server draft overlay if any, deployed otherwise) via UserDraft.seed,
consuming the guard without POSTing.
- Guard hygiene in the cell's sync effect: a programmatic write consumes
BOTH one-shot guards, and a no-op write (same serialization — e.g. the
trigger pages fire openEdit twice per row click, re-seeding the same
value) defuses a lingering seedNextWrite instead of leaving it armed
to eat the next real edit.
- The at-baseline auto-discard is now deferred + revalidated (600ms):
with the cell seeded, the double-openEdit churn transiently shows
form-at-deployed + cell-holds-draft and an immediate discard deleted
the server draft on open; the recheck skips the transient state while
a genuine user revert still discards.
Verified live on the HTTP route editor: open-with-draft restores the
draft with zero POSTs, the very first field edit saves, and reverting
the form to the deployed value deletes the server draft.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ui(drafts): underscore-separated uuids in draft slot paths
u/{user}/draft_{uuid} now uses underscores instead of dashes in the
uuid — path segments elsewhere in Windmill are [a-zA-Z0-9_] words and
downstream consumers treat '-' as a foreign character. Nothing parses
the uuid back, so existing dashed slots stay valid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): cascade draft cleanup on bulk-delete and rename
Drafts have no SQL FK to their underlying items (only to password.email),
so deletion and rename must cascade programmatically. Two gaps remained:
- Bulk delete of variables/resources did not wipe per-user drafts at the
deleted paths (single delete already did via delete_all_drafts_for_path).
Cascade them — including the linked resource/variable rows the bulk
delete fans into — so no orphaned draft-only rows survive.
- Renaming a variable/resource/trigger left the per-user draft stranded at
the old path. Add delete_own_draft_for_path and clear the deployer's own
(+ legacy NULL) draft at the old path on rename, mirroring the
script/flow/app rename path; teammates keep theirs (StaleDraftModal).
Variable/resource renames also move the linked counterpart, so both
kinds' drafts at the old path are cleared. Schedules have no rename path.
Note: sqlx offline cache not yet regenerated for the new/changed queries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): surface legacy NULL-email drafts and migrate pathless /add keys
Legacy workspace-scoped drafts (pre-per-user rows + the remove_draft_only
migration, all email IS NULL) stopped showing up because every per-user
lookup matched only email = self. Match (email = self OR email IS NULL)
everywhere a draft is surfaced or opened, with the owned row taking
precedence (DISTINCT ON / ORDER BY email NULLS LAST): the home drafts
list, the script/flow/app/drawer draft-only list syntheses, and the
get-by-path overlay/fallback.
The localStorage->DB migration also dropped pathless legacy keys
(userdraft/w/{ws}/{kind}/ with no path) — the new-item /add autosave —
because parseKey rejected an empty path, leaving them stranded in LS.
Mint a fresh u/{user}/draft_{uuid} slot for those (same convention as the
editors' /add redirects) so they migrate as regular draft-only items.
Note: sqlx offline cache not yet regenerated for the changed macros.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(sqlx): regenerate offline cache for draft cascade + legacy-draft queries
Adds the offline entries for the queries changed in the two preceding draft
fixes (delete_own_draft_for_path, the maybe_overlay_draft/fetch_draft_only
NULL-email fallback, and the script/flow/app draft-only syntheses).
Also forwards the `http_trigger` feature from windmill-api-openapi to
windmill-store: that crate imports `try_get_resource_from_db_as`
unconditionally, but the fn is cfg-gated behind a trigger feature, so the
openapi targets failed to compile in isolation (e.g. `--all-targets` under
resolver 2) — which blocked `cargo sqlx prepare`. The feature was already
present transitively in whole-workspace builds; this just makes it explicit
where the symbol is used.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): resolve own draft owner in the admins workspace
The draft-owner surfaces (home-page badge, "others' drafts", View JSON /
Fork) resolve a draft's email to a username via the `usr` table. The
`admins` workspace has no `usr` rows — there a user's "username" IS their
email — so the join missed every owner and returned NULL, which the badge
renders as "Legacy workspace draft". A user editing a deployed item in
`admins` thus saw their OWN draft plus the genuine legacy NULL-email row
both labelled "Legacy workspace draft" (the reported duplicate).
Add the identity fallback `COALESCE(u.username, CASE WHEN workspace_id =
'admins' THEN email END)` to the script/flow/app draft_users aggregations
and fetch_other_drafts_users, and accept username==email in
get_draft_for_user. The genuine legacy row keeps username NULL (its email
is NULL, so the CASE yields NULL too), so it alone reads "Legacy
workspace draft" while the user's own draft now reads "<email> (you)".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drafts): keep "See others' drafts" after reset-to-deployed
other_drafts_users is only computed by the backend when getDraft is true
(the cross-user lookup is skipped otherwise). Reset-to-deployed reloads
with getDraft:false, so the editors were overwriting the known list with
the empty response — hiding the "See others' drafts" button until a full
page reload recomputed it. Discarding one's own draft is independent of
other users' drafts, which are untouched on the backend.
Only assign otherDraftsUsers on a getDraft:true load. Applied to the
script, flow, app and raw-app editors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* disable fork for operators
* Path reactivity issue
* docs(drafts): tighten draft-feature comments and drop dead code
The draft feature accumulated many multi-paragraph comments that risked
code-comment drift. Compact them to the AGENTS.md bar (constraints not
narration, state-once, no drafting-history), de-duplicating the repeated
draft_users / cascade / draft_only-synthesis rationale to one canonical
version per theme with terse cross-references elsewhere (~1300 fewer lines).
Also fixes three stale/contradictory comments surfaced while trimming:
- the operator authz note claimed operators are "excluded from every draft
surface", contradicting require_can_read_path (they can read some drafts,
never write) — reworded to match the code;
- a migration comment named a non-existent index (draft_user_sync_idx);
- a syncer comment documented the wrong map-key separator.
Removes notifyDraftLoaded (orphaned exported helper, no callers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(drafts): rename save_draft route to /update for CRUD consistency
The draft write route was POST /drafts/save_draft/{kind}/{path}, which
stutters with the /drafts prefix and uses a non-house verb. Rename it to
POST /drafts/update/{kind}/{path} (operationId saveDraft -> updateDraft) to
match the codebase's CRUD convention (/list, /get/{path}, /update/{path}).
/list and /get/{kind}/{path} already matched and are unchanged.
Updates the handler, openapi spec + dereferenced bundles, the two
DraftService callers, the hand-built keepalive page-unload URL (it bypasses
the generated client, so it wouldn't be caught by regeneration), and the
integration tests. Response status values ("saved"/"conflict") are
unchanged, so there is no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(frontend): align Monaco editor font size with text-xs across viewports
* fix(frontend): make placeholder lineHeight reactive to fontSize
* fix(frontend): align GraphQL schema viewer font size with text-xs
The read-only GraphQL schema viewer was the lone Monaco instance still
inheriting Monaco's 14px default. Wire it through editorFontSize like
the other editors so it stays in sync with text-xs across viewports.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sessions): chat + editor side-by-side with multi-session state
Introduces the Sessions feature: a workspace where the AI chat and an
editor (flow / script / app / raw-app) sit side-by-side, with each session
having its own AIChatManager instance, history, and target item. Sessions
are persisted across reloads and can be staged into forks for review.
Key pieces:
- sessions/ — SessionWrapper (the split-pane shell), SessionPicker
(sidebar list), SessionForkBar, SessionWorkspaceBar, FlowEditorView /
ScriptEditorView / AppEditorView / RawAppEditorView, ForkDiffDrawer,
sessionRuntime (per-session AIChatManager + draft state),
sessionState (in-memory + persisted index), sessionUnread, sessionScope,
appDraftCodec / flowDraftCodec, forkEditUrl, /sessions route.
- WorkspaceItemDrillPicker refactor — extracts WorkspaceItemRow + adds
surfaceAI drafts, stale-while-revalidate. workspacePicker.ts drops
explicit invalidate() in favor of always re-fetching in the background.
- ForkDiffDrawer + WorkspaceItemDiffViewer — per-kind diff bodies
reusable from the compare page. FlowGraphDiffViewer / FlowGraphV2 gain
inlineDiff forwarding + onHeight callback for equal-height layout.
- Global AI chat sessions plumbing — AIChatManager exports the class +
adds disabledModes, beforeSend hook, scoped instance context. AIChat /
AIChatDisplay accept session-only props (wideLayout, emptyHint,
inputPreface, hideHeader, hideModeSelector, forceDisabled). Chat
preserved across /flows/add → /flows/edit, /scripts/add → /scripts/edit.
- Draft-first loaders — sessions open drafts when present, otherwise
seed a draft from the last deployed value via globalDraftStore.
RawAppEditor / AppEditor / AppEditorHeaderDeploy get newApp prop +
fixes so draft-only apps can deploy.
- Compare page (/forks/compare) — bigger overhaul to plug into the new
drawer.
- Sidebar — Sessions entry + unread badge + status dot in
SidebarContent / MenuButton / SideBarNotification.
- Misc fixes — chat group color palette constraint, deploy_workspace_item
confirmation dropped, open_preview tool, picker drafts surfacing,
fork archive/delete buttons on compare page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): bypass UserDraft inside session panes + sessionUnread crash
After merging main's UserDraft PR (#9121) into the sessions branch, two
integration issues surfaced:
1. AppEditor.svelte calls `UserDraft.use<App>('app', path)` at the
component level — keyed by ($workspaceStore, 'app', path). Sessions
that haven't materialized a fork yet stay at the user's main
workspace, so a session targeting an app at the same path as a
regular /apps/edit tab shared the same LS key. The session would
read the regular tab's autosave and write its fork-edits back over
it.
Gate UserDraft.use on `!getContext('aiChatManager')` — sessions
inject the manager via setContext, so inside a session pane the
handle is `undefined`, stateApp falls through to the `app` prop
the session loaded, and the auto-save $effect bails. Same gate on
the four UserDraft.remove call sites in AppEditorHeader and
RawAppEditorHeader so save/deploy from a session pane doesn't wipe
the LS draft of a non-session tab at the same path.
2. sessionUnread.svelte.ts called useLocalStorageValue at module
scope. Main's PR added a deep-mutation $effect inside that helper,
which now requires component-initialization context — every page
crashed at import time with `Svelte error: effect_orphan`.
Replaced with a plain module-level $state + manual localStorage
persist; same reactivity contract for callers.
3. ScriptEditorView.svelte was passing a `replaceStateFn` prop that
ScriptBuilder dropped on main. Removed.
Verified end-to-end with Playwright:
- /flows/edit/{path} regression: UserDraft handle still created, no
console errors
- /sessions loads, sessionUnread doesn't crash
- Session targeting non-raw app `u/admin/userdraft_collision_test`
displays the fork content (FORK_ONLY_MARKER) even with an LS
poison at `userdraft/w/local/app/{path}` containing a
POISONED_BY_REGULAR_TAB_AUTOSAVE marker; poison remains untouched
after the session loads and renders
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): stop fork-create retry loop on first user message
Removed the SessionWrapper $effect that retroactively committed the
session's workspace from the in-memory chat history. When opening a
session whose previous commit attempt had failed (or whose response was
lost) the effect ran in a tight retry loop, flooding the user with
`workspace_pkey` violations from `create_workspace_fork`.
The send path already commits through `AIChatManager.beforeSend` →
`commitSessionWorkspace`, which is the deterministic moment-of-action.
The $effect was a redundant reactive bridge that turned every backend
failure into an infinite retry.
Also hardens `materializeFork`/`commitSessionWorkspace` so the most
common cause of the duplicate-key error self-heals:
- `materializeFork` short-circuits when `fork.id` is already in
`$userWorkspaces` (the previous create actually succeeded, we just
lost the response). On a `workspace_pkey` catch, refresh the workspace
list and adopt the existing row instead of toasting an error.
- On a real `materializeFork` failure, `commitSessionWorkspace` now
drops `pending_fork` so the session falls through to the
workspace-pick fallback instead of looping on the same broken intent.
* feat(sessions): show EditorHeader breadcrumb in the not-found state
When a session's target item has been deleted or moved, the editor pane
used to render a bare "Script not found at path X" line — leaving the
user with no way to navigate to a different target without backing out
of the session.
Each editor view now renders a `SessionItemNotFound` shell instead: a
real `EditorHeader` (read-only summary, no pen popover) with a
breadcrumb keyed to the missing kind+path, plus the "not found" copy
below. Clicking any breadcrumb segment opens the workspace picker
scoped to that level — pick a replacement and the session swaps target
via the existing `onNavigate` callback.
`SessionItemNotFound` maps `raw_app` to `EditorHeader`'s `kind: 'app'
+ raw_app: true` so the picker routes through `/apps_raw/...`; the
local label still says "Raw app not found" (not "App not found") so
the user knows which surface is missing.
* fix(picker): stop self-feeding fetch effect that OOM'd the tab
The drill picker's $effect watched `scope` and called `ensureLoaded`
on every change. `ensureLoaded` reads `loaded[kind]` synchronously
(to decide whether to show a spinner), so the effect ended up
subscribed to the very signal it fills. Each fetch result wrote
`loaded[kind] = items`; Svelte 5's $state proxy notifies on every
property set even when the reference is unchanged from cache, which
refired the effect, which called `ensureLoaded` again, which awaited
the cached fetch, which wrote `loaded[kind]` again... runaway loop.
In `/scripts/edit/...` the picker's lifecycle stabilised quickly
enough to mask the loop, but in a session pane (multiple warm
sessions, picker kept alive by the surrounding state) the cycle
spun freely — 29.8 million iterations in <100 ms during testing,
enough to OOM Firefox / kill the Chromium tab.
Two changes:
- Replace the scope-watching $effect with an explicit `setScope()`
helper called from `drill()`, `goUp()`, and `onMount`. Fetch is
now a callback reaction to user navigation, never a reactive
consequence of one. No closed feedback cycle is possible.
- Untrack the `loaded[kind]` read inside `ensureLoaded`. The search
$effect (which loads every kind on first keystroke) is still a
reactive caller; the untrack stops it from subscribing to the
signal `ensureLoaded` fills, so the same loop can't form there.
* feat(script-editor): wire initialTestPanelCollapsed through ScriptBuilder
The `initialTestPanelCollapsed` prop was already declared on
`ScriptBuilderProps` (used by the session preview to start the editor
with the run/test pane closed) but never destructured in
`ScriptBuilder.svelte`, so the value silently dropped on the floor
and the test pane always opened.
- `ScriptBuilder.svelte` — destructure the prop and forward it to
`<ScriptEditor>`.
- `ScriptEditor.svelte` — accept the prop and seed `rawTestPanelSize`
to 0 when true, while keeping `storedTestPanelSize` at the default
30 so the user's first toggle expands the pane to a sensible width
rather than 0.
Regular `/scripts/edit/...` doesn't pass the prop → default `false`
→ panel still opens by default.
* fix(sessions): resolve aiChatManager via context in AskUserQuestionDisplay
Inside a session the chat uses a per-pane AIChatManager injected via context. AskUserQuestionDisplay imported the global singleton, so answers clicked in a session dispatched to the singleton's callback map and the AI loop stalled. Resolve via getContext with singleton fallback, matching ChatMode / ToolExecutionDisplay.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(raw_apps): let preview start in single-view on the preview tab
Add a defaultSplitWithPreview prop (default true). When false (session preview), the editor boots in single view with the preview tab selected: gate the onMount default-file activation, the setActiveDocument auto-activation, and iframeShouldMount so the UI Builder bundler iframe still mounts when preview is the active tab. RawAppEditorView passes defaultSplitWithPreview={false}.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(copilot): add get_preview_status tool and make open_preview idempotent
So the assistant can tell whether the session preview already shows the item it just edited, instead of re-opening or re-offering it. Mirrors the open_preview handler plumbing (setGetPreviewStatusHandler) and the session runtime registers it alongside open_preview. open_preview now returns 'already open' when the requested target matches the active session's current target. The system prompt steers the AI to check status before offering. Unit tests cover the no-arg schema, the session-only error, and handler dispatch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): make script preview reactive to AI draft writes
ScriptEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists. None did for the preview path, so the chat's writes (UserDraft.save) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft, materializing the shared $state cell that bridges the chat's writes to the editor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): make raw-app preview reactive to AI draft writes
Mirror of the script-preview fix. RawAppEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists. None did for the preview path, so the chat's raw-app writes (UserDraft.save / setDraftAndMeta, from write_app_file / patch_app_file / write_app_runnable) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft. Verified in-browser: an external UserDraft.save live-updates the bound summary in the open preview.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): make flow preview reactive to AI draft writes
Mirror of the script/raw-app preview fixes, completing two-way binding for all three session editor kinds. FlowEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists — none did, so the chat's writes (write_flow / patch_flow_json / set_flow_module_code) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft. Verified in-browser both directions: an external UserDraft.save live-updates the flow header summary and rebuilds the module graph; a preview edit propagates through the debounced save to both UserDraft.get and the chat's getGlobalDraft adapter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sessions): surface local-storage drafts in fork diff & compare page
Augments the backend fork-vs-parent comparison with browser-local (UserDraft) drafts so a session's uncommitted AI/user changes are visible in the Fork Diff Viewer and the /forks/compare page. Adds forkDraftDiff.ts (augmentForkComparisonWithLocalDrafts + getForkItemValue), a 'local changes detected' / new-draft warning surface (checkbox-slot warning icon, no-op-baseline filtering, dedup), a 'Local draft <> fork' tab in DiffDrawer, and selectTooltip/nonSelectableTooltip plumbing in Row/WorkspaceDeployLayout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "feat(sessions): surface local-storage drafts in fork diff & compare page"
This reverts commit 3cfd858e36.
* fix(sessions): leave for home when switching workspace from the session page
An AI session is scoped to its (forked) workspace, so it makes no sense to keep showing it after the user picks a different workspace. The workspace switcher's link href now points home on the session route (the link navigation wins over onClick's preventDefault), and toggleSwitchWorkspace also redirects home there as a fallback. Session-switching uses a separate path (syncWorkspaceTo), so it's unaffected — which is why reacting at the switcher is more robust than watching workspaceStore.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): clear session highlight off the session page; default delete-fork on
Two SessionPicker fixes: (1) only highlight the active session while on the /sessions route — currentSessionId lingers after navigating away, so the row stayed selected in the sidebar; gate the highlight on the route. (2) The 'Also delete forked workspace' toggle in the delete-session modal now defaults to on (the fork is tied to the session and would be orphaned otherwise); resets keep it defaulted-on for the next open. User can still untick it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(copilot): say "local storage" instead of "draft" in write-tool status
The global chat's write tools persist to the browser's localStorage (UserDraft), not a workspace draft. The tool status / result messages now say the item was saved to local storage (and discard says it was discarded from local storage) so users aren't misled into thinking a workspace draft was created. Covers the shared script/flow/trigger/resource/variable helpers and the app tools.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): sidebar collapse, new-session chat, fork delete & not-found nits
- Hide the collapse chevron and make the section header non-interactive when there are no sessions; reset the persisted collapsed state while the list is empty so the first session always appears expanded.
- Stop grafting a recent past chat onto a freshly created session: ensureChatIdsSeeded now skips transient sessions, so the seed only pairs untagged chats with pre-existing sessions.
- After deleting a fork from a session (SessionPicker / SessionWrapper), fall back to the fork's parent workspace when the deleted fork was the active one, instead of stranding the user on a deleted workspace.
- Show a 'Session not found' message (with a New session action) when the URL names a session that doesn't exist, rather than rendering a blank page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(copilot): expose preview tools only to session chats
open_preview and get_preview_status drive a session's side-panel editor, so they only make sense inside an AI session. They were always present in the global tool list and just errored when called outside a session. Now AIChatManager carries an isSessionChat flag (set by sessionRuntime.createRuntime); the GLOBAL-mode branch uses globalToolsFor({ sessionPreview }) to drop the two tools for the regular side-panel chat, and prepareGlobalSystemMessage omits their guidance unless previewTools is set. The module-level handlers + in-tool error guards stay as defense in depth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(flow-editor): move intra-editor chat preservation to its own PR
The beforeNavigate / preserveChatOnDestroy guard that keeps the global FLOW
chat alive across same-flow editor remounts is a standalone global-chat fix,
unrelated to sessions. Split out to #9339; FlowEditor reverts to the plain
session-guarded saveAndClear lifecycle here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(raw-app): pre-boot session editor hidden so files open instantly
In single-view (sessions) the UI Builder iframe was mounted inside a
display:none wrapper while the Preview tab was active, so the VS Code
workbench booted at 0x0, threw in its LayoutService ("Unable to figure
out browser width and height"), and wedged the editor on "Loading
editor" with no recovery when later revealed.
Keep the iframe mounted at the editor area's real width and hide it with
visibility instead of collapsing it: Monaco boots correctly while hidden,
and revealing a file is an instant un-hide (no reload, no relayout, no
latency).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(flow-ai): move flow-group color-palette work to its own PR
The flow-group color-palette guidance + validateFlowGroups guard + tests are
an independent flow-AI improvement, not part of sessions. Split out to #9343;
these three flow files revert to their main state here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): hide the in-editor Flow AI Chat button in the session preview
The flow preview pane in a session already sits next to the session's own AI
chat, so FlowBuilder's in-editor "Flow AI Chat" toggle (which opens the global
singleton chat) is redundant and confusing there. Pass
customUi={{ topBar: { aiBuilder: false } }} from FlowEditorView, reusing the
existing showFlowAiButton gate (!disableAi && customUi?.topBar?.aiBuilder !=
false) that flows down to FlowStickyNode — no new prop needed.
Verified in-browser: the button (WandSparkles) renders in the regular
/flows/edit route but is absent in the session preview for the same flow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): mirror /scripts/add for never-saved scripts in editor preview
An AI-created script with no backend version yet left savedScript undefined
in the session preview, which disabled Save draft and hid Show diff. Open it
as a new script (empty initialPath) like /scripts/add so Save draft is enabled
and creates it on first save; seed the path as already-chosen
(initialPathChosen) so the summary->path auto-slug does not rename the
AI-assigned path. On first save ScriptBuilder writes savedScript back through
the bind and flips into edit mode (Save draft + Show diff) without navigating
away.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): refresh fork diff count after an editor draft save
The fork-bar diff count reads a cached comparison refreshed only on AI-turn-end or tab refocus. A 'Save draft' in the session editor registers in the backend fork tally asynchronously (~300ms after the create returns), so the count stayed stale until one of those triggers fired. Add SessionRuntime.scheduleForkComparisonRefresh() (re-fetches at 700ms + 2200ms to clear the async tally) and wire it to onSaveDraft in ScriptEditorView and FlowEditorView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): don't auto-open the settings drawer in script preview
When the AI's open_preview tool previews a never-saved script, ScriptEditorView
passes initialPath='' so ScriptBuilder behaves like /scripts/add. That empty
path also triggered ScriptBuilder's auto-open of the settings drawer, which is
unwanted in the session preview where the AI manages metadata. Pass
neverShowMeta so the drawer stays closed on mount; the Settings button still
opens it manually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): don't host legacy drag-and-drop apps in the editor preview
The session preview pane only hosts code-based items (flow, script, raw
app). Drop the legacy 'app' kind from SessionTarget and the open_preview
tool, and route a legacy app picked in the drill picker to the standalone
/apps/edit editor instead. Removes the now-dead AppEditorView and its
runtime load path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): don't prompt to discard raw-app changes on navigation
In a session the raw-app editor's content is continuously persisted to the
UserDraft (localStorage), so tearing the editor down on navigation loses
nothing. Skip the UnsavedConfirmationModal (and its beforeNavigate guard)
when the editor is mounted inside a session pane; the standalone /apps_raw
editor still shows it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sidebar): pin Help to the bottom instead of floating
The bottom of the sidebar stacked the User/Settings cluster and the Help
block with a fixed ~40px gap between them, plus a bottom margin that kept
Help from sitting flush — so Help appeared to float. Drop those fixed
margins so the cluster and Help stay glued at the bottom with a small gap
and Help is flush, and let mt-auto own the flexible space above the group.
Add pt-4 so the cluster keeps a minimum gap from the Triggers section when
the sidebar runs out of room and that flexible space collapses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): surface diff/discard for AI script drafts in preview, refresh diff on deploy
loadScript built the editor's scriptStore by aliasing and mutating savedScript.val, so the deployed baseline got overwritten with the draft content and the diff compared draft-vs-draft. Clone the baseline before layering the AI draft on top. On load, when the local draft diverges from the saved baseline, surface a toast ('AI saved a local draft') with Show diff (opens the diff drawer with a Discard-draft button) and Discard local draft — mirroring the regular /scripts/edit affordance the session's parallel loader omitted. Also wire onDeploy (alongside onSaveDraft) to scheduleForkComparisonRefresh so the fork diff count refreshes after a deploy, not just on an AI turn or tab refocus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): script preview restore/deploy feedback; drop on-load draft toast
- Implement real restoreDeployed/restoreDraft for the diff drawer: the shared loadScript-based handler was a no-op (loadScript early-returns on the loaded path and would re-read the local draft). Reset the live UserDraft handle to the chosen baseline (deleting the backend draft for 'restore to deployed') so the inbound effect syncs the editor.
- Show a 'Deployed' toast on deploy: the default Deploy takes ScriptBuilder's no-toast branch (the editor navigates away instead); the session stays put, so surface the success toast.
- Remove the on-load 'AI saved a local draft' toast: unnecessary in a session, where the user already expects their changes to be present. Diff/discard remain reachable via ScriptBuilder's Show diff + the diff drawer's restore buttons.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): gate breadcrumb picker draft-merge behind the dev flag
The WorkspaceItemDrillPicker merges localStorage UserDrafts into its
navigable items so in-flight session/chat drafts are reachable. That
merge was ungated, so with the sessions dev flag off it also surfaced
the standalone editors' autosave drafts — they appeared as navigable
rows that 404 on the backend draft fetch. Gate aiDraftsForKind on
isGlobalAiEnabled() so it is a no-op without the flag (no sessions
exist then anyway); inside sessions the merge still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): reload script/flow editor on client-side breadcrumb nav
Picking a different item in the editor-header breadcrumb picker calls
goto() for a client-side navigation. SvelteKit reuses the same +page
instance across a path-param change, but the script and flow editor
routes captured `draftPath` and the `UserDraft.use()` handle once at
mount and never remounted ScriptBuilder/FlowBuilder. The URL and title
updated while the editor kept showing the previous item's breadcrumb,
summary and content; only a full reload showed the navigated-to item.
Mirror the pattern the app / raw-app editors already use:
- Derive the draft path from the URL and key the handle off it via
`UserDraft.useMany` (a stable proxy onto the current handle), so the
reload reads/writes the navigated-to item's draft instead of the
previous one's — fixing the stale draft-comparison too.
- Gate the builder subtree on a `renderEditor` flag flipped false when a
navigation kicks off the reload and true once the data is ready, so
the builder cleanly unmounts and remounts once against stable data. A
synchronous `{#key}` swap instead races Monaco's async init against
the torn-down container.
- Flows also reset `nobackenddraft` per navigation so a fresh load
reconsiders the backend draft.
The unsaved-changes guard is unaffected (it runs in beforeNavigate,
before the remount). The app and raw-app editors already handled this
and are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sessions): sync preview with the deployed version on editor + chat deploy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sessions): reload the preview after a chat raw-app deploy
The deploy-reload-preview callback added previously only fired for script and
flow. Now that the merged deploy_workspace_item tool can deploy raw apps
(bundle + createAppRaw/updateAppRaw), wire raw apps in too. A raw app deploys
under type 'app' but the session preview addresses it as 'raw_app', so the
deploy handler maps 'app' -> 'raw_app'; the runtime open-check gains the
loadedRawAppPath case. syncPreviewWithDeployed already handled 'raw_app'
(discard the local draft + force-reload via loadRawApp), so no runtime change
was needed there.
Adds a unit test asserting deploy_workspace_item(type:'app') notifies the
session handler with { kind: 'raw_app', path }.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): address Claude PR review (3 P1 + 3 P2 + test)
P1:
- Drop the hardcoded placeholder default sessions (u/guilhempw/...). New users
(empty/cleared/private-browsing localStorage) now start with no sessions and
see the empty state instead of unresolvable "session not found" rows.
- Scope the preview/deploy tool handlers to the *calling* session. open_preview,
get_preview_status and the deploy reload handler dispatched via the global
currentSessionId, so a backgrounded session's tool call mutated the UI-active
session. The calling session id is now carried in the per-manager tool
`helpers` (AIChatManager.sessionId, set in createRuntime) and threaded through
the tool ctx to the handlers, which dispatch to it (falling back to the active
id only when absent). Keeps backgrounded sessions isolated.
- beforeSend now aborts the send on failure: commitSessionWorkspace throwing used
to be swallowed, letting the message go out against the wrong workspace
silently. Now it toasts and returns. Also guarded the unguarded
listUserWorkspaces refresh in materializeFork's duplicate-key self-heal so a
second network failure can't rethrow past the toast-and-return contract.
P2:
- disposeRuntime now clears the fork-comparison refresh timers (700ms/2200ms)
via a new runtime.dispose(), so an evicted/deleted runtime can't fire a stray
refreshForkComparisonNow/compareWorkspaces after teardown.
- Convert Svelte 4 on:click -> Svelte 5 onclick on the Button components in
SessionWrapper, SessionForkBar, ForkDiffDrawer, SessionPicker, sessions/+page.
- WorkspaceItemRow's <a href> branch gains role="option" + aria-selected to match
the <button> branch, for consistent listbox semantics.
Tests:
- core.test.ts: deploy_workspace_item(type:'app') threads the calling session id
through helpers to the deploy handler ({ sessionId, kind:'raw_app', path }).
- New sessionState.test.ts unit-tests deriveForkStatus + isForkSession across
all branches (root/fork/unavailable/draft, ahead/behind/diverged/in_sync).
svelte-check 0 errors; 57 frontend unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(copilot): collapse deploy preview-reload dispatch to a type→kind map
Replace the if/else-if that mapped deploy type to preview kind with a single Partial<Record<WorkspaceItemType, ...>> lookup + one if. Non-previewable types map to undefined → no dispatch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(copilot): use getAiChatManager() instead of inlining the context fallback
Six chat components still inlined
`getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager` even
though aiChatManagerContext.ts already exports getAiChatManager() for exactly
this (the resolve-scoped-instance-or-fall-back-to-singleton pattern, already
used by AIChatDisplay/AIChatInput/AIChatMessage/CodeDisplay). Adopt it in
DatatableCreationPolicy, ChatMode, ToolExecutionDisplay, AIChat,
AskUserQuestionDisplay and flow/FlowAIChat, and drop the now-unused getContext /
AIChatManager / singletonAiChatManager imports (FlowAIChat keeps getContext for
its FlowEditorContext/FlowCopilotContext lookups).
No behavior change — getAiChatManager() is the same resolution.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): consistent script deploy → preview sync; trim session deploy menu
Two related session deploy fixes + clarifying comments.
1. Hide the extra deploy-dropdown options in the session script preview. The
editor always "stays" and is already scoped to a fork, so Deploy & Stay here,
Fork, Edit in workspace fork, Exit & See details and Export as YAML/JSON make
no sense there — only "Show diff" is kept. ScriptBuilder gains
`inSessionPane = !!getContext('aiChatManager')` (same pattern ScriptEditor
uses) and gates those items. (They were correctly absent for never-deployed
session scripts but leaked for deployed ones.)
2. Fire onDeploy on every successful script deploy. ScriptBuilder previously
skipped onDeploy for "Deploy & Stay here" and lib scripts (it just re-pinned
parent_hash + toasted), so a session preview wouldn't sync after those. Now
onDeploy always fires with a `stay` flag; route consumers skip navigation when
stay (behaviour identical to before — stay → toast only, primary → navigate),
and the session ignores stay and always syncs. With (1) hiding Deploy & Stay,
this now covers the lib-script-in-session case.
3. Comments: RawAppEditorHeader / AppEditorHeader note that the
`if (!inSessionPane) UserDraft.remove` guards are intentional — the editor
doesn't own the localStorage draft in a session (the runtime does, keyed by
the fork); the session-side equivalent is the View's onDeploy →
runtime.syncPreviewWithDeployed (discard fork draft + reload to deployed).
svelte-check 0 errors; session dropdown verified to show only "Show diff" for a
deployed script, route deploy menu unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sidebar): single Menubar so bottom menus hover-switch (WIN-1993)
The bottom sidebar group split Settings/Workers/Folders/Logs and Help across
two separate <Menubar> components. melt-ui's hover-to-switch (open menu closes
when another trigger in the same Menubar is hovered) only coordinates within a
single Menubar, so hovering between the two groups left both menus open
(stacked) instead of switching. Collapse them into one Menubar, wrapping each
group in its own flex container to preserve spacing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(editor): gate external code sync behind opt-in syncExternalCode prop
The unconditional `code` prop->Monaco sync effect added for sessions
live-preview ran for every <Editor> caller (14 call sites). Most either
bind:code with their own external-sync (e.g. ScriptEditor) or treat code as
init-only, so a blanket setValue risked clobbering them. Gate the effect on a
new opt-in `syncExternalCode` prop (default off) and enable it only at the two
flow inline-rawscript editors — the case that actually needs external updates
(AI chat editing a flow module's content reflecting live in the preview).
Verified in-browser: AI-driven external edit to a flow step now reflects live
in Monaco, and typing keeps the caret intact (round-trip guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sessions): address P1 review findings (commit-abort, render-stuck, workspace sync)
From the cubic/Claude PR review:
1. beforeSend now aborts the send when the workspace isn't committed. The earlier
fix only caught a *thrown* error, but commitSessionWorkspace returns undefined
(never throws) when a staged fork fails to materialise — so the first message
+ its tool calls shipped to get(workspaceStore) (the parent). beforeSend now
throws on undefined so AIChatManager's catch toasts + aborts.
2. The script/flow edit reload effect set renderEditor=false then called
loadScript()/loadFlow(); a rejected fetch left renderEditor stuck false, so the
editor pane vanished and never remounted. Both calls now .catch → toast +
renderEditor=true (token-safe), so the pane always remounts.
3. SessionWrapper.moveAndActivate now syncWorkspaceTo(target) — moving a session
off an unavailable workspace was leaving the app pointed at the old one
(mismatch with moveSessionToNewFork / handleConfirmedDelete).
Test: sessionState.test.ts pins commitSessionWorkspace's failure contract
(returns undefined + drops pending_fork when the fork fails) — the invariant the
beforeSend abort relies on. svelte-check 0 errors; 58 frontend unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): address P2 review findings (cubic)
Draft round-trip:
- appDraftCodec: carry custom_path through runtimeRawAppToDraft /
applyDraftToRuntimeRawApp (+ seed it in loadRawApp) so a session round-trip
no longer erases a raw-app draft's custom URL.
- sessionRuntime.loadScript "no draft" path: structuredClone the baseline before
setting parent_hash — it could alias `result` (= savedScript.val) and corrupt
the pristine deployed baseline the diff drawer reads.
- FlowEditorView: include `summary` in the inbound/outbound dedup sigs so
summary-only changes propagate/persist.
Workspace-state on navigation:
- SidebarContent (post-delete) and workspace_settings (post-archive): guard the
listUserWorkspaces() refresh so a transient failure can't strand the user on
the just-removed workspace, and refresh the list before switching to parent.
- WorkspaceMenu: keep ?workspace=<id> in the session-page workspace href so a
modifier/middle click (which bypasses onClick) lands in the right workspace.
UI/keyboard:
- WorkspaceItemRow: indent adds to the px-3 base (calc) instead of replacing it.
- ForkDiffDrawer: ArrowLeft maps a 2-segment file path (f/foo) to its scope
folder (folder:f/foo) instead of a nonexistent folder:f.
- flows/edit: defer flowBuilder setup (primary schedule, draft triggers,
loadFlowState) until after the builder remounts (renderEditor=true + tick),
so reload-time state restoration isn't skipped on the unmounted builder.
svelte-check 0 errors; 58 frontend unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(sessions): unit-test the P1/P2 review fixes (extract pure helpers)
Extract the pure logic touched by the review fixes into small tested helpers
(behaviour-preserving) and add unit tests:
- appDraftCodec.test.ts — custom_path survives the runtime↔draft round-trip (A1).
- forkDiffNav.ts/.test.ts — parentFolderKey (extracted from ForkDiffDrawer):
ArrowLeft parent resolution incl. the 2-segment-path case (C2).
- workspaceMenuHref.ts/.test.ts — extracted from WorkspaceMenu: session-route
href keeps ?workspace=<id>; off-session swaps the param (B2).
- flowDraftSig.ts/.test.ts — extracted from FlowEditorView (dedups 3 sig sites):
the dedup signature includes summary, so summary-only changes propagate (A3).
(commitSessionWorkspace failure-contract test for the beforeSend P1 landed with
the P1 commit.) svelte-check 0 errors; 75 frontend unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sessions): address second-round review (Pi + Codex)
Three findings flagged post-push (cubic was fully addressed in the prior
commits; this commit covers the new ones):
- [P1] commitSessionWorkspace non-fork branch — when a session created
inside a fork defaults pending_workspace_id to the family root, commit
set s.workspace_id but never synced workspaceStore. First send's
logAiChat + tool calls then ran against the wrong (still-fork)
workspace. Fix: syncWorkspaceTo(ws) after the commit, mirroring the
pending_fork branch's switchWorkspace(newId).
- [P1] Warm-session live-editor slot hijack — /sessions keeps up to 3
warm-mounted sessions; UserDraft stores one live editor per
(workspace, kind). Each editor view unconditionally claimed the slot,
so a hidden warm session in the same workspace+kind could overwrite
the visible session's claim — chat actions like discard /
"the open editor" then resolved to the wrong session. Fix: thread
isActiveSession from SessionWrapper into Script/Flow/RawAppEditorView
and gate setLiveEditorDraft on it.
- [P2] ForkDiffDrawer stale per-item raw diff cache — loadedDiffs /
summaries persist for the drawer's lifetime; fetchComparison refetched
on each open() but loadDiffFor short-circuited on cached keys, so an
edit-then-reopen showed fresh counts but stale expanded content. Fix:
clear both records at the top of fetchComparison.
Tests:
- sessionState.test.ts: 2 tests pinning commitSessionWorkspace's
workspaceStore sync (mismatch and matching).
- userDraft.test.ts: 3 tests pinning the live-editor slot collision
(regression), the active-session gate, and cleanup ordering.
- forkDiffCache.test.ts (new): 2 tests for the drawer cache
invalidation contract via fetchComparison simulation.
Verified end-to-end in browser: P2 (close+reopen drawer triggered an
identical second batch of per-item get fetches), P1#1 (new-session send
from a fork synced localStorage.workspace to root and posted chat to
/api/w/local/...), P1#2 (raw_app slot for workspace=local correctly
follows the visible session across A→B→A switches while both stay
warm-mounted). svelte-check 0 errors; touched test suites green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(frontend): remove localStorage-backed autosave drafts
Strip the per-editor localStorage autosave for flows, apps and raw apps,
along with the associated restore toasts and diff actions, so we can
replace them with a unified UserDraft service in a follow-up. The
backend DraftService (DB-backed drafts) is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): add UserDraft service for per-workspace local drafts
Introduces UserDraft, a key-value store keyed by
`{workspace}/{itemKind}/{path}` and backed by localStorage. Supports
save/get/remove plus a reactive use() handle so multiple component
instances observing the same draft stay in sync via a shared $state
loaded through useLocalStorageValue. Designed to host drafts for
scripts, flows, apps, raw apps, resources, variables, and all trigger
kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* tests
* nit schedule_ prefix
* feat(frontend): persist deep mutations in useLocalStorageValue
Track the serialized value alongside the $state and add an $effect that
deep-reads it (via readFieldsRecursively). When a deep mutation produces
a serialization that differs from the last persisted blob, write it to
localStorage. The setter keeps writing synchronously so callers reading
localStorage right after assignment still see the new value; the effect
no-ops on those because lastSerialized was already updated by the setter.
Undefined values are persisted as a removal.
UserDraft no longer needs its own removeItem workarounds for undefined
values — useLocalStorageValue handles that uniformly now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): add defaultValue + empty-path handling to UserDraft
UserDraft.use() accepts an opts.defaultValue used when no localStorage
entry exists yet. It is not persisted on first read — only an actual
mutation writes through.
Empty paths (new items) bypass localStorage entirely. The entry still
lives in the in-memory Map so multiple components on the same /add page
share state, but save/get/remove/use never read or write localStorage
with an empty path. Once the item is saved and the route navigates to
its new URL, a fresh use() on the non-empty path takes over.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire script editor to UserDraft
The script editor's top-level state now lives in UserDraft.use(), keyed
on the route's path (page.params.path on /scripts/edit, '' on /scripts/add).
Deep edits inside ScriptBuilder persist automatically; deploy and draft
restore now call UserDraft.remove to clear the local autosave alongside
the backend draft.
Replaces the URL-hash autosave that ScriptBuilder used to write via
replaceStateFn — that prop is now gone, the encodeScriptState debounce
is gone, and Triggers no longer takes a saveSessionDraft callback.
Viewing a specific historical hash (?hash=...) is kept draft-free by
passing '' as the path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire flow editor to UserDraft
flows/add and flows/edit drive the flow value through a StateStore
adapter backed by UserDraft.use, so every edit auto-persists at
userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal
.val convention. On returning visits the local autosave wins and a
toast offers a diff against the latest backend draft/deployed version;
on a fresh visit the backend value is written into the handle. Deploy,
save-as-draft rename, restore-draft and restore-deployed each call
UserDraft.remove on the route path so the local autosave doesn't
outlive the action.
Adds UserDraft.has() for "is there already a local draft?" detection
in the load path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire app editor to UserDraft
AppEditor registers a UserDraft.use<App> handle for its current path
(empty path for /apps/add stays in-memory) and a single $effect
deep-tracks the internal stateApp and forwards every mutation to the
handle. useLocalStorageValue's lastSerialized check then dedupes the
actual localStorage writes per tick, so even fast drag/resize loops
only persist when the JSON output really changes.
/apps/edit overlays a local autosave from UserDraft.get on top of the
backend value when one exists, with the existing "Discard / Show diff"
toast wired to UserDraft.remove. Deploy, save-as-draft, restore-draft
and restore-deployed all call UserDraft.remove on the relevant path,
including the JSON editor save paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire raw app editor to UserDraft
/apps_raw/edit owns the canonical raw-app state (files, runnables,
data, summary) in four $state vars; a single $effect deep-tracks them
and forwards the bundle to a UserDraft.use<RawAppDraft> handle so each
mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by
useLocalStorageValue's serialized check). On load the route overlays
the local autosave on top of backend.draft/deployed and offers a
"Discard / Show diff" toast when they diverge; matching local entries
are silently dropped. Deploy, save-as-draft rename, restore-draft and
restore-deployed each call UserDraft.remove on the route path.
/apps_raw/add keeps the same shape (UserDraft.use with empty path)
so the draft is in-memory only and we drop it explicitly when the
initial save creates the real path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire resource editor to UserDraft
ResourceEditor registers a UserDraft.use<ResourceState> handle keyed
on the initialPath (empty for new resources, in-memory only). A
$effect deep-tracks the current workspace's edit state and forwards
mutations to the handle; on bootstrap and lazy backend-fetch the
local autosave wins over the backend value when they diverge. After
a successful save() we call UserDraft.remove so the local autosave
doesn't outlive the deploy. Cross-workspace deploys always start from
the live backend value rather than the local draft.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire variable editor to UserDraft
VariableEditor persists the current workspace's edit state via
UserDraft.save on every mutation, keyed on editPath ('' for new
variables → in-memory only). Backend fetches now overlay a matching
local autosave when one exists, and initNew() rehydrates from the
in-memory empty-path entry so opening a fresh "Add variable" drawer
keeps any unsaved work from the previous open. After a successful
save we drop the corresponding entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* editor external changes sync
* fix(frontend): don't UserDraft.remove flows while route is still mounted
The /flows/add and /flows/edit routes drive FlowBuilder from a flowStore
whose getter reads flowHandle.draft directly. Calling UserDraft.remove
synchronously before goto() therefore wiped the in-memory entry, made
flowStore.val collapse to emptyFlow(), and tripped
UnsavedConfirmationModal against the just-saved value — even though the
deploy/save-draft itself succeeded.
Drop those explicit removes in onSaveInitial, /add onDeploy, and
/edit onDeploy. The empty-path entry self-cleans on unmount via
onDestroy ref counting; for the non-empty edit path the next visit's
load-time diff will silently overwrite localStorage when the local
autosave matches the deployed value. Restore-draft/restore-deployed
keep their explicit remove because they navigate to the same route
(no modal) and loadFlow immediately rehydrates the handle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "fix(frontend): don't UserDraft.remove flows while route is still mounted"
This reverts commit 079ebef72b.
* Only remove from localStorage
* feat(frontend): saveInitialValue option on useLocalStorageValue
The first time a value flows into a UserDraft.use() handle — typically
the editor route loading the backend value via flowHandle.draft =
backendFlow — is the baseline, not a user edit. Persisting it on the
spot puts a copy of the backend into localStorage on every page open
and produces spurious "local autosave" toasts on next visit when the
serialization round-trips differently.
useLocalStorageValue now takes options.saveInitialValue (default true,
backward compatible). When false, the first time the serialised form
of the state changes — via the setter or via a deep mutation — the
lastSerialized cache is updated but localStorage is not touched. Every
write after that persists normally. UserDraft.use() passes false.
Tests updated to reflect the new contract (first write is the
baseline) and a regression test added for the second-write-persists
behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(frontend): persist full multi-workspace bundle for resources/variables
ResourceEditor and VariableEditor can stage edits for several target
workspaces in a single drawer session (see deployTo / states[ws] map).
The previous UserDraft wiring only persisted states[$workspaceStore] —
the user's session workspace — so any edit made under a different
target workspace tab disappeared on refresh.
Persist the entire `states: Record<wsId, State>` bundle as the draft
value instead. On lazy-fetch we pick the local state for that ws if
present and divergent from the backend; on bootstrap for new
resources/variables we restore states for every workspace the user
had staged. The localStorage key still lives under the user's session
workspace via UserDraft, but its contents now cover all target
workspaces from that session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(frontend): bake parent_hash into the initial script load
loadScript() assigned the backend value to scriptHandle.draft and then
deep-mutated parent_hash on the next line. Under
useLocalStorageValue's saveInitialValue=false contract only the very
first write is the baseline — the parent_hash mutation right after
counted as a second write and was persisted to localStorage, so
opening an existing script would silently write a draft entry even
though the user hadn't touched anything.
Combine `parent_hash` (and the topHash override) into a single
bakedBaseline so each branch of loadScript performs exactly one
assignment to scriptHandle.draft. Mirrored across the local-autosave
branch's discard callbacks too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire SqsTrigger editor to UserDraft
Persist the trigger's getSaveCfg() output to
userdraft/w/{ws}/schedule_sqs/{path} on every edit, overlay any
existing local autosave on top of the backend value when openEdit
loads the trigger, and clear the entry on successful update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire KafkaTrigger editor to UserDraft
Same pattern as the Sqs trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger, drop the entry on successful update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire NatsTrigger editor to UserDraft
Same pattern as the Kafka trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger (with initialConfig/originalConfig snapshotted from
backend first so hasChanged correctly reports the overlay as unsaved),
drop the entry on successful update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire MqttTrigger editor to UserDraft
Same pattern: persist getSaveCfg() on edits, overlay local autosave
in openEdit (with initialConfig/originalConfig snapshotted from
backend first), drop the entry on successful update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire GcpTrigger editor to UserDraft
Same pattern as the other triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire AzureTrigger editor to UserDraft
Same pattern as the other triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire WebsocketTrigger editor to UserDraft
Same pattern as the other triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire PostgresTrigger editor to UserDraft
Same pattern as the other triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire EmailTrigger editor to UserDraft
Same pattern as the other triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire HTTP RouteEditor to UserDraft
Same pattern as the other triggers, keyed on schedule_http.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): wire ScheduleEditor to UserDraft
Same pattern, keyed on schedule_schedule. ScheduleEditor doesn't track
an originalConfig (its saveDisabled doesn't compare against a baseline)
so ordering is simpler — initialConfig snapshotted from backend, local
autosave overlaid after.
This completes UserDraft wiring across all 11 trigger editors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(frontend): rename schedule_* UserDraft kinds to trigger_*
The schedule_ prefix grouped all the trigger editors under what looked
like a "scheduler" namespace; trigger_ is what these actually are
(triggers — including the cron-style schedule). Mechanical rename
across UserDraftItemKind, every trigger editor's UserDraft.save/get/
remove calls, and the one test that asserted on the localStorage key.
Behaviour-only impact: existing localStorage keys under
userdraft/w/{ws}/schedule_{kind}/{path} from older builds will be
ignored on next open (no schema migration). Users will lose any
unsaved trigger drafts persisted before this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(frontend): wrap UserDraft localStorage payload as { value }
localStorage entries now look like {"value": <draft>} instead of just
<draft>. The wrapping is invisible at the API boundary — UserDraft.use,
.save, .get, .remove all still operate on the unwrapped draft value —
but it leaves room to add metadata (timestamps, originating user,
schema version, ...) later without breaking existing entries.
Internals:
- StoredDraft<V> = { value: V } is what we serialise to localStorage
and what useLocalStorageValue's $state holds.
- wrap()/unwrap() helpers gate the boundary; the handle returned by
use() unwraps on get and wraps on set.
- readPersisted() defensively drops entries whose payload isn't a
{ value: ... } object, so pre-migration drafts written by earlier
commits on this branch are simply ignored (has() returns false,
get() returns undefined) rather than confusingly surfacing as
undefined-shaped drafts.
Test data switched from { value: X } (which collides confusingly with
the wrapper shape) to plain primitives / objects, plus a regression
test for the pre-migration ignore behaviour. 28 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(backend): expose freshness for UserDraft staleness check
Variable
- Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE.
- Surface them on `ListableVariable` so `getVariable` / `listVariable` return them.
DB drafts (script, flow, app/raw_app)
- The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal.
- Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match.
The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): track remote rev metadata on UserDraft entries
Extends StoredDraft<V> with two optional rev fields used by the
forthcoming staleness modal:
- remoteRev — the deployed version's id/hash/timestamp at the moment
the local draft was created. Compared against the latest deployed
rev on reload.
- remoteDraftRev — the DB-draft created_at at the moment the local
draft was created. Only meaningful for kinds that have a DB draft
(script, flow, app, raw_app). Checked first so a teammate's draft
push is detected before the "deployed version moved" case.
API additions on the handle returned by UserDraft.use():
- handle.meta — read the rev metadata currently stored.
- handle.setDraftAndMeta(value, meta) — atomic write of value + meta in
a single state.val assignment. Editor routes use this on load so the
baseline rev rides along with the value without consuming the
saveInitialValue=false dedup slot twice.
- handle.setMeta(meta) — update just the rev metadata after the user
picks "Keep current draft" in the staleness modal.
- handle.draft = X — unchanged surface; now preserves existing rev
metadata across user edits.
Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted
rev metadata when called without a live handle.
7 new tests cover the metadata surface; all 35 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): staleness modal for the script editor's local autosave
Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.
Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.
The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.
* feat(frontend): staleness modal for flow, app, and raw-app editors
Migrates the flow, app, and raw_app editor routes to the same
`LocalDraftStaleModal` flow already used by scripts: compare the
recorded meta against the current `version` / `versions[last]` and
`draft_created_at`; on mismatch, surface the choice in a modal.
Adds `UserDraft.saveMeta` for routes that don't hold a live handle
(the app editor reads via `UserDraft.get` and the handle lives in
the child `AppEditor` component). It writes meta directly to
localStorage and tolerates the no-entry case.
* feat(frontend): migrate legacy localStorage autosave entries
Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`,
`app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded
state envelope. This adds a one-off migration that rewrites surviving
legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}`
keys with the new `{ value }` wrapper, transforms the payload where the
shape differs (drops the flow view-state envelope, defaults the new
raw-app `summary` field), and drops the source key.
The migration lives in its own file (`userDraftLegacyMigration.ts`)
so the new UserDraft service stays free of legacy decoders. Idempotent
via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in
root layout once a workspace is known. Defensive shape checks avoid
clobbering co-resident apps that happen to use the same key prefixes.
* nit remove comments
* refactor(frontend): per-workspace UserDraft handles in Resource/Variable editors
Earlier commits in this PR wired the resource and variable editors to a
single multi-workspace bundle stored under the user's session workspace
key — which mixed workspaces in one localStorage entry and required a
custom multi-key fix-up pass to persist edits for other workspaces.
Reset both editors to their pre-PR shape and apply the minimal change:
the per-workspace `Record<string, ResourceState>` (resp. `VariableState`)
becomes `Record<string, UserDraftHandle<…>>`, with one handle per
workspace created via `UserDraft.use(…, { workspace: ws })`. The handle
keys its own localStorage entry under that workspace, so cross-workspace
edits stay cleanly separated and reactivity flows through the handle's
`draft` accessor — `bind:` on form fields just works.
Adds `manualRelease: true` + `handle.release()` to `UserDraft.use` so
the editors can register handles lazily inside an effect (Svelte 5
forbids `onDestroy` outside component init). The editors register a
single top-level `onDestroy` that releases every collected handle.
After a successful save, the per-workspace autosave is cleared via
`UserDraft.remove(itemKind, path, { workspace })`.
* refactor(frontend): seed per-workspace handles via UserDraft.use defaultValue
ensureHandle was doing a post-hoc `if (h.draft === undefined) h.draft = baseline`,
which relies on the saveInitialValue=false skip to swallow that seeding
write. Hand the baseline to `UserDraft.use({ defaultValue })` instead —
useLocalStorageValue uses it as the initial $state value when localStorage
is empty, so lastSerialized is correct out of the gate and no setter call
is needed.
* feat(frontend): persist empty-path drafts across reloads
Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.
- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
`userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
refcounting and saveInitialValue=false behavior already handle them
correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
when `?nodraft=true` is present in the URL, before the handle is
created.
- The two "+" entry points that lacked the `?nodraft=true` flag
(CreateActionsScript's plain `<a href>` and CreateActionsFlow's
YAML/JSON import paths) now include it, so every fresh-start path goes
through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
"empty path (persists across reloads)" and asserts the new behavior.
* refactor(frontend): drop legacy-migration shape guard
We assume Windmill is the only app on the origin, so the
isPlausibleLegacyValue per-kind shape check was just dead weight.
Keep the cheap "decoded is an object" guard for malformed payloads.
* docs(frontend): refresh stale "in-memory only" comments around empty paths
Empty-path UserDraft entries persist now. Drop the leftover "in-memory
only" comments on the /add pages' handle creation, and rewrite the
EditorHeader save-initial-draft comments to describe why the UserDraft.remove
call is still needed: the draft was promoted to a real path on the
backend, so the prior-path autosave must not shadow a future "+ App" /
"+ Flow" / … visit.
* fix(frontend): strip ?nodraft=true from /add URLs synchronously
The previous cleanup ran in afterNavigate, which (a) fires asynchronously
— a quick reload between mount and the callback would re-wipe the
freshly-started draft — and (b) did `url.search = ''`, nuking sibling
params like ?template, ?hub, and ?wac.
Move the URL cleanup to the same synchronous block that calls
UserDraft.remove on nodraft, using `window.history.replaceState` so it
lands before paint. Only the `nodraft` key is removed — other params
survive.
* feat(frontend): toast when editor opens on a local autosave
When a route loads its local autosave (differs from backend, no
staleness alarm), surface "Restored from local storage" with up to
two reset actions:
- "Reset to saved draft": drop the autosave, reapply the backend DB
draft. Only shown when the backend has a DB draft.
- "Reset to deployed": drop the autosave, delete the DB draft on the
backend (if any), reload from the deployed version. Only shown when
the item has a deployed version.
The toast title + label wording + per-state inclusion live in a
single helper (`$lib/userDraftToast`). Each editor passes its own
reset callbacks since the side effects differ per route (handle vs
UserDraft.get/save, redraw counters, loadXxx helpers).
Wired to scripts/edit, flows/edit, apps/edit, apps_raw/edit. Resource
and variable editors don't have DB drafts and use per-workspace
handles — a follow-up will tailor a single-action version.
* feat(frontend): load URL-encoded scripts on /scripts/add
The "Fork" action on run/[...run] and several workspace-settings
helper-script templates base64-JSON-encode a NewScript into the URL
hash on `/scripts/add#...`. Until now /scripts/add silently dropped
that payload — both call sites landed on a blank editor.
Decode `page.url.hash` at module top, and if it parses to an object,
apply it as `scriptHandle.draft` and surface "Loaded from URL". The
URL value wins over local autosave, ?template, ?hub, and YAML imports
because the hash represents an explicit "open this script" intent.
Parsing is inlined rather than reusing `decodeState` so an unrelated
hash (e.g. a future route anchor) doesn't fire its default "Impossible
to parse state" error toast.
* feat(frontend): strip URL hash from /scripts/add after consumption
The URL-encoded script is a one-shot seed (Fork preview, workspace
handler templates, hub publish) — keeping the hash in the bar after
loading meant a reload would re-apply the original payload and wipe
whatever the user edited since landing.
After applying `urlScript` and firing the "Loaded from URL" toast,
clear `location.hash` via `window.history.replaceState`. The user's
edits then flow into the normal autosave path (UserDraft empty-path
entry), and a reload restores those edits instead of the seed.
* feat(frontend): load URL-encoded scripts on /scripts/edit + consume-once
Mirror the URL-hash seed mechanism from /scripts/add to /scripts/edit
for parity: decode the base64-JSON-encoded NewScript payload from the
URL hash, apply it over the bakedBaseline as the editor's initial
state, send "Loaded from URL", and strip the hash immediately via
window.history.replaceState so a reload restores the user's autosave
rather than re-injecting the seed.
The seed wins over local autosave + backend draft + deployed —
UserDraft.remove(script, draftPath) drops the stale autosave on disk
before setDraftAndMeta writes the seeded value, so the user's
subsequent edits will overwrite cleanly.
Skipped when ?hash= is in the URL (historical-version view, which is
read-only relative to drafts) and when the hash fragment isn't a
parseable encoded payload.
No callers build /scripts/edit#<encoded> URLs today — this lands the
mechanism for future symmetry with /scripts/add.
* fix(frontend): "Reset to deployed" loop on Restored-from-local toast
UserDraft.remove only clears localStorage — the entry's reactive cell
stays alive as long as some component holds a handle. The toast
callback was relying on remove+loadXxx to reset state, but loadXxx
then read the *in-memory* autosave through the still-alive entry,
matched it against the now-deployed reference, and re-fired the same
toast. Forever.
Drop the in-memory state explicitly before the load:
- scripts/flows/apps_raw (route-level handle): `handle.setDraftAndMeta(undefined, {})`
- apps (handle lives in the AppEditor child): set `app = undefined`
to unmount AppEditor — its onDestroy releases the handle and the
entry's refcount drops to 0, destroying the entry.
ScriptBuilder / FlowBuilder / RawAppEditor briefly unmount while the
reload fetches; the flash is the user-visible "loading" cue.
* fix(backend): convert draft.created_at to TIMESTAMPTZ
The new `*WithDraft` endpoints surface `draft.created_at` as
`Option<chrono::DateTime<Utc>>` for the frontend's staleness check,
which requires `TIMESTAMPTZ`. The column was originally created as
plain `TIMESTAMP`, so SQLx fails to deserialize any row that has a
non-null draft and the handler returns HTTP 400 instead of 200 —
caught by `test_draft_endpoints` in the integration tests.
Migrate the column to `TIMESTAMPTZ`, interpreting existing values as
UTC (matching `now()`'s behaviour on a UTC server). No compile-time
sqlx queries reference the column, so the offline cache stays valid.
* fix(frontend): settings drawer auto-opening on /scripts/edit
ScriptBuilder's metadataOpen flag fires when `initialPath == ''` (the
heuristic for "new script, expected on /scripts/add"). The route's
`let initialPath = $state('')` left it empty until applyBaseline ran
later inside loadScript.
Pre-PR, the editor was gated on a route-level `script` $state that
started undefined, so ScriptBuilder didn't mount until loadScript's
synchronous block set both `script` and `initialPath` in the same
tick. With UserDraft.use reading localStorage synchronously, the gate
(`scriptHandle.draft`) is satisfied at mount time and ScriptBuilder
mounts with the still-empty initialPath, popping the drawer open.
Seed initialPath from page.params.path synchronously so ScriptBuilder
sees the path on its first render. Falls back to '' for the historical
`?hash=` view to preserve the existing behaviour there.
* fix(backend): refresh draft.created_at on every upsert
The draft upsert was `ON CONFLICT (...) DO UPDATE SET value = EXCLUDED.value`,
so subsequent draft writes left `created_at` frozen at the first INSERT.
The frontend's UserDraft staleness check reads that timestamp as
`remoteDraftRev`; with it frozen, an updated remote draft looked
identical to the originally-baselined one and the "newer draft was
saved on the server" modal never fired.
Touch `created_at` on conflict too. The column's semantic widens from
"first write time" to "last write time", which is what every reader of
the field actually wants — the staleness signal is the only consumer.
SQLx offline cache regenerated to match the new query text.
* fix(frontend): persist trigger drafts in script-editor autosave
The triggers in ScriptBuilder live in a dedicated `triggersState`
$state, separate from the `script` object that the UserDraft handle
deep-tracks. Pre-PR the per-builder localStorage autosave bridged the
two by snapshotting `triggersState.getDraftTriggersSnapshot()` into
the payload on every write — that bridge was dropped when we removed
the per-builder autosave in favour of UserDraft.
Add an $effect that deep-reads triggersState and mirrors the snapshot
back into `script.draft_triggers`. The UserDraft handle (already
deep-tracking `script`) then persists the trigger drafts as part of
the script autosave, restoring the prior behaviour.
* feat(frontend): debounce option on useLocalStorageValue + 500 ms in UserDraft.use
Adds `debounce: number` to `useLocalStorageValue`'s options. When set,
repeated mutations within the window collapse into a single
localStorage write fired by a plain `setTimeout`. The in-memory
`$state` is updated on every change so readers of `.val` always see
the latest value; only the persistence side-effect is deferred.
No `onDestroy` flush — the timer is independent of the Svelte
lifecycle, so SPA route teardown doesn't drop the pending write
(the callback still fires later as long as the JS context is alive).
A hard browser tab close within the window does drop it; that's an
acceptable trade-off vs the complexity of `beforeunload` listeners
and the leak/refcount issues they create alongside `useLocalStorageValue`'s
keyed instances.
`UserDraft.use` opts in with `debounce: 500` so a typing storm in the
script/flow/app editor produces one localStorage write per 500 ms
instead of one per keystroke.
Tests switch to `vi.useFakeTimers()` and a `flushPersist()` helper to
keep the synchronous `expect(localStorage…)` assertions working. New
test verifies the coalescing behaviour end-to-end.
* fix(frontend): tighten legacy-migration key matching
The legacy migration was consuming any localStorage key starting with
`app-`, `flow-`, or `rawapp-`, with no constraint on what followed and
no shape check on the decoded payload. Two failure modes called out
in review:
1. A future feature (or third-party extension) picking a name like
`app-recent` would silently lose data on first migration run.
2. A stray key that happened to base64-decode to valid JSON but
wasn't a real legacy draft would still get promoted to the new
format, surfacing later as a phantom "Restored from local storage"
toast on the next edit.
Two guards:
- `LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/`: after a `<prefix>-` match,
the remainder must look like a Windmill item path (`u/owner/name`
or `f/folder/name`, possibly with deeper segments). Bare-prefix
empty-path entries (`app` / `flow` / `rawapp` for `/add` autosaves)
still match the exact branch and don't go through the shape gate.
- `isPlausibleLegacyValue`: after decode, require the payload to
carry the field the legacy writers actually produced
(`flow.flow` for flows, any of `summary|value|policy|path` for
apps, any of `files|runnables|data` for raw apps).
Both are belt-and-suspenders: nothing else currently uses these key
prefixes, but enforcing the shape locally keeps the migration safe
against future namespace collisions.
* fix(backend): drop AT TIME ZONE 'UTC' from draft.created_at migration
The original migration forced `USING created_at AT TIME ZONE 'UTC'`,
which tags every existing wall-clock value as UTC. That matches the
common case (Postgres on a UTC server, which the Docker image and most
managed offerings default to), but on a non-UTC operator's deployment
it shifts all pre-migration timestamps by the server's tz offset.
Drop the USING clause. Postgres's default `TIMESTAMP -> TIMESTAMPTZ`
cast reinterprets each existing value in the session's current
timezone — which is the same timezone under which the original
`INSERT ... DEFAULT now()` values were truncated to TIMESTAMP, so
the conversion correctly recovers the original instant regardless of
the operator's timezone. Same semantics on UTC servers, correct
semantics on non-UTC servers.
Down migration updated symmetrically.
* docs(frontend): clarify staleness modal copy
The four route-level editors (scripts/flows/apps/apps_raw) keep the
user's local draft visible behind the modal so they can glance at it
before choosing. The old body text described the situation (server
has moved on, local autosave is behind) but didn't say what's
actually on screen or how each action maps to it.
New body leads with "The editor is showing your local autosave" and
spells out each action: "Load latest replaces what's on screen; Keep
current leaves it alone." Same copy for both `cause = 'draft'` and
`cause = 'version'`, branching only on what the user is "behind"
relative to.
* refactor(frontend): drop dead updateDraftCallback from Triggers constructor
None of the eight `new Triggers(...)` call sites pass an update
callback any more — the bridge was a leftover from the pre-UserDraft
era when ScriptBuilder ran its own localStorage autosave and had to
be notified on every triggers mutation. The unified UserDraft handle
now deep-tracks `script.draft_triggers` via the $effect in
ScriptBuilder, so the callback channel is dead weight.
Removes the third constructor parameter, the private field, and the
six `this.#updateDraftCallback?.()` invocations across setters and
mutators.
* docs: review nits — variable.edited_at backfill, UserDraft toast/modal headers
Three low-priority callouts:
- Document the variable.edited_at backfill in the migration. All
existing rows get a single `now()` timestamp from the column
DEFAULT; the staleness check only consumes the field as an opaque
rev string and never displays/sorts on it, so the collision is
harmless — but worth saying out loud.
- Add module headers to userDraftToast.ts and LocalDraftStaleModal.svelte
explaining how this layer sits above the per-browser UserDraft
autosave and is distinct from the backend DraftService (the
server-side "Save as draft" feature surfaced as `*.draft`).
* refactor(frontend): replace UserDraft.release() with useMany()
Public surface change:
- New `UserDraft.useMany(getSpecs: () => UserDraftSpec<V>[])` returns a
reactive array of handles. The reconcile loop acquires entries for
added specs, releases entries for removed specs, and re-uses cached
handles for unchanged keys so caller-captured references stay stable.
- `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around
`useMany`. The spec getter is `untrack`ed so reactive opts
(`$workspaceStore` etc.) are still captured-once — current `use()`
semantics unchanged.
- `UserDraftHandle.release()` and the `manualRelease` option are gone.
Component teardown is handled by a single internal `onDestroy` that
releases every entry `useMany` acquired.
ResourceEditor + VariableEditor migrated:
- Replaced `Record<ws, Handle>` + manual `ensureHandle`/`release` with
a `workspaceSpecs: $state<Array<{ws, defaultValue}>>` plus a
derived `Record<ws, Handle>` that pairs each ws with its parallel
handle from `useMany`. `ensureHandle(ws)` is now just a push to
the specs array; `VariableEditor.reset()` clears it. The reconcile
loop handles acquisition/release end-to-end.
Tests:
- Dropped the `manualRelease`/`release` test; the option no longer
exists.
- Added a `useMany` test asserting per-spec entries, isolated
workspace-scoped localStorage keys, and a single onDestroy
registration covering every acquired entry.
Implementation note: I tried wrapping `useLocalStorageValue` in
`$effect.root` to give the entry's `$state`/`$effect` an independent
scope (in case `useMany`'s reconcile effect tore down nested effects
across cycles). But `$effect.root`'s callback wasn't running
synchronously in the test runtime (vitest + svelte-vite plugin), and
the original `use()` implementation called `useLocalStorageValue`
directly without issue. Reverted to the direct call; the
nested-scope concern stays theoretical.
* fix(frontend): isolate UserDraft entries via $effect.root
The previous commit landed `useMany` calling `useLocalStorageValue`
directly. That works for the `use()` 1-spec wrapper (whose getter is
untracked, so the reconcile `$effect` never re-runs), but for dynamic
specs (ResourceEditor / VariableEditor) it leaks the persist `$effect`
into the reconcile `$effect`'s scope — meaning the second spec change
would destroy the first entry's deep-mutation persist loop.
Wrap the `useLocalStorageValue` creation in `$effect.root` so the
entry's reactivity lives in its own scope. Stash the returned
disposer on the entry and invoke it when the refcount hits 0.
The vitest runtime's `$effect.root` returns its disposer but never
runs the callback (a test-env quirk, not a production behaviour).
Kept a documented fallback that calls `useLocalStorageValue` directly
when the callback doesn't populate `stateRef`. In tests that path
parents the persist `$effect` to the test scope and lives long
enough; in production `$effect.root` runs the callback synchronously
per the Svelte 5 spec and the fallback is unreachable.
* chore(frontend): drop leftover console.log in setDraftConfig
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(frontend): wire ?nodraft=true to actually skip the local autosave on /edit
The flows/apps/apps_raw `/edit` routes had a `?nodraft=true` handler
that just stripped the param from the URL via `afterNavigate` —
nothing behind it. The original pre-PR semantics (and what every
caller assumes) was "skip the localStorage autosave on this load."
Mirror the synchronous wipe pattern already in /add: when nodraft is
present, call `UserDraft.remove(kind, path)` and strip the flag from
the URL via `window.history.replaceState`, before the UserDraft handle
is created. The handle then reads an empty entry and the editor opens
on the backend version. A plain reload (no nodraft) restores the
autosave normally.
Removed the redundant `afterNavigate` blocks. Dropped the now-unused
`afterNavigate` import in all three; apps/edit still imports
`replaceState` (used downstream), so only that name stayed.
* feat(frontend): GC UserDraft entries older than 30 days
Without a sweep, a heavy user accumulates one localStorage entry per
(workspace, kind, path) they ever touched. The pre-PR single-key
autosave self-capped at one entry per editor; this one needs an
explicit GC pass.
Mechanism:
- Stamp every persist with `lastWrittenAt: Date.now()`. Added at four
sites: `useLocalStorageValue`'s new `transformBeforePersist`
option (covers both setter and deep-mutation persists),
`UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta
writes), and the legacy migration. Done at persist time, not in
`wrap()`, so deep mutations bump the clock too — `wrap()` runs only
on `.draft =` assignments, which would leave the timestamp stale for
bind-mutated editor sessions.
- `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key,
removes the ones older than the cutoff. Entries written before this
field existed (pre-PR or pre-this-commit) get backfilled with the
current time on first sweep so a 30-day clock starts fresh; the
alternative — sweeping on sight — would wipe work that the legacy
migration just rescued.
- Wired into the logged-in layout: runs once on mount and every 30 min
via `setInterval` (cleaned up in the effect's return).
Tests use `vi.setSystemTime` to drive the clock; assertions on the
stored payload now go through a `storedShape` helper that strips
`lastWrittenAt` before string-comparing, so the existing
`expect(...).toBe(wrapped(...))` style still reads cleanly. New tests
cover the sweep, the backfill behaviour, the default 30d window, and
a custom `maxAgeMs`.
* fix(frontend): break useMany reconcile feedback loop
The reconcile effect read `handles.length` / `handles[i]` for the
"unchanged?" early-exit optimisation and then `handles.splice(...)`
to publish the new array. Reading `handles` inside the effect
registered it as a dependency; the subsequent splice re-fired the
effect; ad infinitum (Svelte threw
`effect_update_depth_exceeded`).
Wrap the comparison reads in `untrack` so the effect's only
tracked dependency stays `getSpecs()`. The splice still fires the
downstream readers of `handles` (the whole point of `useMany`'s
reactivity); it just doesn't re-enter its own producer.
* fix(frontend): untrack the splice's own .length read in useMany reconcile
The previous fix wrapped only the comparison reads in `untrack`, but
`handles.splice(0, handles.length, ...next)` still reads `.length`
under the effect's tracking scope — same feedback loop, same
`effect_update_depth_exceeded`.
Move the whole "compare + splice" block inside `untrack`. The
downstream notification on splice still fires (untrack suppresses
dependency subscriptions on the producer side, not write
notifications), so consumers of `handles` still re-render.
* nit
* fix(frontend): drop in-memory handle before reloading after DB-draft discard
When the "Script/flow loaded from latest saved draft" toast's
"Reset to deployed" action ran, it:
1. Deleted the DB draft via DraftService.deleteDraft.
2. Called UserDraft.remove (clears localStorage only).
3. Called goto + loadScript / loadFlow.
But the handle's in-memory state still held the now-deleted DB draft
and its meta (remoteDraftRev pointing at the gone draft's created_at).
On the reload, the editor's loadScript/loadFlow saw `localDraft !=
undefined` and ran the staleness check, which compared
`meta.remoteDraftRev = <old timestamp>` against
`currentDraftRev = undefined`. Verdict: "version" stale → spurious
"A newer version was deployed on the server" modal, even though
nothing on the server actually moved. The editor visibly froze
behind the modal because the in-memory state wasn't refreshed.
Drop the in-memory state with `handle.setDraftAndMeta(undefined, {})`
before the reload — same fix already applied to the
"Restored from local storage > Reset to deployed" toast action.
apps/edit and apps_raw/edit's "discard draft" actions don't call
DraftService.deleteDraft (they just swap the in-memory view to the
deployed branch), so they don't hit this codepath.
* fix(frontend): drop in-memory handle in DiffDrawer restoreDraft/restoreDeployed
Same UserDraft.remove-without-clearing-in-memory bug as the previous
two commits, this time in the DiffDrawer's "Restore to draft" /
"Restore to deployed" buttons on all four /edit routes. The handler
deletes the DB draft (in the deployed case), wipes the localStorage
entry, navigates, and reloads — but the route's UserDraft handle
still holds the old draft + meta in memory, so the reload's
staleness check compares the stale meta against the freshly fetched
backend and surfaces a spurious "newer version was deployed" modal.
- scripts/edit, flows/edit, apps_raw/edit: route-level handle —
`handle.setDraftAndMeta(undefined, {})` before the reload.
- apps/edit: the handle lives in the AppEditor child, so force a
remount by setting `app = undefined; redraw++` before goto/loadApp
(matches the existing pattern from the toast's onResetToDeployed).
* fix(frontend): legacy app migration matches actual stored shape
Legacy AppEditor wrote `encodeState($appStore)` — the inner App value
(grid/fullscreen/theme/unusedInlineScripts/hiddenInlineScripts), not the
wrapping AppWithLastVersion. The plausibility check was matching the
wrapping fields, so real legacy app entries were filtered out and never
migrated to the new userdraft/w/{ws}/app/{path} keys.
* fix(frontend): untrack meta-preservation reads in UserDraft setters
`set draft`, `setMeta`, `UserDraft.save`, and `UserDraft.saveMeta` all
read `state.val` before writing it (to preserve existing rev metadata).
When called from inside a `$effect` — as AppEditor does to mirror its
reactive `$state` into the handle — the read subscribes the effect to
the entry's `$state` cell that the write then mutates, producing an
`effect_update_depth_exceeded` loop. Wrap the reads in `untrack` so
mirrors don't self-trigger.
* fix(frontend): apps detect drift + restore on /apps/add reload
Two related issues in the app editor's UserDraft wiring:
1. Drift wasn't detected on first deploy/draft after starting an
autosave. The route only backfilled meta on a reload that found a
local diff — so the first external change after editing slipped
through with empty `previousMeta`. AppEditor now receives the
load-time revs as `initialRevs` and seeds them into the handle's
meta on the first mirror, capturing the rev at autosave-creation
time.
2. /apps/add didn't restore from LS on plain reload. The route
always initialised `value` to `emptyApp()` and the AppEditor's
`stateApp` captured the prop unconditionally, so the LS autosave
was shadowed. `stateApp` now falls back to `appDraftHandle.draft`
when present; the template/hub/import branches explicitly
`UserDraft.remove('app', '')` to keep "start fresh from this
content" semantics.
Also work around `useLocalStorageValue`'s `saveInitialValue: false`
skip slot — in the mirror pattern the slot survived past mount and
swallowed the user's first edit. Consume it up-front with a
wipe-then-restore pair so subsequent edits persist normally.
* feat(frontend): restored-from-local toast in resource/variable editors
Resource and variable editors silently loaded LS autosaves over the
backend value, leaving users with no signal that the form wasn't
reflecting deployed state. Both now fire the standard
`notifyRestoredFromLocal` toast (with a "Reset to deployed" action
that re-seeds the handle from the just-fetched backend) the first
time a lazy-fetch finds the local draft diverging from the remote.
* fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist
The "Reset to deployed" toast action in resource/variable editors
called UserDraft.save with the backend value to repaint the form. That
left a duplicate-of-backend autosave in localStorage which would
silently restore on every subsequent reload, defeating the reset.
New UserDraft.discard(itemKind, path, fallback) clears LS AND resets
any live handle's in-memory state to the fallback, skipping the next
persist so the fallback doesn't round-trip back into storage. Backed
by a new `skipNextWriteOnce()` method on useLocalStorageValue's return.
* fix(frontend): use UserDraft.discard in apps reset flows
The apps editor route doesn't hold the UserDraft handle — AppEditor
(the child remounted by {#key redraw}) does. When a reset action ran
`UserDraft.remove` + `redraw++`, Svelte could mount the new AppEditor
before the old one's onDestroy released its handle, leaving the
entry's in-memory state.val populated with the stale autosave. The
new AppEditor would then re-acquire that entry and shadow the
just-emptied localStorage.
Switch every reset path (stale modal Load latest, restored-from-local
toast, DiffDrawer restoreDraft/restoreDeployed) to `UserDraft.discard`
so the in-memory cell is cleared synchronously alongside LS. Also
plumb `currentRevs` updates so the next mount's initialRevs reflects
the acked state.
* fix(frontend): /flows/add restores autosave on plain reload
`loadFlow()` initialised the local `flow` variable to `emptyFlow()`,
then passed it to `initFlow` which writes it to `flowStore.val` (=
`flowHandle.draft = flow`). On a bare /flows/add reload (no
template/hub/import/fork/urlHash) the assignment overwrote the
persisted autosave with the empty baseline. Seed `flow` from
`flowHandle.draft` instead, and keep `emptyFlow()` as the explicit
"start fresh" baseline for template/hub branches.
* nit rename
* fix(frontend): snapshot UserDraft proxy before structuredClone in resource save
`states[ws].draft` is now a Svelte $state proxy (it flows through
UserDraft's useLocalStorageValue cell). `structuredClone` can't clone a
proxy and threw "Failed to execute 'structuredClone' on 'Window'",
blocking resource saves. Snapshot to a plain object via
`$state.snapshot` before assigning the dirty baseline.
* fix(frontend): raw app deploy toast crash + harden Toast against bad type
RawAppEditorHeader's catch blocks called `sendUserToast(msg, e)`,
passing an Error as the `_type` arg. `classes[<Error>]` is undefined so
`color.descriptionClass` threw — and because the toast renders in the
root layout, it crashed the whole page on raw app deploy/create. Fixed
both call sites to the proper `(msg, true)` error form.
Also hardened Toast.svelte: coerce any non-AlertType `type` to 'error'
so a future miscall degrades to a plain error toast instead of taking
down the page.
* fix(frontend): /apps_raw/add restores autosave on plain reload
The route initialised files/runnables/data/summary to hardcoded
defaults, and the $effect mirror then wrote those defaults over the
persisted empty-path autosave. Seed the $state from
`draftHandle.draft` instead; import/template/hub branches
`UserDraft.remove('raw_app', '')` for explicit "start fresh"
semantics. Also consume useLocalStorageValue's saveInitialValue=false
skip slot (wipe-then-restore) so the user's first edit isn't dropped.
* feat(frontend): staleness modal in resource/variable editors
Resource/variable editors only showed the restored-from-local toast;
they never surfaced the staleness modal when the backend item moved on
since the local autosave was written. Wire LocalDraftStaleModal +
checkStaleness using the backend `edited_at` as `remoteRev` (these
items have no DB-draft concept). Meta is backfilled on reload for
legacy autosaves and seeded on the first real edit via a guarded
effect, so an external edit is detectable as drift. Per-workspace
detection; the modal is a singleton driven by `pendingStale`.
* feat(frontend): restored-from-local toast in standalone trigger editors
The schedule/postgres/http/kafka/websocket/email/sqs/nats/gcp/azure/
mqtt editors silently overlaid the local UserDraft autosave on top of
the backend config in `openEdit`, with no signal that the form wasn't
showing deployed state. Each now snapshots the just-loaded backend
config, then fires `notifyRestoredFromLocal` with a "Reset to
deployed" action that drops the LS entry and re-applies the snapshot.
* fix(frontend): trigger autosave no longer false-restores on plain open
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(frontend): live UserDraft handle for trigger editors
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(frontend): live UserDraft sync for raw app editors
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(frontend): extract useTriggerDraftSync composable
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(frontend): trim rot-prone comments in UserDraft
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* in /script, put code state in URL
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
* fix: scope SimpleEditor webview paste to focused editor instance
* fix: scope webview clipboard paste to focused editor instance
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: bail on missing selection instead of pasting at document start
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: hide SimpleEditor paste sink input from a11y tree and tab order
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: resolve absolute-path imports in monaco ts editor
* fix: dispose absolute-path extra libs on editor teardown and reset
* fix: skip late ata local-file callbacks after editor teardown
* export_datatable_schema
* Propose to fork the datatable on ws fork
* dump datatable
* Dockerfile
* Fix import_datatable_dump
* datatable schema fork works!
* Option to copy both schema and data
* Datatable fork behavior
* nit ui
* use psql instead
* remove fork_datatable route
* feat: add fork_pg_database and export_pg_schema routes with DB Manager UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: pluralize "schema" to "schemas" in DB Manager export/import UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add import mode select (schema only vs schema + data) to DB Manager import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Select schema or schema+data when important database
* fix: prepend $res: prefix to resource paths in DB Manager import/export
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: dynamic import button label based on selected mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* nits
* feat: add warning alert when schema+data import mode is selected
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* nit hide on cloud hosted
* refactor: remove fork_behavior from datatable settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: split CreateWorkspace into layout wrapper and CreateWorkspaceInner
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: instantiate CreateWorkspaceInner in globalForkModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* nit icons
* Data table fork UI
* feat: pass per-datatable fork behaviors from UI to backend during workspace fork
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix fork overwriting all datatables
* UI nits
* custom instance db refactor
* custom instance db wizard btn for all in dropdown
* nit
* Delete custom instance database button
* Disable forking for resource datatables
* Big import buttons when db empty
* Revert "Disable forking for resource datatables"
This reverts commit 9561cc8fd4.
* feat: add non_diffable flag to resource table
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add resource-type datatable fork with CREATE DATABASE
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: tag forked datatables with nonDiffable and forkedFrom
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: diff datatable and ducklake settings individually on workspace merge
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: skip non_diffable resources and datatables in workspace diff
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: default datatable fork behavior to keep_original
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: make grant permissions non-fatal in instance datatable fork
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: make datatable and ducklake diffs visible in workspace comparison
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: remove datatable fork logic from workspace fork route
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: correct ahead/behind logic for datatable and ducklake diffs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert "fix: correct ahead/behind logic for datatable and ducklake diffs"
This reverts commit 6b50884dc6.
* revert: remove datatable and ducklake settings diffing logic
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add datatable clone UI with step-by-step confirmation modal
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: extract datatable fork UI into ForkDatatableSection component
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit
* fix: run datatable cloning before workspace fork creation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit disable fork admins
* nit fix switching workspace prematurely
* fix: use source workspace for forkPgDatabase calls during fork
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: update forked workspace datatable settings after fork creation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add forked_from field to DataTable and set it for instance forks
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit onFinish
* fix: add forked_from to DataTableSettings OpenAPI schema
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: track datatable table DDL changes in workspace_diff
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert "feat: track datatable table DDL changes in workspace_diff"
This reverts commit 7526dd68b9.
* feat: add get_datatable_full_schema endpoint and snapshot schema on fork
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix duplicate migration key
* fix: set forked_from on datatable config for both instance and resource types
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nits
* feat: drop forked databases on workspace deletion with confirmation UI
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: extract drop_forked_datatable_databases from delete_workspace
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: cast pg char columns to text in FK schema query
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: show dbname instead of resource type in fork deletion modal
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* ui nit
* refactor: extract drop_custom_instance_database into windmill-common
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add datatable schema diff section to merge UI
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* UI
* feat: add review drawer with YAML diff and SQL migration runner
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: use Monaco DiffEditor for YAML diff in review drawer
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit
* Revert "refactor: use Monaco DiffEditor for YAML diff in review drawer"
This reverts commit a86008ba4c.
* Revert "feat: add review drawer with YAML diff and SQL migration runner"
This reverts commit 0a0deb5ddb.
* feat: add review drawer with DiffEditor and SQL migration runner
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* ui nits
* fix: show diff between forked_from schema and changed side
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: re-fetch target live schema after migration for correct baseline
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* revert
* nit auto next
* feat: add confirmation modal before deploying migration to parent
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: handle missing columns/foreignKeys in schema conversion
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nits
* refactor: use temp file on disk for pg_dump instead of in-memory string
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Don't replace postgres dbname
* fix: add validation to drop_custom_instance_database and use source db for CREATE/DROP
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: type DataTable.forked_from as DataTableForkedFrom struct
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: simplify fork_pg_database to take source + target_dbname
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* dead code
* feat: enforce schema_and_data admin-only and extract create_custom_instance_database
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: rename fork_pg_database to import_pg_database with source/target/override params
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit
* refactor: remove original_dbname/original_resource from forked_from, resolve from parent
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit
* fix: resolve forked dbname from fork workspace when dropping resource databases
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nits
* fix: always clean up global_settings even if database doesn't exist
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: check datatable resource_type from config instead of URL prefix
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: wrap PG default value expressions in braces to prevent CAST quoting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert "fix: wrap PG default value expressions in braces to prevent CAST quoting"
This reverts commit 77f5a2c4e8.
* refactor: reuse columnDefToTableEditorValuesColumn for default value handling
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: store raw API schema in forked_from to avoid double transformation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert "fix: store raw API schema in forked_from to avoid double transformation"
This reverts commit e326197a20.
* Revert "refactor: reuse columnDefToTableEditorValuesColumn for default value handling"
This reverts commit bd8f071d9f.
* fix: validate dbname with strict regex to prevent SQL injection
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix default value
* always validate dbname
* refactor: move get_datatable_full_schema structs and logic to query_builders.rs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: split import_pg_database into create_pg_database + import_pg_database
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: extract drop_forked_datatable_databases into its own route
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: require admin when using $res: resource paths in import_pg_database
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use UserDB for $res: resource access and restrict dbname creation
- resolve_pg_source_checked uses UserDB (row-level security) for $res: paths
- transform_json_unchecked is now pub(crate) to prevent misuse
- Non-superadmins can only create databases with wm_fork_ prefix
- datatable:// remains accessible to everyone
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: refuse to drop forked databases unless name starts with wm_fork_
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: remove resolve_pg_source, use resolve_pg_source_checked everywhere
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix permissions
* sqlx prepare
* compilation nits
* sqlx prepare
* sqlx prepare
* wrong route syntax
* fix: allow workspace owner to edit datatable config for fork setup
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert "fix: allow workspace owner to edit datatable config for fork setup"
This reverts commit ab683e637b.
* refactor: move datatable fork setup into create_workspace_fork backend
Instead of updating datatable settings from the frontend after fork
creation (which required admin/owner access), pass forked_datatables
info to create_workspace_fork and handle it atomically in the same
transaction. Removes applyPostForkDatatableUpdates from frontend.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: snapshot schema in backend during fork instead of frontend
The schema snapshot is now taken by the backend in apply_forked_datatable
via snapshot_datatable_schema, which connects to the parent workspace's
datatable and runs pg_get_full_schema. This removes the need for the
frontend to call getDatatableFullSchema and pass the schema through.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use get_resource_value_interpolated_internal for $res: to resolve $var: references
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit
* sqlx prepare
* fix: add permission check to drop_forked_datatable_databases, validate dbnames, restrict temp file perms
- drop_forked_datatable_databases: same permission as delete_workspace
(fork owner or super admin)
- validate_dbname on target_dbname_override and ForkedDatatableInfo.new_dbname
- Enforce wm_fork_ prefix on forked datatable new_dbname
- DumpFile: set /tmp/windmill/ to 0700 and create files with 0600
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* nit CLI
* Rename to ws_specific
* sqlx prepare
* nit always validate dbname
* fix: include foreign keys in CREATE TABLE migration for added tables
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: detect nextval defaults and use SERIAL/BIGSERIAL types in CREATE TABLE
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Update frontend/src/lib/components/DBManagerDrawer.svelte
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* Update backend/windmill-common/src/lib.rs
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* Update backend/windmill-common/src/lib.rs
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* fix: sort foreign keys by constraint name for deterministic schema output
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* sqlx prepare
* rename migration to update timestamp
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* feat: detect sql.raw() in TS parser and tag queries with has_raw_interpolation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: filter out sql.raw queries from type-checking and preparation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: implement sql.raw() for inline raw SQL fragments in template literals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: split sqlProviderImpl into provider interface + shared builder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix ts client compilation
* update asset parser
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three root causes:
1. Editor.setCode() never dispatched `change` — it pre-set `code = ncode`
before the Monaco edit, so the debounced updateCode() saw code == ncode
and skipped dispatch. The Reset button, copilot accept, and other
setCode callers never triggered schema inference. Fixed by capturing
`changed` before the pre-set and dispatching directly when true.
2. EditableSchemaForm's $effect only tracked the schema reference, not
its properties. Since inferArgs mutates schema in-place through the
Svelte 5 proxy, the reference never changes and the effect never
re-ran. Added schema?.order and Object.keys(schema?.properties ?? {})
reads to detect in-place mutations (matching SchemaForm's pattern).
3. ScriptEditor's $effect depended on both selectedTab and code, causing
a redundant double inferSchema call on every code change (racing with
the on:change handler and initContent's explicit call). Moved code
into untrack() so the effect only fires on tab switches.
Also removed the no-op `testPanelSchema = testPanelSchema` in
inferModuleSchema.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add ws_base_url instance setting to override WebSocket base URL
Allow deployments behind reverse proxies to route WebSocket traffic
(LSP, debugger, multiplayer) to a different host/port than the main
frontend via a new instance setting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: move ws_base_url to Advanced section with toggle and connectivity test
- Move setting from Core to Advanced > WebSocket section
- Render as toggle "Custom websocket base url from frontend to
multiplayer/lsp/debugger" with conditional URL text field
- Add Test connectivity button (always visible) that checks HTTP health
and WebSocket ping for all three services (LSP, Multiplayer, Debugger)
- Add /ws/ping and /ws/health endpoints to LSP service
- Add /ws_mp/health HTTP and __ping__ WS handlers to multiplayer service
- Add /ping WS handler to debugger service
- Add CORS headers to health endpoints for cross-origin testing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: toggle enabled check and testWs promise resolution
- Fix enabled derived to check only for null (not empty string),
otherwise the toggle never turns on since toggleEnabled sets ''
- Fix testWs onclose handler to resolve(false) so the promise
doesn't hang if the server closes without sending a message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: make connectivity test work with existing services
- HTTP test: accept plain text "ok"/"okay" (old services) in addition
to JSON {"status": "ok"} (new services), reject HTML (SPA fallback)
- WS test: resolve on onopen (connection established) instead of
waiting for a specific pong message, so the test works even with
services that don't have the new /ping handler yet
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Remove $$props.field usage
* Rename slots to ensure no hyphen
* _props
* _trigger
* OnSelectedIteration type correct capitalization
* rename _content
* Remove afterUpdate
* Migrate everything to svelte 5
* array bind
* Fix popover
* type never
* nit fixes
* Fixed many trivial errors
* onClick
* Fix errors
* use let:
* nit typing
* fix: wrap state_referenced_locally vars with untrack()
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add untrack import
* Fix all syntax errors due to untrack migration
* Fix undefined errors
* Fix more undefined errors
* untrack(() => initialOpen)
* svelte-ignore
* Fix state_descriptors_fixed error in Chart.svelte
Use $state.snapshot() to pass plain copies of data/options to Chart.js
instead of $state proxies. Chart.js's listenArrayEvents tries to define
property descriptors on data arrays, which Svelte 5 proxies reject.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* nit typing
* Merge issue
* Fix "path is not set" error in resource picker / editor
* Fix InputTransformForm error when rerunning some flows
* fix npm run check
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* clean plate
* npm i
* log in e2e
* global setup login
* set license key
* Revert "set license key"
This reverts commit 86d5db2c48.
* create datatable test
* fix wrong pg_creds
* data table + db manager e2e test
* DbManagerPage class
* small refactor
* create resource test + improvements
* text db manager in resources
* Factor test logic in classes
* refactoring
* refacto
* alter table test
* alter table e2e test
* set schema in test
* nits
* fix wrong schema var
* Correct setup and parallelization
* reducedMotion
* tests passing headless !
* bigger timeout
* start e2e docker compose
* e2e runs on all databases
* nit test uid fix
* refactp
* stash
* Better Workspace Storage settings
* minio setup
* nit
* nit
* super nit
* Permission settings in modal
* badge indicator
* Fetch alter table metadata much faster
* Upgrade duckdb to 1.4.3
* Ducklake tests
* Disable transactional DDL for Ducklake (bug on their side)
* git ignore env
* bigquery tests passes
* getJsonEnv
* load coldef in parallel
* Make Bigquery schema fetching much faster
* makeLoadTableMetaDataQuery for entire db in bigquery
* refactor getDbSchemas to avoid assignment side effect
* fix col def
* Better loading state mgmt
* snowflake
* fix snowflake primary keys
* Test CI
* fix setTimeout type
* remove type node
* test e2e ci
* Revert "test e2e ci"
This reverts commit bf98a755dc.
* remove ci
* fix snowflake pk query in alternate schemas
* nit wait for coldefs
* nit snowflake
* Snowflake fk fix
* UNPROCESSABLE_ENTITY instead of INTERNAL_ERROR
* nits
* fix alter pk in snowflake
* yet other fixes
* snowflake tests pass
* nits
* set default font size to 13.5
* fix(frontend): update FakeMonacoPlaceHolder default font size to match Editor
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
* feat(aichat): add get_lint_errors tool for script and flow mode
This adds a new `get_lint_errors` tool to the AI chat for script and flow modes,
similar to what exists for app mode.
For script mode:
- Added `getLintErrors` function to Editor.svelte that returns lint errors from Monaco
- Added `ScriptLintResult` and `ScriptLintError` interfaces
- Added `get_lint_errors` tool definition and implementation
- Updated system prompt to instruct AI to use the tool after code changes
For flow mode:
- Added `FlowLintResult` interface for flow-level lint results
- Added `get_lint_errors` tool that gets lint errors from the currently selected module
- Updated system prompt to include linting in the tool selection guide
The AI is now instructed to always use `get_lint_errors` after making code changes
and fix any errors before proceeding with testing.
Closes#7430🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* fix for script
* fix for flow
* cleaning
* fix DatatableCreationPolicy
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: centdix <farhadg110@gmail.com>
* data tables settings ui
* install runed
* zod 4 fixes
* use new toJSONSchema
* Migrate ducklake catalogs to more generic custom instance databases
* fix compilation
* Safety conversion for old duckdb ffi
* data tables settings
* ts client basis
* inline run works
* datatables work
* Revert "datatables work"
This reverts commit 6e1588d59e.
* datatables work (without leaking pg credentials)
* println
* separate sqlUtils.ts
* nit
* Separate custom instance db Select and Wizard components
* nit
* nit wording
* add tags to custom instance dbs
* error when trying to use ducklake as datatable or opposite
* show status in dropdown
* data table instance setup works
* sqk function for ducklake
* factorize logic
* fix temp reactivity
* Data table assetexplore
* Migrate S3 permissions to modal
* Revert "Migrate S3 permissions to modal"
This reverts commit 0631d03cb0.
* nit query -> fetch
* Custom instance setup new look
* run_language_executor separate fn
* run_inline param
* nit wording
* Better typed client
* Data tables display as assets in frontend
* asset db icon
* nit
* cleaner errors
* nit
* Fix sed calls in mac
* run_inline_script_preview in python client
* basic python datatable client
* datatable and datalake parser in python
* ducklake client python
* nit fix
* Fix migration producing NULL instead of {} when no custom databases
* merge conflict fail
* python ducklake client arg fix
* parse or infer sql types in ts client
* ts asset parser, detect datatable & ducklake R/W
* fix sql repl for other read ops than select
* export type SqlTemplateFunction
* rename list_custom_instance_pg_databases
* typecheck datatable and ducklake name in Typescript
* Fix typecheck datatable and ducklake in TS
* declare module overriding instead of extending
* infer_sql_type in python client
* SqlQuery object in python
* fix merge conflicts
* update const_format
* CI fix
* factor out to var_identifiers
* sqlx prepare
* unnecessary security (admin is required)
* clearer comment
* ee repo ref
* nit snake case
* claude step 1: detect var declarations
* move detect_sql_access_type to common mod
* claude step 2: detect when saved vars are queried
* Revert "claude step 2: detect when saved vars are queried"
This reverts commit 1e1f930568.
* Revert "claude step 1: detect var declarations"
This reverts commit f866f4819d.
* remove ducklake/datatable and default
* detect data table assigns in var_identifiers
* Python parser successfully infers R/W/RW from ducklake / datatable
* still register ducklake/datatable if not used as unknown R/W
* Go to settings button in Assets Dropdown on not found
* nit
* sqlx prepare fail
* manual fix, somehow sqlx prepare won't do it
* fix frontend ci
* ee repo ref
* ducklake_user doesnt exist in unit tests
* nit fix
* ui nit
* nit
* nit missing clone
* fork ducklakes and datatables
* fix surface hover bug
* stupid mistake
* better deeply reactive mutable derived
* Ducklake picker
* Editor bar data tables
* DuckDB supports datatables
* datatable in duckdb asset parser
* duckdb asset parser var_identifiers
* Revert "duckdb asset parser var_identifiers"
This reverts commit 88068b1a77.
* sqlx prepare
* Box pin in test_workflow_as_code to fix stack overflow
* stash
* sql asset parser parses most s3 literals
* nit
* Detect attach + handle returning RW
* detect assets used with dot notation
* detect implicit access with USE dl; syntax
* Add assets as unknown if var was never used
* Support default ducklake/datatable main in parser
* ignore asset parsing errors in frontend (avoid flow layout shift)
* super weird duplication (merge conflict ?)
* nits
* fix duckdb parser detecting too much as asset when RW ctx is unknown
* fix transparent assets btn
* missing arg
* nit styling
* asset parser specific table parsing
* fix resource specific table parsing
* More concise asset display in flows + better icons
* fix assets page filtering out resources with added table
* Fix frontend to support specific table assets
* Open DB Manager to specific table
* Specific table parser in Python and TS + unit tests
* Fix UPDATE setting access to None
* fix flow edge rendering on top of output picker
* python parser fix var override bug
* add ts test
* fix compilation
* sqlx prepare
* update parsers version
* fix missing schema key onDelete
* Grant permission to create schemas in custom instance databases
* Update pg query to return empty schemas
* Create schema
* Select nits
* support schemas in sql parser
* ts parser handle schema with sql parser result
* detect .schema() syntax
* detect schema syntax in python
* support .schema() in ts and py SDK
* open db manager to specific schema
* support reassignment in ts parser
* nit better unitest
* : syntax in ts
* datatable:schema syntax in python
* fix client py
* nit select dropdown darkmode
* object | null fetchOne
* ts client nits
* parse_sql_client_name fn
* getImportWmillTsStatement refactor in EditorBar
* text to json() in python client
* update parser versions
* pkg lock
* Sql query details in TS asset parser
* code transformation with type parameter in Editor
* Custom Language Worker, code substition works !
* Error marker mapping works
* hover info is correct
* completions work correctly
* other overrides
* type inference kinda works
* Position mapping tests
* refactor prepare_queries
* Refactor PgDatabase to share common code
* Pgdatabase in prepare_queries
* TokioPgConnection refactor
* refactor prepare_queries
* type parameter to sql function
* Fix deadlock
* nit fix
* Fix worker async call freezing because of svelte Proxy
* Force worker to recompute when we set queries
* nit refactor
* nits console logs
* wait that ts worker initialize
* monaco change file version
* update diagnostics
* Refactor for errors
* Show SQL errors in Monaco
* improve sdk
* cleaning refactor + MapResource + usePreparedAssetSqlQueries
* Fixes
* Fix error position mapping
* cache in typescript worker
* fix insert no values
* don't inject type if already present
* Support schema in prepare queries
* update parsers
* ChangeOnDeepInequality
* inferAsset ScriptEditor usage refactor
* sql query typecheck work in flow editor
* Assets and SQL Query check in Raw App Inline Editor
* pkg lock
* Fix DatatableSqlTemplateFunction nit
* prepare query schema nit
* duplicate diagnostics
* nit getScriptVersion mock
* Reprepare queries when switching workspaces
* nit fix
* nit fix
* fetch_one_scalar and execute in python client
* limit pg_connections
* -- prepare flag in postgres
* skip serializing
* fix destructuring undefined
* Prepare queries in workers instead of backend
* nit
* Execute search_path instructions normally
* nit fix
* Fix SET search_path issue in prepare
* only support preparing single-statement queries for now
* update parsers
* safety
* better remove_comments
* Fix getQueryStmtCountHeuristic
* getQueryStmtCountHeuristic tests
* comment out failing tests
* Fix getQueryStmtCountHeuristic impl
* only datatable
* data tables settings ui
* install runed
* zod 4 fixes
* use new toJSONSchema
* Migrate ducklake catalogs to more generic custom instance databases
* fix compilation
* Safety conversion for old duckdb ffi
* data tables settings
* ts client basis
* inline run works
* datatables work
* Revert "datatables work"
This reverts commit 6e1588d59e.
* datatables work (without leaking pg credentials)
* println
* separate sqlUtils.ts
* nit
* Separate custom instance db Select and Wizard components
* nit
* nit wording
* add tags to custom instance dbs
* error when trying to use ducklake as datatable or opposite
* show status in dropdown
* data table instance setup works
* sqk function for ducklake
* factorize logic
* fix temp reactivity
* Data table assetexplore
* Migrate S3 permissions to modal
* Revert "Migrate S3 permissions to modal"
This reverts commit 0631d03cb0.
* nit query -> fetch
* Custom instance setup new look
* run_language_executor separate fn
* run_inline param
* nit wording
* Better typed client
* Data tables display as assets in frontend
* asset db icon
* nit
* cleaner errors
* nit
* Fix sed calls in mac
* run_inline_script_preview in python client
* basic python datatable client
* datatable and datalake parser in python
* ducklake client python
* nit fix
* Fix migration producing NULL instead of {} when no custom databases
* merge conflict fail
* python ducklake client arg fix
* parse or infer sql types in ts client
* ts asset parser, detect datatable & ducklake R/W
* fix sql repl for other read ops than select
* export type SqlTemplateFunction
* rename list_custom_instance_pg_databases
* typecheck datatable and ducklake name in Typescript
* Fix typecheck datatable and ducklake in TS
* declare module overriding instead of extending
* infer_sql_type in python client
* SqlQuery object in python
* fix merge conflicts
* update const_format
* CI fix
* factor out to var_identifiers
* sqlx prepare
* unnecessary security (admin is required)
* clearer comment
* ee repo ref
* nit snake case
* claude step 1: detect var declarations
* move detect_sql_access_type to common mod
* claude step 2: detect when saved vars are queried
* Revert "claude step 2: detect when saved vars are queried"
This reverts commit 1e1f930568.
* Revert "claude step 1: detect var declarations"
This reverts commit f866f4819d.
* remove ducklake/datatable and default
* detect data table assigns in var_identifiers
* Python parser successfully infers R/W/RW from ducklake / datatable
* still register ducklake/datatable if not used as unknown R/W
* Go to settings button in Assets Dropdown on not found
* nit
* sqlx prepare fail
* manual fix, somehow sqlx prepare won't do it
* fix frontend ci
* ee repo ref
* ducklake_user doesnt exist in unit tests
* nit fix
* ui nit
* nit
* nit missing clone
* fork ducklakes and datatables
* fix surface hover bug
* stupid mistake
* better deeply reactive mutable derived
* Ducklake picker
* Editor bar data tables
* DuckDB supports datatables
* datatable in duckdb asset parser
* duckdb asset parser var_identifiers
* Revert "duckdb asset parser var_identifiers"
This reverts commit 88068b1a77.
* sqlx prepare
* Box pin in test_workflow_as_code to fix stack overflow
* go to settings button
* ee repo ref
* fix compilation
* wording nit