Commit Graph
378 Commits
Author SHA1 Message Date
GuilhemandClaude Opus 4.8 84cc043406 feat: link files & folders to the global AI chat (#9520)
* feat: add file attachments to the global AI chat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: add folder linking and file-type icons to chat attachments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: persist linked files, add @-menu file tree, and polish chat file UI

Persistence (survive reload, scoped to session.id):
- IndexedDB store (attachedFilesDB) holding Blob snapshots (every browser)
  and re-grantable File System Access directory handles (capable browsers)
- restore on session activation; re-grant locked handles on the next send;
  flush in-memory items when the session persists; GC on session delete
- capability via feature-detection (fsAccess), never UA sniffing
- folders auto-refresh (live re-enumerate + reconcile) on each send

@-mention file picker:
- Files branch in ChatContextPicker (new DrillPicker architecture); a linked
  folder's files render as a nested directory tree, picking inserts @filename
- attached-file mentions highlight in the input just like context mentions

UI polish:
- file/folder chips reuse the context-element chip style (icon -> X on hover)
- file + context badges sit above the fork/draft bar
- disabled dropdown items can surface an explanatory tooltip (DropdownV2Inner)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: deepen the attached-files store — folders as first-class objects

Two seam fixes from an architecture pass, no behaviour change:

- addFolder(dirHandle) now enumerates internally (same junk-filtered walk
  used on restore/refresh), so callers never pre-enumerate. The dead
  drop-walkers (collectDroppedEntries, filterFolderPickerFiles) are deleted;
  isIgnoredPath/MAX_FOLDER_FILES move next to enumerateDir in fsAccess.

- The store exposes `folders` (name + aggregate status + children) and
  `standalone` as derived views, so the bar, the @-menu picker, the folder
  chip and the system-prompt roster stop re-grouping the flat row list and
  re-deriving folder status. Placeholder rows (isFolderRoot) become an
  implementation detail; the roster renders a locked folder as one line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: drop the redundant context-badge row in the global chat

In GLOBAL mode selected context already appears as a highlighted @mention
in the input (deleting the mention deselects), so the hoisted badge row
above the chat duplicated it. File chips keep their row — attachments
aren't represented in the input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: harden attachment edge cases found in review

- requestReadPermission/queryReadPermission never reject (the spec rejects
  with SecurityError when user activation is missing — now mapped to
  denied/prompt), and sendRequest wraps attachment upkeep in try/catch, so
  a permission hiccup can never silently swallow a Send.
- regrantLocked expands before dropping the locked placeholder: when the
  re-granted directory is gone from disk, the folder now shows
  "unavailable" instead of vanishing into a zombie that resurrects locked
  on the next reload.
- addFolder: re-picking a locked/unavailable folder relinks it (natural
  recovery gesture); a genuine second folder with the same basename gets a
  visible "already linked" rejection instead of a silent no-op.
- fileEngine: readFile clamps its byte slice to maxChars*4 before decoding
  and streamLines caps its per-line buffer, so newline-sparse files
  (minified JS, single-line JSONL) can't materialize unbounded strings;
  corrected the scan-cap comment's claim about catastrophic backtracking.

4 new unit tests (41 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: surface folder-picker failures instead of swallowing them

`pickDirectory` caught every `showDirectoryPicker` rejection and returned
undefined, so a real failure (an enterprise/browser policy blocking the File
System Access API, a lost user-activation, …) was indistinguishable from a
no-op — the picker just silently never opened. Now only `AbortError` (user
dismissed the dialog, or CDP intercepted it under automation) is treated as a
cancel; anything else is rethrown and `linkFolder` surfaces it as a toast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: support folders in browsers without the File System Access API

Folders can now be added in every browser, not just Chromium. Where the File
System Access API is absent (Firefox/Safari), a dropped or picked folder's files
are snapshotted into the browser (via a webkitGetAsEntry drop-walk or a
`webkitdirectory` input) instead of linked as a live handle, and grouped/displayed
identically to a File System Access folder. The dropdown item reads "Link folder"
when a live link is possible and "Add folder" otherwise, with a tooltip pointing
to Chrome/Edge for a live link.

Snapshot folder children persist their `folder`/`relPath`, so they regroup into
the same folder chip on reload.

Removes the arbitrary file-count caps (500 per folder, 100 total) — only the
browser's memory / IndexedDB quota now bound a folder. Junk paths
(node_modules/.git/dist/dotfiles) are still skipped, folder-contents only, so an
explicitly attached standalone dotfile is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address review feedback — index-race guard + read_file line numbers

Both automated reviewers flagged two issues on the attached-files feature:

- (P1) Stale async indexing could corrupt a newer file. `#indexFile` applied
  its unawaited `buildLineIndex` result by display name, so if a row's file was
  swapped while indexing was in flight (remove + re-add a same-named file, or a
  folder refresh re-indexing an edited file) the stale result stamped the wrong
  lineIndex/lineCount — and `read_file` then sliced the new Blob with old
  offsets. Now patched via `#patchFile`, which applies the result only while the
  row still holds the exact file object that was indexed.

- (P2) `read_file` promised "line-numbered context" but returned raw text. It now
  prefixes each line with its absolute 1-based number (`<n>→<content>`), matching
  the tool contract; `numberLines` lives in fileEngine and is unit-tested.

Adds regression tests: a deterministic stale-index race test (controlled
buildLineIndex ordering) and numberLines coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address re-review nits — read_file pagination + searchFiles regex state

- read_file: when the maxChars cap truncated a window short of its requested
  end line, the pagination note still reported the full range and gave no/wrong
  resume point, so the model couldn't reach the unread lines. The note now
  reports the last line actually returned and resumes at the next unread line
  (advancing past a single over-long line rather than re-truncating it forever).
- searchFiles: reset `regex.lastIndex` before each `.test()` — a caller-supplied
  `g`/`y` flag makes test() stateful and would silently drop matches. Not
  reachable from the current caller, but searchFiles is exported.

Adds regression tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep an emptied live folder linked and refreshing

A live (File System Access) folder carried its directory handle only on its child
file rows. When the folder was emptied on disk, refreshFolders/#reconcileFolder
removed the last child — dropping the only handle-bearing row — so the folder
vanished from the chip bar AND was never re-enumerated again (files added back on
disk weren't picked up until a reload). #expandFolder had the same gap on restore.

Now #ensureFolderRow leaves one handle-carrying placeholder row when a folder has
no readable children (keeps the chip visible and the live source alive), and drops
it once children return; refreshFolders collects sources from placeholder rows too,
and readyFiles never exposes a placeholder to the read/search tools. Adds a
regression test (empty → still visible → file returns → picked up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: trim read_file char-cap output to match its pagination note

When the char cap cut partway into the line after some whole lines, readFile set
the note/endLine to the last complete line but still returned the partial next
line in `text` — so read_file showed (line-numbered) a line the note said would
come on the next read. Trim the returned text back to the last complete newline
so the body and the note agree. Test now asserts res.text for that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: isolate search_files in a Worker (ReDoS) + path-aware folder dedup

- search_files runs a model-supplied regex, and a catastrophic-backtracking
  pattern (e.g. /^(a+)+$/) can't be interrupted mid-test, freezing the tab. Run
  the search in a Web Worker (searchFilesInWorker) and terminate it on a timeout,
  returning "pattern too expensive" instead of hanging. Degrades gracefully to a
  main-thread search where Workers are unavailable / fail to load.
- #isDuplicate keyed its content check on the file basename, so two distinct
  files sharing a basename under different folder subdirs (proj/a/index.ts vs
  proj/b/index.ts) were wrongly deduped and silently dropped from snapshotted
  folders. Key it on the relative path instead.

Adds tests: worker result/timeout-and-terminate, and same-basename-different-subdir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep an initially-empty live folder linked (placeholder + persist)

addFolder only created rows / persisted the dir-handle when at least one text file
was found, so linking a folder that's empty (or all-binary) at pick time was a
silent no-op: no chip, nothing persisted, and refreshFolders had no source to
re-enumerate when files were added later. Now it always leaves a placeholder
(#ensureFolderRow) and persists the handle — matching the became-empty behavior —
so the folder stays visible, survives reload, and picks up files added afterward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep empty-folder placeholders out of the real-file name space

The placeholder row for an empty live folder uses name = folder, which could
collide with a standalone file of the same name: addFiles deduped the file
against the placeholder, removeFile(name) dropped both rows, and #uniqueName
pushed the file to a "(2)" suffix. Placeholders are managed via removeFolder and
never read by the tools, so exclude isFolderRoot rows from #isDuplicate,
removeFile, get(), and #uniqueName. Adds a placeholder/standalone collision test.

(codex's other nit — @-mentions not highlighting filenames with spaces — left as
a known cosmetic limitation per the chosen scope.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: highlight @-mentions of filenames containing spaces

A file mention was inserted verbatim as `@my file.txt`, but the highlighter regex
`@[\w/.\-\[\]]+` stops at the space, so only `@my` was parsed/highlighted and the
mention didn't behave as advertised. Introduce a small shared `mention` module:
names with whitespace are inserted in a bracketed form `@[my file.txt]`, and the
shared regex + `mentionTitle` parse both bare and bracketed tokens. Both insertion
entry points (the inline `@` picker in ContextTextarea and the toolbar path in
AIChatInput) now use `formatMention`, so the full name highlights.

Verified in a real browser: `@[my file.txt]` renders as a single highlight span.
Unit tests cover format/parse/round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: search_files reports a requested file's real status, not "not attached"

search_files filtered the store down to readyFiles() before validating a
requested `file`, so searching an attached-but-not-ready file (indexing / errored
/ locked / unavailable) while another file was ready returned "No attached file
named X" — even though it is attached. Factor read_file's status reporting into a
shared notReadyMessage() and have search_files report the same accurate status
before searching the ready subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: clear attached files on new/loaded chat in the non-session global chat

saveAndClear() (the "New chat" button) and loadPastChat() left attachedFiles
intact. In an AI session that's intended — files are session-scoped and persist
across conversations. But the ephemeral global side-panel chat has no session, so
the next, unrelated conversation still got the previous file roster injected and
could read_file/search_files against it. Clear attachments on both transitions
when `!isSessionChat`; sessions keep them. Adds a lifecycle regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep an empty folder linked when regranting access after reload

regrantLocked() dropped the locked placeholder unconditionally after #expandFolder.
If the regranted folder was empty (or all-binary), #expandFolder's #ensureFolderRow
no-op'd (the locked placeholder still existed), so dropping it removed the only
handle-bearing row — unlinking the folder and stopping future refreshFolders from
ever seeing files added back. Re-ensure a ready placeholder after dropping the
locked one. Adds a regression test for the empty-regrant path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: round-trip @-mentions of filenames containing a closing bracket

The bracketed mention form `@[name]` broke when the name contained a `]`
(e.g. `notes ] draft.md`): the regex stopped at the first `]` and mentionTitle
resolved the wrong name, so it wouldn't highlight. Escape `\` and `]` when
bracketing, match escaped chars in MENTION_RE, and unescape in mentionTitle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: highlight @-mentions of filenames with HTML-sensitive / special chars

getHighlightedText() escapes the textarea value to HTML before parsing mentions,
then looked the parsed title up against raw attached names — so a file like
`R&D notes.md` (escaped to `R&amp;D notes.md`) never matched and wasn't highlighted.
Also, names with chars outside the bare set (`<`, `>`, `&`, parens, …) weren't
bracketed, so the bare regex truncated them. Now formatMention brackets any
non-bare-safe name, and the highlighter HTML-unescapes the parsed title before the
store lookup. Verified in a real browser with `R&D notes.md`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: report the real reason search_files has no readable targets

When attachments existed but readyFiles() was empty, search_files always told the
model "still being indexed, try again shortly". That's wrong for the placeholder
states this PR introduces: an empty or binary-only linked folder leaves only a
filtered-out `ready` placeholder, and a locked/unavailable restored folder exposes
no readable children. Now the message reflects the actual state — no searchable
text, restore access, or re-link — and only says "indexing" when something is.
Adds a focused fileTools test for the empty-ready states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:21:14 +02:00
centdixandClaude Opus 4.8 4296a6ae1f feat(ai-chat): cap read_app_file + search_app grep tool to bound context in large raw apps (#9653)
* docs: add global AI chat context-optimization plan for raw apps

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): add global raw-app debugging cases on a large fixture

Adds a ~20-file analytics_dashboard raw-app fixture (incl. a 5k-line data module
and a planted wrong-totals bug), two global cases (read-heavy debug + small-edit
baseline), app-seed support in the mock backend, directory-fixture loading, and a
decorateHelpers seam so read-dedupe is measurable. Records tokenUsage for before/
after comparison of the read-tool optimization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): cap and dedupe read_app_file to bound context in large apps

read_app_file now defaults to a head slice (1500 lines / 50k chars) with offset/
limit to page further, and skips resending a file whose earlier read is still in
context (per-conversation ledger keyed off the originating tool-call id, so it
self-heals after compaction). Bounds the file-content portion of global-chat
context when working in large raw apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): add read-heavy raw-app debug case (large data module)

global-test31 induces the model to inspect the 5k-line seedData module, exercising
the read_app_file cap/offset path. Baseline ~262k tokens vs ~200k with the cap+dedupe
change (-24%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record A+B benchmark results and fixed-overhead finding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): clearer read_app_file past-EOF message + unit tests for cap/dedupe

Addresses local-review nits: out-of-range offset now reports 'offset N is past the
end of the file' instead of a backwards 'lines 11-10' label; adds unit coverage for
the slicing (line cap, offset/limit window, char budget, past-EOF) and re-read dedupe
(hit + miss-when-not-retained).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): char-level paging + per-range dedupe for read_app_file

Adds char_offset/char_limit so minified/long-line files can be paged within a line
window, keys the re-read ledger by range (so reading different ranges no longer
collides), and dedupes on the full-file hash (a cached range stub is invalidated
when any byte of the file changes, not just the returned range). Tests updated for
the char-slice behavior plus single-line capping, char paging, and out-of-window
change detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-chat): add read_app_file context micro-benchmark + re-read eval case

Adds a deterministic micro-benchmark (no LLM) that drives read_app_file through a
realistic big-project read pattern (large file, re-read, minified bundle, paging)
and asserts the cap+dedupe cut returned context >50% vs the old whole-file behavior
— isolating the feature's effect from model nondeterminism and guarding against
silent weakening. Adds global-test32, a cross-file consistency investigation that
revisits overlapping files so re-read dedupe is exercised in a real run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): clarify test32 measures the read cap, not dedupe

Verified: sonnet and haiku both read each file once per conversation and retain
it, so test32 never triggers read_app_file re-read dedupe. Dedupe is measured
deterministically by the micro-benchmark instead. Comment corrected to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): drop read_app_file re-read dedupe, ship the cap only

Benchmarking showed the per-conversation re-read dedupe never fires in practice:
across sonnet/opus/gpt-5.5/haiku, every model reads each file once per conversation
and keeps it in context (0 within-conversation re-reads). It was a correct but unused
guard, so this removes the ledger, full-file hash, retention predicate, the
AIChatManager wiring, and the eval decorateHelpers seam — keeping the read cap +
offset/limit/char paging (A), which is the lever that actually bounds context. The
micro-benchmark is now cap-only; test32 is kept as a multi-file read-load case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): add search_app grep tool for global raw-app chat (experimental)

Client-side grep over a raw app's frontend files and inline runnables (literal,
case-insensitive, optional file_glob/context_lines/max_matches, head-capped).
Completes the list -> search -> ranged-read triad. Includes the eval A/B gate
(WMILL_AI_EVAL_DISABLE_SEARCH_APP), unit tests + micro-benchmark, and a
find-all-usages eval case (global-test33).

Experimental: A/B benchmarking shows it is not an unconditional win — it helps
on find-all-usages but adds agentic iterations on navigable apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): accept search_app as a valid file-inspection tool in raw-app cases

Add requiredToolsAnyOf alternatives-group to ToolValidationSpec and switch
global-test29..32 to it so a model that locates files via search_app instead
of read_app_file no longer false-fails the tool assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: remove stale ai-chat context-optimization planning doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): drop read_app_file char paging for a hard char cap

The char_offset/char_limit params guarded minified files (a single line over
the char budget) but were effectively unused in benchmarks. Remove them and the
in-window char paging; keep the hard 50k-char budget and, when a read hits it,
tell the model to narrow the line limit (or treat the file as unreadable if a
single line exceeds the budget). Proper long-line handling is left as a TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): bake search_app context to 1 line, clarify query is literal

Drop the context_lines param (models varied it to little effect) for a fixed
SEARCH_APP_CONTEXT_LINES=1, and cap on matching lines instead of pushed rows so
max_matches stays accurate with context always on. Sharpen the query description
to state it is a literal (non-regex) substring and to suggest the call form
(e.g. formatCurrency() to hit call sites and skip formatCurrencyPrecise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): widen baked search_app context to 2 lines

Models that set the old context_lines param leaned to 2; match the lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): count every file with a match in search_app header

Move fileHadMatch ahead of the render cap so files whose matches fall past max_matches are still counted (with a regression test). Also swap the raw NUL globstar sentinel for a printable escape (the NUL bytes made core.ts read as binary to grep) and reword two comments to describe current constraints instead of drafting history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): drop redundant input echoes from app tool results

read_app_file and search_app no longer prefix results with the tool name or echo back the caller's own inputs (file path, query, file_glob) — the model already has them from the call args, and the unbounded query echo could push the search result past its output budget. Keeps the useful signals (line range, match/file counts, truncation) and the actionable advice. Also reword max_matches to 'matching lines' since it caps lines (each expands to context rows). Unit tests updated to the new format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:33:54 +00:00
Ruben FiszelandClaude Opus 4.8 017c3d3343 feat(ansible): add AI chat and editor bar buttons for ansible (#9671)
Ansible scripts previously lacked the AI assistant and the contextual
variable helper that other scripting languages expose in the script editor.

- Add 'ansible' to SUPPORTED_CHAT_SCRIPT_LANGUAGES so the AI chat button
  shows in the editor toolbar and the AI chat opens in SCRIPT mode without
  the "language not supported" warning.
- Add an Ansible system prompt (system_prompts/languages/ansible.md) plus a
  LANGUAGE_METADATA entry, and regenerate the auto-generated prompts/skills
  so the AI has tailored Ansible context.
- Show the contextual variable picker for ansible and insert references as
  `{{ lookup('env', 'NAME') }}`, matching how Windmill exposes reserved
  variables as environment variables to the ansible-playbook process.

Fixes WIN-2072

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:12:33 +00:00
centdixandClaude Opus 4.8 2fed808b9e fix(ai-chat): stop echoing app draft value in global chat write tool results (#9658)
finishAppDraftWrite returned `item: result.item`, whose `value` is the entire
app draft (every frontend file body and inline runnable). Each write_app_file /
patch_app_file / write_app_runnable therefore re-sent the whole app back to the
model; on a large app a few edits overflow the 200k context window.

This restores #9530 (which removed the echo) — the DB-backed-draft refactor
(#9601) reintroduced it by routing all app writes through this shared helper
with `item:` re-added. Write results now return only `{ success, message }`,
matching the flow write tools. Adds a regression test asserting the value is
not echoed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:21:49 +02:00
centdixandClaude Opus 4.8 5d553b81c0 feat(ai-chat): summary-based conversation compaction (#9645)
* feat(ai-chat): summary-based conversation compaction

Replace drop-oldest compaction with summary-based partial compaction: when
a send would cross the context-window trigger, summarize the older prefix
into one message and keep the recent tail verbatim, replacing the prefix in
both the model context and the visible transcript with a collapsible
boundary. Drop-oldest remains a fallback; a circuit breaker disables the
summary round-trip after repeated failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* fix(ai-chat): address review findings on summary compaction

- Stop during an in-flight summary no longer falls through to a destructive
  drop-oldest compaction. The aborted controller short-circuits the fallback
  and its save, so the cancel path rolls the unsent turn back cleanly instead
  of permanently dropping older history (P1).
- Preserve the original chat title across compaction: once the summary
  boundary leads the transcript, reuse the title computed before compaction
  rather than re-deriving it from the first surviving tail message (P2).
- Strip every <analysis> block from the model's summary, not just the first,
  so extra scratchpad blocks can't leak into context (P2).
- Reindent AIChatMessage.svelte / ContextUsageIndicator.svelte (prettier).

Adds regression tests for the abort path, title preservation, and
multi-analysis stripping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nit

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:51:06 +02:00
GuilhemandClaude Opus 4.8 e09cd5862c feat: per-user draft review & deploy page (gating, badges, rename, raw-app deploy fixes) (#9625)
* feat: per-user draft gating, badges and rename display on deploy page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): don't strike the path when a draft adds a summary to a summary-less item

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): don't strike draft-only items' auto-generated path against the pretty path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy raw-app drafts from top-level files so the bundle isn't dropped

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): share raw-app source→draft-value projection across chat and deploy page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy renamed/new flow, app and raw-app drafts at draft_path, not the temp storage path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): add a design-system Checkbox and use it for deploy-page row/select-all checkboxes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: "Show all drafts" toggle on the deploy-drafts page

Replace the deploy-drafts page's legacy-hiding "Only my drafts" toggle with
a "Show all drafts" toggle that switches the listing scope between the
current user's own drafts (+ legacy no-owner rows) and every user's drafts
in the workspace.

Backend (`drafts.rs`, `openapi.yaml`):
- `/drafts/list` gains an `all_users` query param that drops the owner
  filter, and a per-row `mine` flag (own draft or legacy no-owner row).
  `DISTINCT ON` now prefers the user's own row, then the legacy row, then
  another user's, so `mine`/`legacy_draft` describe the kept row.

Frontend (`CompareDrafts.svelte`, `workspaceDrafts.svelte.ts`):
- "Show all drafts" toggle (default off). The all-users superset is fetched
  lazily via the shared resource only while the toggle is on, so the page's
  fork draft-count (own drafts) is unaffected.
- Other users' drafts are view-only: disabled checkbox + Discard with a
  "belongs to another user" tooltip; Show diff stays enabled. Selection,
  select-all and the deploy count only ever include the user's own drafts.
  The multi-user warning triangle shows on owned rows only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): gate all_users draft listing by read permission

Addresses the PR review on the per-user deploy-drafts page:

- `/drafts/list?all_users=true` previously had only `WHERE workspace_id = $1`
  with no read-permission check, so any non-operator could enumerate every
  draft's path, summary and authors — including items they can't read. Now
  rows the caller doesn't own (`mine = false`) are gated through
  `require_can_read_path` (the same gate `/drafts/get` uses) and dropped when
  unreadable; both its `NotFound` and `NotAuthorized` denials are treated as
  "not visible".
- Skip the per-row `require_can_write_path` probe on those non-owned rows
  (they're never selectable — `isSelectable` requires `mine`): set
  `can_write = false` directly, removing a redundant N RLS write-probes when
  `all_users` is on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): only confirm destructive draft discards on the deploy page

Discarding a draft is non-destructive in every case except removing the last
draft of a never-deployed item (`draft_only` with no other user's draft),
which permanently deletes it. Confirm only that case; reverting a draft over a
deployed item, or discarding your copy while another user still holds a draft,
now runs immediately (the ⚠️ already signals the multi-user case). Drops the
redundant "other users still have a draft" / "deployed version unaffected"
confirmation branches.

Harden the destructive check: it keyed off `otherDraftUsers()`, which subtracts
`currentUsername`; while `$userStore.username` is unhydrated, your own draft
looked like another user's, flipping a draft-only item to "non-destructive" and
deleting it with no confirmation. Now: deployed counterpart → never destructive;
`draft_only` with unknown `currentUsername` → treated as destructive (confirm).
The delete modal also shows the friendly `draft_path` instead of the raw
`draft_{uuid}` storage path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy low-code app drafts (value + summary persistence)

A visual (low-code) app draft is autosaved as the *bare* App value
(grid/theme/... plus a draft-only `draft_path`), not wrapped in
{ value, summary, policy } like script/flow drafts. The Review & Deploy page
read `requestBody.value = d.value` — undefined for that shape — so deploying any
low-code app draft (created or edited) sent no value and failed. Read the value
from the draft object itself, strip the draft-only `draft_path` from it, and use
that as the deploy path.

Also persist the app summary, which was dropped entirely: the autosave stores
the bare App value (the summary normally lives only in the `app` table column,
set on deploy), so a draft never carried it — reopening a draft or deploying it
lost the summary. Mirror the summary onto the autosaved App (like `draft_path`),
read it back when loading a draft, and on deploy send it as the summary column
while stripping it (and `draft_path`) from the deployed value so the value stays
clean.

Verified end-to-end: a new low-code app with a summary deploys at its pretty
path with the summary set, content intact, and no draft_path/summary leaked into
the deployed value; the draft is cleaned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:49:36 +00:00
centdixandClaude Fable 5 f4425fca9f feat(ai-chat): self-hosted docs tools via windmill.dev llms.txt + ask benchmark (#9578)
* feat(ai-chat): add self-hosted docs tools fetching from windmill.dev llms.txt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ai-evals): add ask benchmark mode comparing inkeep vs llms.txt docs tools

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ai-chat): fix docs link sanitizer tests to match skip-all-`../` guard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai-chat): add hybrid full-text docs search tool and ask variant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai-chat): expose docs search tools in the global workspace assistant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): drop inkeep/llmstxt arms, keep only hybrid docs search

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ai-chat): remove docs-tool benchmark write-up

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-evals): remove ask mode, cover docs search via global mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* nits

* refactor(ai-chat): swap navigator + api copilots from inkeep to search_docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): point read_docs_page empty-path hint at search_docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 15:01:03 +02:00
Guilhem 51bd8692a4 feat: queue messages typed while ai chat is streaming (#9525)
* feat(frontend): queue messages typed while ai chat is streaming

* fix(frontend): avoid losing queued chat messages on send early-return

* test(frontend): cover queued chat message semantics in AIChatManager

* fix(frontend): complete ChatLoopResult mock in queued message tests

* feat(frontend): single appendable queued message, send on cancel

* fix(frontend): only auto-send queued message on a user cancel, not programmatic

* chore(frontend): remove queued-message dev preview page

* fix(frontend): clear queued chat message on conversation switch
2026-06-17 12:50:10 +00:00
GuilhemandClaude Opus 4.8 611c70acd2 feat(frontend): adapt AI-chat/sessions drafts to DB-backed model (#9601)
* feat(frontend): adapt AI-chat/sessions drafts to DB-backed model

PR #9351 dropped UserDraft's localStorage layer; the chat adapter's
synchronous save->read-back threw "Could not read written draft". The
adapter now treats the backend as source of truth (in-tab cell used
opportunistically for live-preview coherence) with conflict-on-save,
and read tools fall back to the backend. Collapses the six writeXDraft
functions onto one generic writeDraft + typed per-kind WriteSpec
constants. Terminology: "local draft" -> "draft" (drafts are server-side).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): autosave indicator + draft-only diff guard in session editors

Thread an explicit (workspace, path) autosave target to the cloud
AutosaveIndicator in the Script/Flow/RawApp session previews so it
watches the same key saves land on (it previously watched an empty path
and never animated). Disable the Diff button with a hint for draft-only
(no_deployed) items consistently across the three editors. Adjust the
script topbar compact breakpoint/layout so the cloud icon is part of the
bar, and stop splitpanes over-constraining session panes on reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): session draft diff viewer for schedule/resource/variable

Canonicalize both sides of the draft diff onto one field set and strip
runtime-only fields so rows aren't spuriously marked all-changed; mask
secret values. Map draft itemKinds to deploy-style kinds so the DiffRow
shows the correct icon/label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): uniform diff-viewer row height regardless of summary

Diff-viewer leaf rows (WorkspaceItemRow) drew two lines when an item had a
summary and one line otherwise, giving unequal heights. Add an opt-in
`uniformHeight` prop that gives the text wrapper a shared min-height and
vertically centers the one-line case; enable it only from the diff viewer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): address review nits on the drafts diff/guard changes

- Reuse the exported TRIGGER_RUNTIME_IGNORE from utils_deployable instead of
  a verbatim copy, so the runtime-field ignore list has one source of truth.
- Drop the now-redundant `(savedApp as any)` cast in RawAppEditorHeader; the
  prop type already carries `no_deployed`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): add description parameter to the write_flow chat tool

write_flow had no way to set a flow's top-level description (the sibling
of summary in OpenFlow); patch_flow_json only edits the compact value, so
the field was unreachable from the AI chat. Thread an optional description
end-to-end: tool schema -> persisted draft -> read-back -> deploy body.
Structural patches (patch_flow_json/set_flow_module_code) pass no
description, so a previously-set description is preserved. Adds a
deployRequests regression test asserting a draft description reaches the
deploy body, overriding the deployed one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): round-trip top-level fields in session preview draft sync

The session preview's two-way draft sync dedups on a per-kind signature
and mirrors fields between the editor store and the shared UserDraft cell.
Both omitted fields the chat can set, so with the preview open a change to
only that field was swallowed (identical signature) and then clobbered by
the editor's outbound save:
- flow: the signature and applyDraftToStore ignored top-level `description`.
- script: the signature keyed on `content` alone, dropping `summary`/`language`.

Add the missing fields to flowDraftSig and the script codec signature, and
copy `description` in the flow codec's applyDraftToStore (mirroring `summary`).
Raw-app already stringifies the whole draft, so it was unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy draft-only flow from the session preview

Deploying a draft-only flow (a draft with no deployed row) from the session
preview hit two gaps the full-page flow editor already handled:
- create vs update: newFlow keyed on `!savedFlow.val`, but a draft-only flow
  has a synthesized savedFlow (no_deployed=true), so deploy took updateFlow
  against the draft path and 404'd "Flow not found". Key it on no_deployed too.
- friendly name: a brand-new flow is stored under a `draft_<uuid>` path with
  its intended name in `draft_path`. Seed the builder's initialPath from
  `draft_path` (as the full-page editor does) so the Path widget and deploy
  use the friendly name instead of creating a flow named draft_<uuid>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): deploy draft-only raw app from the session preview

Same create-vs-update bug as the flow session preview: newApp keyed on
`!savedRawApp.val`, but a draft-only app has a truthy synthesized savedApp
(getAppByPath with rawApp:true resolves to the draft kind instead of 404ing,
carrying no_deployed=true), so deploy took updateApp against a path with no
deployed row and 404'd "not found". Key newApp on no_deployed too so a
never-deployed app deploys via createApp. More reachable than the flow case:
it hit any never-deployed app, including chat-created ones at friendly paths.

Keying newApp on no_deployed also exposed that newEditedPath (the breadcrumb
path AND the createApp target) used newApp to mean "brand-new, generate a
random name". A draft-only app is newApp=true but already has a real path
(empty newPath at init, but appPath is set), so it showed and would deploy a
random `*_app` name. Prefer the real appPath before the random fallback, so
only a genuinely new app (appPath === '') still gets a generated suggestion;
the full-page editor is unaffected (it always sets newPath).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): don't re-save a draft after deploying from the session preview

Deploying from a session preview reloaded the editor (expected) but then
immediately POSTed a fresh draft. The full-page editor guards deploy with
discardDraftAfterDeploy (stopSync + arm-restart-on-first-interaction), but the
shared editor header skips that in a session pane (inSessionPane) and routes
post-deploy cleanup through sessionRuntime.syncPreviewWithDeployed, which did
discard + reload without the stopSync guard. UserDraft.discard keeps the cell
entry, so the reload's UserDraft.save fired the cell's reactive effect and
re-POSTed the just-deployed value as a draft.

Wrap the discard + reload in the same UserDraft.stopSync + armRestartOnFirst-
Interaction bracket. One place fixes all three kinds (script/flow/raw_app),
since they all funnel through syncPreviewWithDeployed; autosave resumes on the
next genuine edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): address review findings on the session-preview drafts work

- Type `no_deployed` via the GetXByPathResponse/UserDraftOverlay types instead
  of `(result as any)`/`(saved as any)` casts at the sites this branch added
  (sessionRuntime, ScriptBuilder, FlowBuilder, + widened the script/flow
  builder prop types). Pre-existing trigger/variable/resource-editor casts
  left untouched.
- Drop a history-narrating comment parenthetical per the AGENTS.md comment
  policy (RawAppEditorView).
- Add a unit test covering persistGlobalDraft's conflict-on-save / override
  path (conflict-capable updateDraft mock; inert for existing tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): keep the friendly generated path for a brand-new raw app

The earlier draft-only newApp fix made newEditedPath prefer `appPath` before
the random suggestion, but a brand-new app is parked at the storage placeholder
`u/{user}/draft_{uuid}` (the /apps_raw/add redirect target), so it surfaced that
uuid instead of a friendly `<adjective>_app` suggestion. Reject a `draft_`
placeholder segment when choosing the path: a real named/draft-only path is
still kept, a placeholder falls through to the generated suggestion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): show the Diff-button tooltip when it's disabled

A disabled <button> fires no pointer events and browsers suppress its
native title, so the "deploy once to compare" explanation never showed on
hover for a draft-only item's Diff button. Wrap the button in a titled
element and set pointer-events-none on the button when disabled, so the
hover reaches the wrapper. Applied in ScriptBuilder, FlowBuilder, and
RawAppEditorHeader (covers both the full-page editors and the session preview).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): surface draft save failures and conflicts in the AI chat tools

Addresses Codex + Pi review findings on PR #9601 (three P1s, all in the
DB-backed draft adapter reporting success when the write didn't land):

- persistGlobalDraft reported {status:'saved'} even when UserDraftDbSyncer.save
  failed (it records network/5xx into a failure map instead of throwing). Check
  getState().state==='failed' after the save and return a new 'error' status;
  finishDraftWrite now emits success:false with a retry hint.
- saveGlobalAppDraft dropped the conflict/error status (returned only the item),
  so write_app_file/patch_app_file/write_app_runnable reported every stale or
  failed write as saved. It now returns the full DraftPersistResult, and the six
  app write tools route through a shared finishAppDraftWrite helper.
- fetchBackendDraftValue's catch{} swallowed non-404 errors (403/500/network),
  collapsing them to "no draft" so the write merged from the deployed item and
  lost in-progress draft edits. Narrow the catch to status===404; propagate the
  rest.

Adds unit coverage: save-failure -> 'error', non-404 read -> propagates,
raw-app stale write -> 'conflict'. 71/71 pass, check:fast + full build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): surface failed draft deletes + strip tool-only override from schedule drafts

Addresses Codex's second-round review on PR #9601:

- [P1] deleteGlobalDraft reported success even when the server delete failed or
  conflicted (UserDraftDbSyncer.save records failure state instead of throwing) —
  so discard_local_draft / deploy_workspace_item / delete_workspace_item / the
  /global_drafts delete could report a draft removed while the DB still had it.
  Check getState().state and getConflict() after the awaited null save and throw,
  mirroring the write-path guard.
- [P2] writeScheduleDraft persisted the tool-only `override` conflict flag into
  the schedule draft value (mergeDraftConfig cloned every arg field). Strip
  `override` in SCHEDULE_SPEC.buildDraft before merging.

Tests: failed server delete -> throws; schedule draft no longer contains
`override`. 73/73 pass, check:fast + full build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): /global_drafts "Clear all" deletes persisted drafts, not just cells

Codex review nit (P2): the dev-only global-drafts inspector's "Clear all" called
clearGlobalDrafts(), which only iterates in-tab UserDraft cells — any persisted
backend draft row not currently mounted as a cell survived, so the list re-showed
it after refresh. Iterate the listed drafts and delete each via the backend-aware
deleteGlobalDraft() (continue past per-row failures), matching the per-row delete,
then clear local cells + ephemeral secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:20:19 +02:00
hugocasaandClaude Opus 4.8 51e82d7c6d fix(frontend): make UserDraft read-after-write work without live entry (#9609)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:25:33 +02:00
centdixandClaude Opus 4.8 5709a564fb feat(frontend): add user-level toggle to disable Windmill AI (#9585)
* 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>
2026-06-15 20:04:01 +02:00
GuilhemandClaude Opus 4.8 aa26c4d9b2 feat: scope AI session storage per user, session list in IndexedDB (#9518)
* feat: scope AI session browser storage to the logged-in user

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: address review nits in user-scoped session storage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: store AI session list in per-user IndexedDB via shared userScopedDb

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover legacy chat-history migration; address review nits

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden chat-DB writes and session-key cleanup per PR review

- route HistoryManager writes through whenReady() so they can't land in a
  previous user's DB after an in-place user switch (drop cached this.indexDB)
- dedup the legacy chat-DB claim to one session-wide promise so racing
  manager instances can't issue blocking concurrent deleteDB calls
- delete bare windmill_sessions keys unconditionally even when the user DB is
  already populated, closing a partial-migration leak window

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: trim package-lock to only the fake-indexeddb addition

npm install -D had also stripped "dev": true from ~34 unrelated optional
native-binding packages (npm graph recomputation). Restore main's lockfile and
graft in only the fake-indexeddb node, so the lock diff is exactly the intended
dev dependency and prod-install classification of those bindings is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:31:12 +02:00
1fc355709c feat: Db-backed user drafts (#9351)
* 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>
2026-06-15 10:23:16 +02:00
centdixandClaude Fable 5 2b471805bf feat(frontend): precise AI chat context usage tracking + indicator (#9551)
* feat: track real ai chat token usage and show context indicator

* fix: keep context anchor full-history accurate after trimmed sends

* nits

* feat: restyle context indicator and disable trim for unknown windows

* feat: hide context indicator below 50% usage when window is known

* fix: gate 1M claude context window to sonnet/opus 4.6+

* refactor: import context window helpers from modelConfig directly

* fix: keep base gpt-5 models at 400k context window

* fix: exclude date-suffixed claude 4 ids from 1M window gate

* refactor: replace context window heuristics with explicit model table

* fix: account for context overhead in trim loop stop condition

* fix: re-base context anchor when mode switch changes system prompt or tools

* refactor: replace context estimation with usage-report-driven compaction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: chars/4 fallback for context usage when provider reports none

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: remove unused slide import failing svelte-check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: re-seed context usage estimate on rewind so retry can compact

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: lazy read-side estimate fallback for context usage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: fix reasoningRegistry mock to match resolveRequestReasoning

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 08:43:47 +02:00
Ruben FiszelandClaude Opus 4.8 6ff82b5345 home tree view each_key_duplicate on folder/user name collision (#9561)
* fix(frontend): home tree view each_key_duplicate on folder/user name collision

The root keyed each in TreeViewRoot keyed groups by
`folderName ?? 'user__' + username`. A folder named `user__<name>` and a
user `<name>` both produce the key `user__<name>`, and any root-level item
with neither field keys to `user__undefined`, triggering Svelte's
`each_key_duplicate` and crashing the home screen.

Use a discriminated key (`f__`/`u__`/`i__` prefixes) that cannot collide
across folders, users, and items — the same scheme already used in Dev.svelte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): remove unused slide import breaking frontend-check

`slide` (added in #9539) is imported but never used in AssistantMessage,
which fails `npm run check` (svelte-check --threshold warning) and reddens
the frontend-check baseline on main for every frontend PR. Remove it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 10:17:38 +02:00
114c41251a feat(frontend): improve AI chat cancel and interrupted-turn handling (#9539)
* feat(frontend): improve AI chat cancel and interrupted-turn handling

- Escape stops the in-flight generation when focus is on the chat
  (composer, messages, panel) — capture-phase listener so neither the
  session Monaco editor nor mounted-but-closed modals swallow the key
- When a turn yields no output (or is cancelled before any), roll it
  back and restore the message to the composer
- When a turn is cancelled or fails mid-way, keep the completed
  tool-paired steps and the partial answer text as context so a
  follow-up like "continue" picks up from there
- Animate the thinking-block collapse like tool boxes (slide 150ms)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): address review findings on interrupted-turn handling

- Guard the catch in sendRequest so a post-commit throw (e.g. saveChat)
  cannot commit the turn a second time or mis-flag the user message
- Delete the persisted chat entry when rolling back a first turn empties
  the transcript (saveChat no-ops on empty, leaving a stale entry)
- restoreInstructions skips when the user already typed a new draft
- Use stopImmediatePropagation so one Escape on body focus cannot cancel
  several mounted chat panels at once

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): tighten comments and dedupe tests in AI chat changes

Keep each invariant comment once at the place it would be broken; drop
narration and repeated rationale. Remove near-duplicate test cases
(chatLoop boundary permutations, cancel-before-output subset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add comment policy to AGENTS.md core principles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: comments must describe current code, not PR drafting history

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): treat completed reasoning-only turns as unsent

A turn that finishes without abort but emits only reasoning produced a
display bubble, so the empty-turn rollback (keyed on display output)
skipped it and the user message was silently swallowed. Key the
decision off usable output instead. Also trim comment blocks to the
AGENTS.md 4-line norm, splitting rationale to its break-site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-12 17:27:36 +02:00
hugocasa aaf05635ce feat: wire chat reasoning effort through gemini and bedrock proxies (#9545)
* feat: map chat reasoning effort to gemini thinkingConfig in ai proxy

* feat: support claude adaptive thinking through the bedrock ai proxy

* feat: per-model gemini effort levels and thought summary display

* fix: exclude claude opus 4.5 from reasoning effort (rejects adaptive)

* fix: render markdown in thinking blocks and unstick reasoning spinner

* feat: model-aware reasoning effort options across ai chat providers

* fix: scope openrouter reasoning off to the underlying model family
2026-06-12 17:24:46 +02:00
centdixandClaude Fable 5 3585716872 fix: stop sending temperature for AI chat across all providers (#9553)
* fix: stop sending temperature for AI chat across all providers

Remove the temperature field from all AI chat completion requests and
drop the model-based send-or-not special-casing. Previously the chat sent
temperature: 0 for determinism and omitted it for reasoning models
(claude-opus-4-7/4-8, gpt-5+, o-series) that reject sampling params, which
required hand-maintaining a growing model list.

The model-detection helper is kept (renamed modelDisallowsSamplingParams ->
requiresMaxCompletionTokens) since it still serves a separate concern:
choosing max_completion_tokens over max_tokens for OpenAI/Azure reasoning
models on the Chat Completions API.

The FIM autocomplete temperature: 0 is intentionally left untouched (it is
the code-autocomplete path, not the chat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: drop dead claude-* branches from requiresMaxCompletionTokens

After temperature removal, requiresMaxCompletionTokens only governs the
max_completion_tokens vs max_tokens choice for OpenAI/Azure reasoning
models (its sole call site is gated on provider === openai|azure_openai).
The retained claude-* branches were leftovers from when the function
omitted temperature for Anthropic reasoning models; they are unreachable
for the max_completion_tokens decision and made the kept detection tests
assert a semantically false claim. Claude reasoning behavior is owned by
reasoningRegistry. Drop the dead branches and their tests so name, comment,
code, and tests agree. Behaviorally inert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:21:43 +02:00
centdix bd00beeac5 fix: omit temperature for claude fable 5 (#9540) 2026-06-12 00:21:40 +02:00
centdixandClaude Opus 4.8 f86d0d79fc feat: add get_app_runtime_logs tool to global chat (#9502)
* feat: add get_app_runtime_logs tool to global chat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only handle raw app backend messages from the runner's own iframe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: add list_app_runs tool to global chat for raw app backend runs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: simplify raw app chat tool results

* nits

* nits

* chore: bump ui builder artifact

* fix: resolve pending runtime log requests on cleanup

* fix: harden raw app runtime log requests

* nits

* fix: show raw app tool results in status

* fix: cap raw app runtime log results

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:19:13 +02:00
centdix d3f5fe1c8c feat: enable native web search in copilot (#9522)
* feat: enable native web search in copilot

* fix: add web search fallback and settings

* test: use frontend uuid helper

* fix: tighten web search fallback

* fix: add web search error hint

* fix: classify web search fallback errors

* fix: avoid web search fallback tool error

* fix: handle anthropic web search enablement errors
2026-06-12 00:18:22 +02:00
GuilhemandClaude Fable 5 a8f1062f37 feat(frontend): show AI sessions in narrow-screen burger menu (#9523)
* feat(frontend): show AI sessions in narrow-screen burger menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): show burger menu on sessions page in narrow setup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:42:11 +00:00
centdix ce6e2f7ade fix(frontend): stop echoing draft values in global chat write tool results (#9530) 2026-06-11 11:35:09 +02:00
hugocasaandClaude Opus 4.8 4e868062d4 fix(frontend): improve AI chat markdown and typing dots in dark mode (#9497)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:49:46 +00:00
GuilhemandClaude Opus 4.8 ab015dd44b style: lean tool-call rendering and tighter ai chat spacing (#9517)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:20:02 +00:00
hugocasaandClaude Opus 4.8 7f987e8c98 feat: add reasoning effort control and thinking display to AI chat (#9511)
* feat: add reasoning effort control and thinking display to AI chat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: strip legacy /thinking suffix from configured model slots

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: refine reasoning effort UX and drop dynamic-capability scaffolding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: show textless reasoning on the typing indicator

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:15:38 +00:00
centdix 61a4dd6e91 refactor: extract frontend uuid helper (#9521) 2026-06-10 15:15:04 +00:00
GuilhemandClaude Opus 4.8 365e20410e feat(ai-chat): collapse big pastes, cap input height, escape HTML (#9487)
* feat(ai-chat): collapse big pastes, cap input height, escape HTML

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(ai-chat): concentrate paste expand/render in a ChatDraft module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ai-chat): step caret over paste chips as one unit

Arrow-Left/Right now jump edge-to-edge across a collapsed-paste chip
instead of crawling through the invisible token characters, and snap the
caret out if it lands inside a token. Shift extends the selection across
the whole chip; word/line jumps (alt/cmd/ctrl) are left to the browser.
Mirrors the existing atomic-deletion behavior so traversal and deletion
both treat the chip as a single object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): atomic chip deletion on overlapping selections + plural label

A selection that partially overlapped a paste token previously hit the
collapsed-caret early-return, so the browser deleted a partial token and
left an orphaned zero-width run plus a dangling pastes registry entry.
handlePasteDeletion now widens the deletion range to cover each overlapped
token whole and drops all affected pastes entries (shared removePasteRange
helper). Strict overlap, so abutting a chip edge doesn't pull it in.

Also route line-count pluralization through a shared lineCount() helper so
a 1-line paste reads "1 line" in the conversation bubble too, not "1 lines".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): full chip atomicity via beforeinput + expand on copy/cut

Addresses review nits on PR #9487:

- Chip atomicity is no longer limited to Backspace/Delete keydown. A new
  beforeinput handler takes over any selection-spanning edit that overlaps
  a paste chip — typing over a selection, paste, drag-and-drop, and
  Backspace/Delete over a selection — and applies it to the whole token(s)
  via a shared replacePasteRange, so a partial edit can no longer leave an
  orphaned zero-width run or a dangling pastes registry entry. handlePaste
  is likewise widened so a large paste onto a chip replaces it whole. The
  keydown handler now only covers the collapsed-caret-at-boundary case
  beforeinput can't see.
- Copy/cut of a selection containing a chip now puts the expanded content
  on the clipboard instead of the chip label + its zero-width run; cut also
  removes the chip whole. This also removes the duplicate-token re-paste
  vector (the clipboard never carries a raw token anymore).
- Rename lineCount -> lineCountLabel: it returns a formatted string, not a
  count, so the name shouldn't read like an accessor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): address cubic + claude review findings on the paste feature

cubic-dev-ai findings:
- autosize: clear inline overflow-y on the uncapped path so a capped->
  uncapped toggle can't leave a stale `auto`/`hidden`.
- pasteTokens: new countLines() ignores a single trailing newline, so a
  10-line paste with a trailing \n no longer counts as 11 (off-by-one in
  shouldCollapsePaste and the chip's reported line count).
- ContextTextarea: the @-mention picker anchor now subtracts the textarea's
  own scrollTop/Left and is recomputed on internal scroll, so it stays
  pinned once the input is capped at 40vh and scrolls.

claude re-review findings:
- ContextTextarea: drag-and-drop of a selection containing a chip now puts
  the expanded content on the drag payload via ondragstart (mirroring
  copy/cut), so a dragged chip no longer orphans its token and loses the
  pasted content (or leaks the label to an external target).
- ContextTextarea: handlePasteBeforeInput now guards on e.cancelable and a
  HANDLED_INPUT_TYPES whitelist, so historyUndo/Redo (Ctrl+Z) and
  non-cancelable insertCompositionText (IME) are left to the browser
  instead of being reinterpreted as a delete / rewritten mid-composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:58:01 +00:00
GuilhemandClaude Opus 4.8 b894f783f1 feat(ai-chat): quick access to AI prompt settings from chat (#9508)
* feat(ai-chat): quick access to AI prompt settings from chat

Add a cog next to the AI model selector in the chat that opens a dropdown
to edit the current chat mode's user (localStorage) and workspace (DB
ai_config) AI prompts, reusing the shared AIPromptsModal. Workspace prompt
editing is admin-only; non-admins get a read-only view with an info Alert.
The dropdown and modal footer include an admin-only deep-link to the full
AI settings page, opening in a new tab.

Workspace save re-applies the saved custom_prompts onto the store after the
ai_config round-trip, since effective_ai_config falls back to the instance
config (and would drop them) when the workspace has no providers of its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): snapshot mode when opening prompt settings modal

Prompt edits were keyed off the live chat mode (a $derived of
aiChatManager.mode). If the chat mode changed while the modal was open,
save/reset/hasChanges and the modal's target mode would follow the new
mode and write to the wrong key. Capture the mode into an activeMode
snapshot at open time and use it throughout the modal lifecycle.

Identified by cubic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): base-prefix AI settings links and reset hasChanges after save

- Prefix the AI-settings deep-links (dropdown item + modal footer) with
  {base} so they resolve under a deployment base path instead of 404ing.
- After a successful save, sync customPrompts to the trimmed value so
  hasChanges flips back to false (Save/Reset disable, Reset no longer
  snaps the textarea).

Identified by Claude review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): gate workspace prompt editing on workspace having own providers

When a workspace has no AI providers of its own (it uses instance defaults),
the backend never makes workspace custom_prompts effective — get_copilot_info
returns the instance config verbatim. A workspace prompt saved in that state
would be dead config that silently disappears on reload, and the earlier
re-apply hack also transiently replaced instance prompts in copilotInfo.

Mirror the settings page (AISettings.svelte): detect the workspace's own
providers in the same getSettings fetch used to seed the prompt, and when there
are none, surface the workspace prompt read-only with an explanatory Alert
(new readOnlyReason prop on AIPromptsModal) instead of an editable field.
Since editing is now gated to workspaces with their own providers,
effective_ai_config equals the saved config, so the re-apply is reverted to a
plain setCopilotInfo(effective).

Identified by Claude review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): await async save before closing prompts modal

AIPromptsModal.handleSave called onSave() without awaiting, then closed the
modal immediately — so the workspace save round-trip was still in flight when
the modal dismissed, with no loading state and any error toast landing after
the modal was gone.

Make handleSave await onSave (now typed to allow a Promise), show a loading
state on Save while in flight, and keep the modal open if the save throws.
AIChatSettingsMenu.save() re-throws on failure so the modal stays open. The
synchronous user-prompt path and existing callers are unaffected.

Identified by Pi review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:54:45 +00:00
centdixandClaude Opus 4.8 cfe5119035 feat(ai): add list_runs and get_job_logs tools to global chat mode (#9488)
* feat(ai): add list_runs and get_job_logs tools to global chat mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai): always suppress ansi hint in get_job_logs, drop misnamed param

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai_evals): add global list_runs and get_job_logs eval cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(ai): trim get_job_logs description and format global core

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai): surface list_runs/get_job_logs output as tool result

The tools set showDetails but never set message.result, so the details panel rendered "No result yet" even on success. Set result in setToolStatus (logs go in result for get_job_logs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:34:36 +00:00
GuilhemandClaude Opus 4.7 64b089cd23 feat(frontend): use unified drill picker for AI chat @-mention dropdown (#9159)
* feat(frontend): use unified drill picker for AI chat @-mention dropdown

* fix(frontend): chat picker review followups + overlay alignment

- AIChatDisplay: migrate @-badge popover to ChatContextPicker (was still
  importing the deleted AvailableContextList after the rebase onto #9034,
  causing a build break).
- DrillPicker: handle Tab as Enter so the inline @<word> mention completes
  without losing focus. Tweak leaf-row weight to font-normal; secondary
  text uses text-hint.
- ContextTextarea: drop px-0.5 from the highlight span — extra horizontal
  padding made every glyph typed after a mention drift right of the
  invisible textarea below. box-decoration-clone keeps the rounded corners.
- ContextElementBadge: explicit font-normal label, hoist label into a
  {@const} and pass to title= so the truncated badge shows the full title
  on hover.
- workspaceTree: drop orphaned doc-comment left dangling by the rebase.
- Add unit tests for drillPicker.ts and workspaceTree.ts (51 tests cover
  resolveScope/scopeChain/collectLeavesGrouped/leafHaystack, buildWorkspaceTree
  shape + loading + dir forest + leaf shape, withCurrent rename suppression,
  extraItemsByKind dedup, legacyScopeToPath, relativizeWorkspacePath).

* fix(flow-editor): ignore keyboard shortcuts when focus is outside the flow root

Menus, modals, drawers etc. live outside the flow root and capture focus
explicitly. Flow nodes aren't focusable, so the unfocused default
(activeElement === body) means "flow is the canvas" and we should react;
anything else means another surface has the user's attention and our
shortcuts would steal it.

* fix(frontend): inline @ mention picker + chat layout polish

- ContextTextarea: swap manual Portal+caret-math positioning for
  svelte-floating-ui anchored at the `@` character (virtual reference,
  middleware [offset, flip(crossAxis:false), shift]). Picker stays
  pinned to `@` while the user types the query, slides leftward when
  hitting the right edge instead of flipping alignment, and floating-ui
  handles above-vs-below + edge clamping automatically. Drops the
  60vh-worst-case reservation that left a big gap above the caret in
  sessions, and the now-unused isFirstMessage prop is marked deprecated.
- AIChatDisplay: the `@`-button Popover now opens with placement
  bottom-start (was the default `bottom`), aligning its left edge with
  the button instead of centering under it.
- ChatContextPicker: when no Diffs/Modules/Databases branches are
  present (e.g. global chat), return the Workspace tree's children at
  the root instead of wrapping them under a redundant "Workspace" row.
  handleScopeChange handles both the wrapped and unwrapped layouts and
  the single-kind `dir:` top segment.

* chore(frontend): address review suggestions on chat picker PR

- DrillPicker: clamp width to viewport on narrow screens —
  w-[420px] → w-[min(420px,calc(100vw-20px))].
- workspaceTree.buildWorkspaceTree: make loadingKind optional (defaults
  to {}). Chat picker still passes it; callers that don't track loading
  no longer need to thread an empty object.
- ChatContextPicker.handleScopeChange: name the WRAPPED vs UNWRAPPED
  layouts in a comment block so the dir:/kind: branches are obvious.
- ContextTextarea: drop deprecated isFirstMessage prop (floating-ui
  handles direction); drop defensive Math.max on the @ index now that
  the invariant is documented; comment the floatingRef(anchorRef) call
  as the supported virtual-reference path in svelte-floating-ui.
- AIChatInput: stop forwarding isFirstMessage to ContextTextarea.

* feat(frontend): sync selectedContext with @-mentions in textarea

Both picker entry points now insert a visible `@title` token in the
textarea, and deleting that token drops the matching entry from
selectedContext.

- AIChatInput: new insertMention(title) export. Appends `@title ` to
  instructions, prefixing a space only if the existing text doesn't
  already end in whitespace.
- AIChatDisplay: the `@`-button popover calls insertMention after
  addContextToSelection so its picks match the inline-mention path's
  textarea state.
- ContextTextarea: new onRemoveContext callback. A $effect compares the
  set of `@title` tokens in `value` (derived) against the previous
  snapshot; titles that disappeared trigger onRemoveContext for any
  selectedContext entry with `deletable !== false`. The diff lives in
  an effect (not handleInput) so it catches both keystroke deletions
  AND programmatic value updates from updateInstructionsWithContext.
- AIChatInput: passes onRemoveContext that filters selectedContext by
  type+title — mirrors the existing badge X-button handler.

* chore(frontend): narrow ChatContextPicker `inner` from `any` to `DrillPicker | undefined`

The previous `let inner: any` worked around svelte-check rejecting
`DrillPicker<ChatLeafData>` (the imported component is seen as the
non-generic `Comp`). Dropping the type parameter keeps the workaround
without `any`, so handleKeydown / pickHighlighted are at least typed
at the call site.

Addresses May-14 PR review.

* fix(frontend): address PR #9159 bot-review findings (eager preload, focus, dedup, icon types)

- [P1] ChatContextPicker.handleScopeChange: stop preloading workspace
  kinds at the wrapped picker root. New `isWorkspaceOnly` $derived (true
  when no Diffs/Modules/Databases branches are present) gates the at-
  root preload, so the chat root no longer fires two list requests
  before the user enters Workspace. Reported by Codex.
- [P2] AIChatDisplay @-button popover: call aiChatInput.focusInput()
  after close() so the textarea is focused for immediate typing — mirrors
  the inline-mention path's setTimeout(textarea.focus, 0). Reported by
  Claude.
- [P2] AIChatInput.insertMention: no-op when the `@title` token is
  already present in instructions, so re-picking a workspace item
  doesn't leave duplicate visible tokens for a single selectedContext
  entry. Reported by Codex.
- [P2] drillPicker.ts: introduce `DrillIcon = ComponentType |
  Component<any, {}, ''>` and replace `icon: any` on DrillLeaf,
  DrillBranch, and ChatContextPicker.buildContextBranch. Mirrors the
  ComponentType | Component pattern used in TriggersBadge.svelte for
  the same Svelte 4/5 compatibility window. Reported by Pi.

* fix(frontend): preserve workspace context on refresh + load all kinds for internal search

- [P1, Codex] ContextManager.updateAvailableContextForScript/Flow:
  preserve workspace_script and workspace_flow entries through the
  selectedContext filter on editor refresh. They're user-picked refs
  that don't appear in availableContext, so the previous filter was
  silently dropping them whenever the script/flow editor refreshed
  options (e.g. on any code change).
- [P2, cubic-dev-ai] WorkspaceItemDrillPicker: in internal-search mode
  (externalFilter === undefined, DrillPicker renders its own search
  box), preload all kinds on mount. Without this, typing in the
  picker's search before clicking a kind branch produced incomplete
  results since DrillPicker can't reach back through the adapter to
  trigger fetches on internalFilter change. Cached items keep the
  effective cost near-zero on warm sessions.

* fix(frontend): preserve workspace refs through script-mode context refresh

The script-mode updateAvailableContext overwrites newSelectedContext
with a fresh [code] entry, defeating the workspace_script /
workspace_flow preservation in the later filter — the entries are
already gone by the time the filter runs.

Seed newSelectedContext with the refreshed code block AND the user-
picked workspace_script / workspace_flow / code_piece entries from
currentlySelectedContext, so editor refreshes don't wipe @-mention
badges in script chat. The existing line-271 filter still validates
each entry against newAvailableContext + the per-type allowlist.

Reported by Codex on PR #9159 — completes the prior workspace-context-
on-refresh fix (b02d1f2d35) which only patched the filter, not the
rebuild step that runs before it.

* fix(frontend): preserve all previously-selected contexts on script refresh

The prior c2775fe0c5 fix only carried over workspace_script /
workspace_flow / code_piece entries from currentlySelectedContext.
That preserved the workspace P1 path but still dropped previously-
selected diff / error / db / runtime-context badges, which cubic
flagged in its 16:55 review.

Spread the full currentlySelectedContext (minus `code`, which we just
rebuilt). The downstream filter validates each entry against
newAvailableContext + the per-type allowlist, so auto-derived types
like diff / error / db survive when still applicable, and unrelated
items are dropped automatically.

Reported by cubic-dev-ai on PR #9159.

* fix(frontend): rehydrate auto-derived context + sync badge X with textarea

- [P2, cubic] ContextManager.updateAvailableContext: when the rebuild
  carries over previously-selected diff/error/db entries, swap each one
  for the matching freshly-built entry from newAvailableContext in the
  final .map() step. Preserves the user's `deletable` override on top
  of the fresh content/diff/schema, so refreshes don't keep stale
  payloads while still surviving the badge across edits.
- [P2, Pi/Codex] AIChatInput: new `removeMention(title)` export that
  strips `@title` tokens from `instructions` (whitespace-bounded so
  substring matches don't bleed). The badge X-button now calls it
  after filtering selectedContext, mirroring the inverse textarea-to-
  badge sync. No double-remove: ContextTextarea's $effect-driven
  onRemoveContext is a no-op once selectedContext no longer holds the
  entry.

* fix(frontend): retype ChatContextPicker.inner to DrillPicker<ChatLeafData>

`npm run check:fast` (TypeScript-only) and `npm run check` (svelte-check)
disagree on whether the imported DrillPicker is generic — `check:fast`
sees it as `Comp` and rejects the type parameter, while `svelte-check`
sees the real generic component and requires it. CI runs `check`, so
follow that: `DrillPicker<ChatLeafData> | undefined`.

This also fully replaces the prior `inner: any` workaround called out in
multiple bot reviews — handleKeydown / pickHighlighted now type-check at
the call site against the correct component instance.

* fix(frontend): scope removeMention's whitespace collapse to the mention site

The trailing `.replace(/  +/g, ' ')` in `removeMention` was global,
collapsing any pre-existing double-spaces in the prompt — e.g. a user
typing `"hello  world  @foo  bar"` lost their intentional formatting
when they deleted the `@foo` badge.

Rework the regex to match `(^|\s)@title(\s|$)` and decide per-match:
- Mention at a boundary (no lead or no trail): drop entirely.
- Mention in the middle: keep ONE bordering whitespace char (the
  leading one verbatim, so newlines/tabs aren't downgraded to spaces).

No global pass over `instructions`. Unrelated whitespace stays intact.

Reported by cubic-dev-ai on PR #9159 (07:27 review of 9e07eac4).

* fix(frontend): expose DrillPicker.onFilterChange + lazy-load workspace kinds

Both Codex P1s came from over-eager preload heuristics on my prior fixes:
the workspace picker cold-loaded every configured kind on mount in
internal-filter mode, and the chat badge popover never observed its own
internal filter so workspace results were missing from search until the
user drilled into Workspace.

Replace both ad-hoc effects with a single `onFilterChange` callback on
DrillPicker that fires whenever the EFFECTIVE filter (external or
internal) changes:

- [P1] WorkspaceItemDrillPicker: drop the "cold-load on mount when
  externalFilter === undefined" effect. Workspace kinds now load only
  once the user actually types something — closer to the pre-refactor
  behavior where the breadcrumb / "Open editor" pickers only fetched
  the drilled-into kind plus all kinds on search.
- [P1] ChatContextPicker: handleFilterChange replaces the prior
  externalFilter-only effect. Badge-popover search (internal filter)
  now triggers the same preload as inline-mention search (external
  filter), so workspace results appear without needing to drill first.

Both fixes reported by Codex on PR #9159.

* fix(frontend): skip mention-removal sync when textarea is programmatically cleared

sendRequest() sets `instructions = ''` immediately after dispatching to
AIChatManager. The mention-removal effect treated this as user-initiated
deletion and cleared selectedContext BEFORE AIChatManager.beforeSend
snapshotted it — selected `@` contexts disappeared from the outgoing
request. Skip the sync when value is empty; user-initiated mention
deletes happen in-place against non-empty content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): scope post-send wipe protection to the send path only

Replace the blanket `if (value !== '')` guard on the mention-removal
effect with an explicit `clearForSend()` export. `sendRequest()` now
calls it instead of `instructions = ''`, so a user manually clearing
the whole textarea still drops the corresponding context badges while
the post-dispatch programmatic wipe is silent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): extract useWorkspaceItemsLoader composable shared by both drill picker adapters

WorkspaceItemDrillPicker and ChatContextPicker each duplicated the same
machinery: loaded/loadingKind state seeded from the module cache, a
stale-while-revalidate ensureLoaded coroutine with an untrack guard, a
kind:/dir: scope-segment decoder, and the "load every kind once the user
starts searching" filter callback.

Move that to a single useWorkspaceItemsLoader() returning
{loaded, loadingKind, ensureLoaded, ensureAll, ensureForScopeSegment,
onFilterChange}. Adapters keep their own scope-walking policy (chat
collapses an optional 'workspace' wrapper, workspace handles single-kind
mode) but delegate kind decoding and lazy fetch to the composable.

Net: -135 +28 LOC in the two adapters; +109 LOC in the new composable.
The cache-version race, untrack discipline, and stale-while-revalidate
semantics now live in one place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): address Codex P1+P2s — non-context clear, same-title cross-removal, single-kind cold load

P1: sendRequest() now clears `instructions` unconditionally after the
optional `clearForSend()` so APP/NAVIGATOR/ASK/API modes (which don't
mount ContextTextarea) still reset the input after send.

P2: removeMention() now calls a new `unsyncMention(title)` on the
textarea before stripping `@title` from `value`, so the mention-removal
effect doesn't fire a second onRemoveContext on a same-title sibling
(e.g. workspace_script + workspace_flow sharing a path).

P2: single-kind WorkspaceItemDrillPicker loads its kind at mount even
when scope is empty — buildWorkspaceTree collapses to the kind's
children, so there's no kind row to drill into to trigger the load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:52:41 +00:00
hugocasaandClaude Opus 4.8 f2f0812a04 feat(flows): opt-in to include the stopping step's result in early-stop errors (#9446)
* feat(flows): early stop can include the stopping step's result in the raised error

When a step uses Early Stop with "Raise an error message if stopped", the
flow result was entirely replaced with a static error object
({"error": {"name": "EarlyStopError", "message": "..."}}), discarding the
stopping step's own output. This made it impossible to stop+fail a flow
while preserving the data the step produced (e.g. an API that returns
HTTP 200 with a userErrors payload).

Add an opt-in `error_include_result` flag on StopAfterIf. When enabled on
the raise-error path, the raised payload becomes
{"error": {...}, "result": <step result>} instead of dropping the result.
Default is false, so existing behavior is unchanged. The option is threaded
through the worker's stop-after-if handling (including stop_after_all_iters_if
for loops/branchall) and exposed in the flow editor's Early Stop panel.

Fixes WIN-2012

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(flows): cover early-stop error_include_result payload shaping

Add a regression test asserting that a step using Early Stop with a raised
error message and error_include_result=true fails the flow while preserving
the step output as {"error": {..}, "result": <step result>}, and that with
the flag off the result is the bare {"error": {..}} object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(flows): nest early-stop step result inside the error object

Embed the stopping step's result under `error.result` rather than as a
top-level sibling of `error`. This keeps the flow result shape as
`{ "error": { .. } }` — identical to a normal error — so consumers that
key off the top-level shape (single `error` key) keep working, while the
data is still preserved for those that look inside the error object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(flows): always include the stopping step's result in early-stop errors

Drop the opt-in `error_include_result` gate. Since the step result is nested
inside the error object (`error.result`), the top-level result shape stays
`{ "error": .. }` — identical to a normal error — so consumers that detect or
parse failures by the top-level shape are unaffected. Gating it added schema
surface, plumbing, and a UI toggle for no real compatibility benefit.

Now, whenever a step early-stops with a raised error message, the flow fails
and the raised error embeds the stopping step's own result under
`error.result` (aggregated iteration results for loops/branchall). This
reverts the `StopAfterIf.error_include_result` field, its threading, the
OpenAPI/generated-client surface, and the editor toggle; the "Raise an error
message" tooltip now notes that the step result is included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(flows): gate early-stop result inclusion behind opt-in flag

Re-introduce the per-step `error_include_result` flag (default off) instead
of always embedding the step result. Although nesting the result under
`error.result` keeps the result *shape* backward-compatible, it does not
address data exposure: a failed flow's result is propagated to synchronous
webhook callers, the flow's failure module, and the workspace/global error
handler (commonly a Slack/email/outbound-webhook notifier). Always including
the step output would surface previously-redacted intermediate data to all of
those sinks for every existing error-stop flow.

Gating keeps the existing behavior (bare `{ "error": .. }`) as the default and
only embeds `error.result` when the flow author explicitly opts in, matching
the original issue's intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(flows): omit error_include_result when false; refresh generated prompts

- Add `skip_serializing_if = "is_false"` to `StopAfterIf.error_include_result`
  so serialized flows are byte-identical when the flag is off. Fixes the
  `flowmodule_serde` round-trip test (cargo_test) and avoids churn on existing
  flows.
- Regenerate `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`
  for the new OpenFlow `error_include_result` property. Fixes check-freshness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(flows): cover error_include_result for the loop "stop after all iters" path

Add a regression test for the stop_after_all_iters_if branch, where `nresult`
already holds the aggregated iteration results — confirming `error.result`
carries each iteration's output (distinct from the per-step fallback path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:57:05 +00:00
centdixandClaude Opus 4.8 819ba5e150 fix: read latest db draft for scripts/flows in global mode read tool (#9441)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:06:05 +00:00
GuilhemandClaude Opus 4.8 e4e0984e55 feat: let flow AI chat create and edit sticky notes (#9412)
* feat: let flow AI chat create and edit sticky notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: strengthen flow AI guidance to prefer groups for organizing flows

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden flow note validation (validate position/size, document color default and group acceptance)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: make AI-created free notes draggable by seeding default position and size

Free notes need explicit geometry to be draggable/resizable in the editor; UI-created notes always set position+size but agent-created notes omitted both, so they couldn't be moved until resized. Seed defaults in validateFlowNotes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:27:01 +00:00
centdix 79178f6f5a feat: use metadata model for small AI tasks (#9431) 2026-06-03 12:13:14 +00:00
centdix 26b7270418 feat: auto-generate AI session names (#9399) 2026-06-03 10:35:03 +00:00
centdix cf5fefb521 feat: add metadata generation model setting (#9418) 2026-06-03 10:33:16 +00:00
centdixandClaude Opus 4.8 11d1ad9a87 fix: omit temperature for gpt-5+ and o-series models on all providers (#9422)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:29:21 +00:00
GuilhemandClaude Opus 4.8 1275487f02 feat: refine ask-user-question chat display and keyboard nav (#9392)
* feat: refine ask-user-question chat display and keyboard nav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: use text-accent for ask-user-question icon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: focus active choice when clicking ask-user-question card

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: disable chat input while an ask-user-question is pending

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: focus active choice on card click instead of pointerdown

Preserves text selection on the question card; wired as a use: action so the non-interactive card needs no keyboard handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: extract isActiveUserQuestion shared predicate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover isActiveUserQuestion predicate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:49:12 +00:00
centdixandClaude Opus 4.8 943ef6eb20 feat: add workspace datatable tools to global AI chat mode (#9395)
* feat: add workspace datatable tools to global AI chat mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover global-mode datatable tools pure logic

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: expose datatable SQL SDK reference via get_instructions in global mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: make datatable get_instructions language-aware, default TypeScript

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop datatable/whitelist args from global init_app tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: flag missing datatable config as an explicit blocking error in global mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop dead branch in exec_datatable_sql result handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:48:50 +00:00
centdix 5c20d6b4f7 feat: add global ai chat test tools (#9391)
* feat: add global ai chat test tools

* fix: avoid session id in flow test preview

* test: cover global flow preview ids

* test: require script and flow test tools

* fix: harden global flow test fallback

* Revert "fix: harden global flow test fallback"

This reverts commit 97254ef33a.

* fix: fallback from inactive flow test hook

* fix: list nested flow steps in errors
2026-06-01 15:43:07 +00:00
GuilhemandClaude Opus 4.7 e4213c1ab8 feat(flow-ai): constrain flow-group colors to the NoteColor palette (#9343)
The flow AI chat's set_flow_json tool lets the model set a `color` on each
semantic flow group, but nothing told it which colors are valid, so it would
sometimes emit hex codes / arbitrary CSS color names. Those render with
default styling at best and break the group color picker at worst.

- core.ts: the set_flow_json schema `.describe()` and the `groups` system-prompt
  bullet now spell out that `color` MUST be one of the palette names
  (yellow, blue, green, purple, pink, orange, red, cyan, lime, gray) — no hex,
  no CSS colors — and that omitting it lets the editor auto-assign one.
- helperUtils.ts: validateFlowGroups now rejects any color outside that palette,
  sourced from the NoteColor enum so the two can't drift.
- helperUtils.test.ts: tests for reject-unknown / accept-known / accept-omitted.

Split out of the sessions branch (gl/layout-ai), where it had been bundled
into the large feature commit; it's an independent flow-AI improvement.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:15:22 +00:00
GuilhemandClaude Opus 4.7 eadeac248b feat: sessions page with isolated AI chat + flow editor (#9034)
* 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>
2026-06-01 10:22:50 +02:00
centdix 2553fbfe31 feat: add deepseek fim support (#9365) 2026-05-28 16:52:26 +00:00
centdix 9e7eaf3684 feat: inject active editor into global chat (#9361) 2026-05-28 13:57:49 +00:00
GuilhemandClaude Opus 4.7 4efc37212a fix: infer script arg schema when deploying via AI chat (#9356)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:16:21 +02:00
centdix dec58e6c4f feat: deploy raw apps from global chat (#9349)
* feat: deploy raw apps from global chat

* fix: require raw app bundle protocol

* chore: bump ui builder artifact

* docs: explain app custom path deploy handling
2026-05-27 18:55:15 +02:00
GuilhemandClaude Opus 4.7 2f50e8bab0 feat(ai-chat): align footer bar + DropdownV2 mode/autonomy selectors (#9308)
* feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dropdown): add `selected` item prop rendering a trailing check

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): add small spacing between chat input and footer bar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai-chat): default autonomy mode to auto-accept on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): use Button component for footer dropdown triggers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): use a hand icon for the auto-accept-off autonomy state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): use subtle Button variant for mode and model selectors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): tighten spacing between input and footer bar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(button): add 2xs unified size with tighter padding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): widen the permission selector dropdown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dropdown): group shortcut + selected check to avoid ml-auto collision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:25:11 +00:00
centdix 3f219aed98 feat(ai-chat): expand chat question answers (#9310) 2026-05-25 14:52:02 +00:00
centdix 1eef53170b feat: plug global chat drafts into userdraft (#9291)
* refactor: move global chat drafts to userdraft

* feat: share script and flow drafts with editors

* feat: share trigger drafts with editors

* feat: share raw app drafts with editor

* feat: share resource drafts with editors

* docs: rename global chat drafts copy

* feat: add global chat draft discard tool

* fix: resolve global chat editor draft paths

* fix: remove editor draft path resolver

* feat: track live editor drafts in userdraft

* fix: snapshot live userdraft reads

* chore: checkpoint pending global draft changes

* fix: address global draft review issues

* fix: defer raw app draft persistence

* docs: remove pr investigation docs

* fix: persist live global draft writes
2026-05-25 14:18:57 +00:00