Commit Graph
5 Commits
Author SHA1 Message Date
Ruben FiszelandClaude Opus 4.8 c91027824b feat(pipeline): AI-chat data-pipeline editor (route + in-session) + home surfacing (#9805)
* feat(pipeline): AI chat tools to build pipeline nodes with diff/approval

Add a data-pipeline AI chat experience modeled on the flow editor and
surfaced through the dev-gated global chat (no new chat panel).

The /pipeline editor registers PipelineAIChatHelpers on the AIChatManager;
while it is open the global mode layers pipeline tools, a pipeline prompt
section, and the helpers on top of the full global tool set (behavior is
unchanged when no pipeline editor is open).

New tools (frontend/src/lib/components/copilot/chat/pipeline/core.ts):
- get_pipeline_graph / read_pipeline_node — read the live graph and bodies
- build_pipeline_node / edit_pipeline_node — stage changes as AI-pending drafts
- remove_pipeline_node — drop a staged proposal
- test_pipeline_node — preview-run a node (requires confirmation)

Tools never deploy: they stage drafts flagged aiPending, rendered on the
canvas with an accent ring and reviewed via Accept all / Reject all (the
flow editor's GlobalReviewButtons). Accept commits the drafts; Reject reverts
to a pre-AI snapshot, preserving earlier accepted drafts. Auto-accept is gated
on the chat autonomy mode.

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

* feat(pipeline): teach the global/session chat to author data pipelines

Without an open /pipeline editor the session chat had no pipeline concept, so
"create a data pipeline" loaded flow instructions and built a flow. Add a
first-class pipeline authoring path:

- system_prompts/base/pipeline-base.md — what a data pipeline is (a DAG of
  annotated scripts wired by storage assets, NOT a flow) and how to author the
  // pipeline / // on / // materialize annotations; wired through generate.py as
  getPipelinePrompt() (regenerated prompts.ts/index.ts).
- global/core.ts — new get_instructions subject "pipeline", and a global-prompt
  rule disambiguating data pipelines from flows so the model routes correctly.
- ai_evals/cases/global.yaml — two global cases (single node, two-node chain)
  asserting pipeline-annotated script drafts and forbidding write_flow, guarding
  the pipeline-vs-flow conflation.

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

* feat(pipeline): show & build pipelines in the AI session preview

Add a 'pipeline' session preview target so the session AI can show the
data-pipeline graph for a folder and build nodes in-pane:

- open_preview now accepts kind="pipeline" (path = folder); SessionTarget /
  EDITOR_TARGET_KINDS widen accordingly. The slot/codec load model stays
  flow|script|raw_app — pipeline bypasses it with its own fetch/draft state.
- New PipelineEditorView.svelte mounts in the session pane: fetches the
  folder graph, overlays AI drafts, renders AssetGraphCanvas + the
  Accept/Reject review buttons, and registers PipelineAIChatHelpers on the
  *session-scoped* manager (via getAiChatManager) so build_pipeline_node /
  edit_pipeline_node + the diff/approval work inside the session too.
- System prompt nudges the model to open the pipeline preview and use the
  staging tools while building.

Verified end-to-end with a real model: the session AI called open_preview,
the graph mounted in the side panel, then build_pipeline_node staged a node
on the session canvas with its schedule trigger and ducklake output.

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

* refactor(pipeline): share the AI editor logic between route page and session

Consolidate the duplicated data-pipeline AI logic onto a single shared layer so
the route editor and the in-session preview behave identically and the session
gains the full code editor.

- New pipelineAiHelpers.ts: createPipelineAiHelpers(deps) owns the propose/edit/
  remove/accept/reject/test staging + the per-turn snapshot bookkeeping that
  powers Reject. Callers inject accessors for their own draft Map and graph.
- Route page (/pipeline/[folder]) drops its ~250-line inline AI-helper block and
  wires the shared factory via deps (folder/workspace/graph/drafts + focus,
  ensureEditable, run-started). Its shell — persistence, navigation guard,
  activity, cascade, trigger drawers — is untouched.
- Session PipelineEditorView uses the same factory and now renders the real
  AssetGraphDetailsPane (code editor + live overlays + test), so a node built in
  a session opens with its source, matching the route editor.

Verified: route page hydrates/renders drafts unchanged; in a session the AI
opened the pipeline preview, built a node, and its code showed in the details
pane. check:fast clean, 197 unit tests pass.

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

* refactor(pipeline): externalize editor state into PipelineEditorState (step 1)

Introduce PipelineEditorState — the data-pipeline analogue of the flow editor's
flowStore. It owns the draft Map, the live editor overlays, and the selection,
with callback-safe methods (handleDraftPersist / handleAnnotationsChange / … ),
so a single editor can be rendered by both the route page and the session.

This commit lands the store and points the in-session PipelineEditorView at it
(no behaviour change — the session already had these inline). Next steps move the
route page onto the store and a shared <PipelineGraphEditor>.

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

* refactor(pipeline): point the route editor at PipelineEditorState (step 1)

Move the route page's draft Map, live editor overlays, selection, and the
draft-persist / live-change handlers onto the shared PipelineEditorState (`pe`),
referencing them as `pe.*` in place. No behaviour change — persistence, graph
resolution, run dispatch, AI staging, and deploy all stay on the page and now
read/write the externalized state.

This is the data-pipeline analogue of the flow editor's flowStore: the route
page and the in-session preview now share one source of editor truth, setting up
the shared <PipelineGraphEditor> in the next steps.

Verified: the page hydrates its DB draft, renders the overlay graph, the toolbar
counts (Save all (N)) track pe.drafts, and selecting a node opens it in the
details pane. check:fast clean, 84 unit tests pass.

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

* refactor(pipeline): render the route editor via shared PipelineGraphEditor (step 2)

Extract the canvas + details-pane editor body into PipelineGraphEditor.svelte,
the data-pipeline analogue of FlowBuilder. The route page now delegates its
Splitpanes block to it, passing the externalized PipelineEditorState plus its
run/cascade/trigger/deploy callbacks; the component owns pane sizing,
selection/details-open derivation, and the canvas+details rendering.

Root-caused the earlier ts2769 "$props() No overload" to a prop named `state`
colliding with the `$state` rune (`let x = $state(...)` parsed as a store
auto-subscription on the prop) — the prop is now `editor`.

Net: the route page sheds ~310 lines of template/state; behaviour preserved.
Verified: the page hydrates its DB draft, renders the graph, opens the draft in
the details pane (live code editor + Test), pane sizing works. check:fast clean,
24 pipeline tests pass.

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

* refactor(pipeline): move draft autosave into PipelineGraphEditor (step 3)

Fold the per-user `data_pipeline` DraftService bundle autosave (hydrate +
debounced persist + localStorage crash mirror) into PipelineGraphEditor, gated
by a `persistDrafts` prop — FlowBuilder's parameterized-autosave shape. The route
page passes `persistDrafts` + `folder` and reads `editor.loadedFromDbDraft` for
its AutosaveIndicator; the in-session preview will leave persistence off.

Also restores the `untrack(...)` wrapping on the pane-sizing $effect (dropped
when the editor body was extracted in step 2). Without it the Pane `bind:size`
feedback loops the effect and pegs the main thread when the details pane is
closed — a latent hang in the step-2 commit.

check:fast clean, 24 pipeline tests pass. Note: browser revalidation was not
possible this session (the Playwright MCP browser was reset); the autosave is a
verbatim port and the untrack fix is the original working form.

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

* refactor(pipeline): render the session preview via shared PipelineGraphEditor (step 4)

Point the in-session PipelineEditorView at the shared PipelineGraphEditor instead
of its own inline canvas + details pane. The session now renders the exact same
editor body as the route page — gaining the full details/code pane — while opting
out of persistence (persistDrafts=false) and the run/cascade/trigger/bounded
affordances (their callbacks are omitted, so those controls hide). Building nodes
+ the Accept/Reject diff still work via the AI helpers.

Also fixes issues surfaced by a full `svelte-check` while wiring this up:
- PipelineGraphEditor: edit mode opened the details pane unconditionally (a step-2
  regression); restored the route's "open only on selection/draft" behaviour.
- Route page passed an `isOperator` prop the component doesn't accept (step-2;
  caught only by full check, not check:fast).
- SessionItemNotFound: narrow its `kind` to exclude `pipeline` (pipeline targets
  never slot-load, so they can't 404 through it) — closes the SessionTarget-widen
  fallout.
- PipelineEditorView: cast the resolveGraph base to AssetGraphResponse.

Full `svelte-check` now clean across all pipeline/session files; 137 unit tests
pass. (Browser revalidation still pending — Playwright MCP was unavailable this
session; see the smoke-test note on the PR.)

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

* fix(pipeline): stop an infinite microtask loop when persisting a no-output draft

handleDraftPersist short-circuits when the open draft's content + inferred writes
are unchanged. The writes check compared `d.outputAssets?.length === writes.length`,
but a no-output draft has `outputAssets: undefined` (so `?.length` is `undefined`)
while the details pane infers an empty `writes: []` (length 0). `undefined === 0`
is false, so it never short-circuited: every persist re-wrote the drafts Map with
an equivalent object, which gave `activeDraft.script` a new identity → the pane
re-emitted its overlays → the graph re-derived → persist fired again. A self-
sustaining microtask loop that pegged the renderer and froze the tab on any
pipeline carrying a no-output draft (e.g. hydrating one from the saved
data_pipeline draft on load). It hangs rather than throwing effect_update_depth_
exceeded because it cycles across microtasks, not within one reactive flush.

Fix: coalesce the undefined length to 0 so "no outputs" compares equal to an empty
inferred-writes list. Adds pipelineEditorState.test.ts covering the idempotency
(fails without the fix) plus the change/no-change cases.

Root-caused by instrumenting the reactive churn: every iteration reassigned
drafts/liveContent/liveBodyAssets/liveAnnotations/displayGraph with identical
values — pure reference churn off the drafts re-write.

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

* fix(ai-chat): make the agent open the pipeline editor before building nodes

In a session, the GLOBAL system prompt only *advised* opening the pipeline preview
("show its graph with open_preview ... prefer those tools once it is open"), so
the agent routinely skipped it: on a plain "build a data pipeline" request it
reached for write_script and staged plain script drafts, and the canvas editor
never opened. build_pipeline_node / edit_pipeline_node are only registered once
the preview is open, so skipping open_preview also loses the canvas-staged
Accept/Reject diff-approval flow entirely.

Make the guidance imperative: open_preview(kind="pipeline", path=<folder>) is the
FIRST step before creating any node (an empty or not-yet-created folder is fine —
create_folder first if needed), and pipeline nodes go through build_pipeline_node
/ edit_pipeline_node, never write_script. This also clears the agent's "the folder
might not exist" hesitation that pushed it toward write_script.

Verified live (same plain prompt, before/after): before it used write_script with
no editor; after, the agent opens the editor first and stages a canvas-highlighted
node with Accept all / Reject all. The guidance is gated on previewTools
(session-only), so it doesn't affect the non-preview global eval cases.

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

* fix(pipeline): preserve in-session pipeline drafts across editor hide/show

The session preview's PipelineEditorState lived in the PipelineEditorView
component with persistDrafts=false. Hiding the editor sets editorVisible=false,
which makes `hasEditor` false and the `{#if hasEditor}` block unmount the view —
discarding its component-local store. Showing it again remounted a fresh, empty
one, so the pipeline the AI had built in the session vanished.

Move the PipelineEditorState onto the per-session SessionRuntime (like the flow /
script / raw_app editors, which already host their state there and take {runtime}),
so it survives the pane unmount on hide and across session switches. The runtime
is keyed by session id and only dropped on session deletion.

Because the instance is now reused, guard against a retarget to a different
folder: PipelineEditorView resets the state when `path` changes to a new folder
(a same-folder remount keeps the drafts). Adds `folder` + `reset()` to the store.

Verified: build a node in a session → Close editor → Show editor → the staged
node, its wiring, the details-pane code, and Accept/Reject all re-appear. Full
svelte-check clean; 139 pipeline tests pass.

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

* feat(pipeline-ai): clearer diff + persistent review banner on the canvas

The AI review affordance had two problems on the pipeline canvas:

- The floating Accept-all / Reject-all bar sat bottom-center, where it
  collided with the minimap once the canvas narrowed on node selection —
  reading as "the buttons vanished when I select a node".
- Every staged draft rendered with the same blue ring, so it wasn't clear
  what the review would actually change (a plain manual draft looked the
  same as an AI proposal).

Replace the floating bar with a top-left review banner (z-30, clear of the
controls and minimap) that stays put regardless of selection and spells out
the pending counts. Color the diff per node: a proposal that adds a node
that isn't deployed rings green with a "new" chip; one that edits an
already-deployed node rings amber with an "edited" chip. Plain manual
drafts keep the neutral gray dashed border, so only the green/amber nodes
read as part of the Accept/Reject set.

aiPendingKind is resolved in resolveGraph (deployed runnable present →
modified, else added) and forwarded through the canvas to the node.

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

* fix(pipeline-ai): persist in-session pipeline proposals across reload/switch

Staged AI proposals lived only in the per-session runtime's in-memory
PipelineEditorState (persistDrafts=false), so a page reload — and an
LRU-evicted runtime on session switch — dropped them, leaving the canvas
and the Accept/Reject review empty even though the chat still showed the
nodes as staged.

Enable the same per-folder DB-draft persistence the route page uses for the
in-session editor. To keep hide/show cheap and race-free, hydration is now
gated per editor instance (PipelineEditorState.hydratedFromDb) rather than
per component mount: the runtime-hosted instance hydrates ONCE when fresh
(reload / evicted runtime) and then keeps its in-memory drafts across the
editor pane unmounting on hide — re-reading the DB on every remount would
race a not-yet-flushed autosave and drop a just-staged draft. A folder
retarget resets the flag so the new folder re-hydrates.

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

* fix(pipeline-ai): make Reject all work for rehydrated proposals

rejectAll only reverted paths tracked in the in-memory aiSnapshots map,
which is rebuilt empty on each editor mount. After a reload (or session
switch into a fresh runtime) the proposals are restored from the persisted
draft but have no snapshot, so Reject all was a no-op on exactly the nodes
it should discard. Sweep any still-pending draft without a snapshot and
discard it (revertPath with no snapshot deletes the path; for an edit of a
deployed node that correctly falls back to the deployed body). Adds unit
coverage for accept/reject including the no-snapshot case.

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

* fix(pipeline-ai): keep proposals visible while the graph reloads on switch

The session editor pane is LRU-capped (MAX_WARM_EDITORS), so returning to a
session whose pane was evicted remounts PipelineEditorView with a fresh
graphRes resource (loading=true, current=undefined). The deployed-graph
loading spinner gated the whole canvas, so the staged proposals and the
Accept/Reject review banner vanished until the re-fetch resolved — read as
"the proposal disappears when I switch sessions".

Only show the loading/error placeholder when there are no drafts to display.
When the runtime already holds staged drafts, render the editor immediately:
resolveGraph overlays them on an empty base so the proposals + banner stay
visible, and the deployed nodes fill in when the fetch completes. Verified
with a 4s-delayed graph fetch — proposals render through the load with no
spinner.

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

* refactor(pipeline-ai): apply AI node edits directly as drafts, no approve/reject

The canvas-level Accept all / Reject all review (aiPending proposals, the
green/amber diff ring + "new"/"edited" chips, and the review banner) didn't
fit the pipeline editor. Match the flow/script editor instead: build/edit
apply directly as ordinary unsaved drafts on the canvas, which the user then
deploys — there is no separate approval step.

Removed across the surface:
- aiPending / aiPendingKind on the runnable node + resolveGraph seeding +
  canvas forwarding; AI-built nodes now render with the existing plain
  unsaved-draft dashed styling.
- the review banner, count derivations, and hasAiPending/onAccept/onReject
  props from PipelineGraphEditor and both consumers (route page + session
  view).
- acceptAll/rejectAll/hasPending and the per-turn snapshot bookkeeping from
  the shared helpers; removeProposedNode now just discards the unsaved draft
  at a path (undo a build). acceptAllProposals/rejectAllProposals/
  hasPendingProposals dropped from the PipelineAIChatHelpers interface and
  the manager's auto-accept hook.
- accept/reject language from the tool descriptions, return messages, and the
  system-prompt section.

Tests updated; pipeline + AssetGraph suites pass (142).

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

* fix(drafts-diff): support data_pipeline diffs + fix blank empty-summary row

Two issues in the session "Drafts" diff drawer (DraftDiffDrawer):

- Clicking a `data_pipeline` bundle row threw "Draft diff not supported for
  kind data_pipeline" (utils_draft_deploy.ts) — there was no handler for the
  kind, so it fell to the OVERLAY_GETTERS lookup and errored. The bundle has
  no deployed counterpart (each node deploys individually as a script), so
  diff it node-by-node: surface each node's draft body keyed by path, folding
  in the deployed body as the "before" when a node edits a deployed script.

- A draft row whose summary is an empty string (e.g. the app draft) rendered
  with no title at all: WorkspaceItemRow's single-line branch used
  `summary ?? secondary`, and `??` doesn't treat '' as absent, so it showed
  the empty summary instead of the path. Use `||` so an empty summary falls
  back to the path.

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

* feat(drafts-diff): explode data_pipeline bundle into per-node subitems

A data_pipeline draft is a bundle of node-script drafts, so a single row
diffed the whole thing as one blob. Explode it in DraftDiffDrawer into one
script row per node, nested under the bundle's `…/data_pipeline` folder so
they read as the pipeline's subitems — each with its own path and a proper
script Content/Metadata code diff. The node's draft body is the "after"; its
deployed body (when the node is already deployed) is the "before", so edits
show as line diffs and new nodes as added. A single bundle row (via the
getDraftDiffValues data_pipeline fallback) is kept only for the case where
the bundle can't be read.

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

* refactor(pipeline-ai): simplify — drop vestigial approve/reject scaffolding & redundant field

Review pass over the PR, removing complexity left from the approve/reject
removal and the shared-component refactor (all behavior-preserving):

- Inline the `acceptPendingEdits` pass-through into `acceptPendingFlowEdits`
  and revert the now-inert `autoAcceptEditsAvailable` GLOBAL+pipeline widening
  (pipeline edits are direct drafts — nothing to auto-accept).
- Fix the global system prompt: pipeline tools "apply directly as unsaved
  drafts (no accept/reject)", not "proposals the user Accepts or Rejects".
- Collapse the redundant `outputAsset` (singular) into `outputAssets`,
  removing a whole resolveGraph fallback tier; simplify propose/editNode.
- Drop the single-field `PipelineAiHelpersHandle` wrapper (callers just
  destructured `{ helpers }`); inline the misleading `isoNow()` helper.
- Remove the now-unreachable `data_pipeline` branch in getDraftDiffValues
  (the drafts drawer explodes bundles per-node; an unreadable bundle is
  skipped) and the "Step N consolidation" drafting narration.
- Un-export internal-only types; reuse `storageKey`; refresh stale comments
  that still referenced proposals / the review banner / diff-approval.

svelte-check clean; 141 unit tests pass.

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

* feat(pipeline): tooltip clarifying the Create/Save button deploys

The accent button in the asset-graph details pane ("Create" for a new script,
"Save" for an existing one) is really a deploy, but had no tooltip explaining
that. Add a title — "Deploy this new script to the workspace" / "Deploy your
changes to this script" — keeping the create-vs-update label distinction while
making clear both deploy.

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

* docs(pipeline-ai): document the `materialize` annotation in the pipeline prompt

The model invented "materialize run" because the prompt only mentioned
`// materialize <uri>` in passing. Spell out what it is in both the in-app
pipeline prompt (getPipelinePromptSection) and the base prompt
(pipeline-base.md, regenerated): a MANAGED output where the runtime writes the
table around a single SELECT (no manual CREATE/INSERT); replace (default) vs
`append` vs `key=<col>` strategies; `manual` to opt out (track-only); and its
pairing with `// partitioned …` (runs once per partition, `{partition}` token
substituted at run time). Explicitly: materialize is an output declaration,
not a command — there is no "materialize run".

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

* feat(pipeline-ai): trigger drawers in the AI session preview

Bring the route page's native-trigger affordances to the in-session pipeline
editor by reusing the shared <PipelineTriggerEditors> (no duplication of the
drawer UI). Clicking a "Schedule · Missing — no trigger row" node (or
edit/delete on an attached trigger, webhook, data-upload) now opens the same
drawers the full editor uses, instead of doing nothing. Draft nodes get the
same "save the script first" guard (a trigger row needs a deployed script).

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

* feat(pipeline-ai): run buttons + live run state in the AI session preview

Wire the per-node Run button and live run-state badges into the in-session
pipeline editor, reusing the shared folder-scoped job poll
(useActiveRunnableIds) the route page uses — node badges, the event log, and
the zero-latency "running" hint all come from it. The session runs one node at
a time (preview for an unsaved draft, the deployed version otherwise),
skipping the route page's cascade/deploy-queue machinery the AI-session UX
doesn't need. Verified: a node's Run button dispatches a job and the badge
updates live from the poll.

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

* feat(pipeline): label the node deploy button "Deploy" (was Create/Save)

Users read "Create" and asked whether it deploys. It does — and the main
script editor's DeployButton already says "Deploy", so this is the consistent
term. Use "Deploy" for both the new-script and existing-script cases; the
new-vs-changes nuance stays in the button's tooltip.

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

* feat(home): surface data pipelines as units, including bundle-phase drafts

Treat a data pipeline as one home entry instead of scattering its member
scripts:

- The home "Pipeline · f/<folder>" entry now also covers bundle-phase
  pipelines — a folder that so far only exists as a `data_pipeline` draft —
  not just deployed ones, so a pipeline shows up the moment its first node is
  drafted (union listPipelineFolders + data_pipeline draft folders).
- Pipeline-member scripts (`auto_kind='pipeline'`) are filtered out of the
  individual scripts list; they're represented by their pipeline's entry.
- Tree view injects pipeline folders so they (and their "Pipeline" entry)
  still appear when their only scripts are hidden members or they have none
  deployed yet.

Verified in both list and tree view: app_groups (deployed member folded) and
a draft-only nyc_transit both show as pipelines; the member script no longer
lists individually.

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

* feat(scripts): compute auto_kind for draft-only pipeline nodes

A never-deployed pipeline node (a script draft starting with `// pipeline`)
had no script row, so list_scripts synthesized it with `auto_kind: None` — and
the home page therefore couldn't tell it was a pipeline member, listing it
individually instead of folding it into its pipeline. Parse the draft content
the same way the create path does (`parse_pipeline_annotations(...).in_pipeline`)
and set `auto_kind = "pipeline"` on the synthesized draft-only row, so draft
nodes fold into their pipeline like deployed members.

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

* feat(search): hide pipeline-member scripts from global search

The Ctrl+k global search listed pipeline-member scripts (`auto_kind='pipeline'`)
individually. Filter them out — they're reached through their pipeline, matching
the home page. Deployed members carry auto_kind from the script row; draft-only
members now do too (computed from draft content in list_scripts), so both are
excluded here.

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

* fix(pipeline): address PR review findings

Session run dispatch (the one real bug):
- runNode now passes `_wmill_skip_asset_dispatch: true` for a single-node run
  of a deployed node unless the user chose "run + downstream" (cascade) —
  previously a single Run could fan out to downstream deployed scripts via the
  backend asset dispatcher and fire side-effecting production runs.
- onRunProducer guards `kind === 'script'`; onTestStateChange only clears the
  run hint for the script the pane finished (not a different in-flight node);
  clear the hint on folder retarget; gate the background poll on isActiveSession
  so hidden warm panes don't poll; note the PipelineTriggerEditors workspace
  coupling.

Home page pipeline surfacing:
- Fold pipeline-member folders into `pipelineFolders` (captured in loadScripts)
  so a members-only / draft-only-`// pipeline` folder still shows its pipeline
  entry instead of vanishing; and don't render the empty-state when only
  pipelines remain (they aren't part of the text filter).
- Insert injected tree folders in name order instead of prepending.

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

* docs(pipeline-ai): make clear `// materialize` is DuckDB + DuckLake only

The model put `// materialize` on a python3 node, which deploy rejects ("only
supported for DuckDB scripts"). The prompt only implied SQL ("write the body
as a single SELECT") without stating the hard constraint. Spell it out in both
the in-app prompt and pipeline-base.md: `// materialize` is DuckDB-only and its
target must be a DuckLake table; for python3/bun/postgresql nodes, write the
output via the SDK instead and let it be inferred — reach for duckdb when a
node should materialize a DuckLake table.

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

* docs(pipeline-ai): fix stale comment — session now wires run + trigger affordances

Addresses review: the comment still claimed the session 'opts out of the
run/cascade/trigger/bounded affordances', but run buttons + trigger drawers
were wired in. Describe the current state (wires run + triggers; omits only
cascade/bounded/add-script).

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

* fix(pipeline): address Codex review — test_pipeline_node dispatch + tree search

- [P1] testNode (the test_pipeline_node tool) ran a deployed node via
  runScriptByPath without `_wmill_skip_asset_dispatch`, so previewing one node
  could fan out to downstream deployed subscribers and run side-effecting
  scripts. Add the skip flag (test is always single-node) + a regression test.
- [P2] Home tree view injected pipeline folders — and rendered their Pipeline
  row — even during a text search, surfacing unrelated pipelines. Gate both the
  TreeViewRoot injection and TreeView's hasPipeline on `!isSearching`, matching
  the list view which hides pipeline rows on a query.

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

* fix(pipeline-ai): keep the pipeline prompt after update_user_instructions

rebuildGlobalSystemMessage (called by the update_user_instructions tool)
rebuilt only the base Global prompt, dropping the pipeline-editor section that
configureGlobalMode appends. So after the chat remembered an instruction, the
next GLOBAL turn lost the active /pipeline/<folder> context + direct-draft/
materialize guidance while pipeline tools stayed registered. Re-append the
pipeline section here when a pipeline editor is registered.

Addresses Codex review [P2].

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

* fix(home): gate pipeline entries by kind/archived/owner filters

Codex review [P2]: pipeline rows/folders rendered independently of the item
filters, so a pipeline still showed under the Flows/Apps tabs, in the archived
view, and outside a selected owner. Add `visiblePipelineFolders` applying the
same gates the items get (kind ∈ {all, script}, not archived, owner-prefix
match) and route the list rows, tree injection, and empty-state check through
it. Pipelines are always `f/<folder>`, so the user-folder toggle and kind=script
keep including them.

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

* fix(pipeline): address review — route folder-switch state, AI node guards, diff identity

claude[bot] [P1]: the route page's in-app folder switcher navigates same-route
(no remount), but nothing reset PipelineEditorState — so folder A's drafts
displayed under B and autosave persisted them into B's bundle, and B never
hydrated. Reset pe on folder change (mirror the session retarget), and guard the
shared hydrateDrafts against a stale folder result landing after a retarget.

codex/claude [P2]: build_pipeline_node (proposeNode) only checked drafts.has —
now rejects a path outside the open folder and one colliding with an existing
deployed node (model should edit_pipeline_node). + 3 regression tests.

codex/claude [P2]: exploded pipeline-node diff rows shared `script/<path>` with a
standalone script draft at the same path, colliding in the {#each} key + value
cache. Add an explicit unique `key` (the distinct bundle-nested path) on DiffRow;
pipeline nodes set/look up by it while `path` stays the real edit target.

claude [P2]: session AI test_pipeline_node now arms the live run badge
(onRunStarted), matching the route page.

nit: pipelineAiHelpers.test uses afterEach(restoreAllMocks) instead of an
unreachable inline mockRestore.

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

* fix(pipeline): harden AI node mutations + close home label-filter / rename gaps

Codex [P1] (AI mutations trust model paths) — fully scoped now:
- editNode validates the open folder too (proposeNode already did), via a shared
  assertInFolder; an edit_pipeline_node for f/other/* no longer persists an
  unrelated script into the current folder's data_pipeline bundle.
- both build_pipeline_node and edit_pipeline_node now require the `// pipeline`
  annotation (assertPipelineAnnotation) so a staged draft is definitionally a
  pipeline member, not a silently-non-member script. + tests.
  (proposeNode's folder + deployed-collision guards landed in the prior commit.)

Codex [P2] home label filter — visiblePipelineFolders ignored labelFilter, so a
label selection still showed every pipeline (and the empty-state fell through to
render pipeline rows). Pipelines carry no labels, so a label filter hides them.

Codex [P2] session rename — PipelineEditorView now wires onScriptRenamed
(repoint selection + refetch), matching the route page; a persisted-script
rename no longer leaves the canvas on the old path.

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

* docs(pipeline-ai): language-specific comment prefix for annotations

Codex [P2]: the tool schema and prompt told the model to write `// pipeline` /
`// on` / `// materialize` regardless of language, and pipeline-base.md grouped
SQL with `#`. A `//` (or `#`) annotation line is invalid in a DuckDB/Postgres
node — it passes the frontend parser (which strips `//`/`--`/`#`) but is a SQL
syntax error at deploy/run. Make the guidance language-specific everywhere:
`--` for SQL (duckdb/postgresql), `#` for python3/bash, `//` for bun/TS — the
`//` in examples is the TS form to translate. Regenerated the prompt outputs.

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

* fix(pipeline-ai): re-scope Global prompt on folder switch + language-aware base prompt

Codex [P2] x2:
- The route page resets editor state on an in-app folder switch, but the Global
  chat's system message kept the old `/pipeline/<folder>` scope (the helper
  methods read the reactive folder, but the prompt string is only rebuilt on
  Global-mode reconfigure). Rebuild it on folder change so the next turn targets
  the new folder.
- The pre-editor base Global prompt (seen before open_preview/get_instructions)
  still showed TS-only `// pipeline` / `// on`. Make it language-aware (`--` SQL,
  `#` Python/Bash, `//` TS) so the model can't draft invalid DuckDB/Postgres
  nodes before the pipeline tools are registered.

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

* fix(pipeline-ai): authoritative new-node probe + SQL-correct eval checklist

Codex [P2] x2:
- build_pipeline_node's collision check relied on the resolved graph, which can
  be empty while the session preview races open_preview (a build could shadow a
  deployed node before the graph loads) and only covered pipeline runnables, not
  a non-pipeline script at the same path. Add an authoritative backend probe
  (ScriptService.getScriptByPath): any deployed script at the path → reject with
  "use edit_pipeline_node". + regression test (empty graph, deployed script).
- The DuckLake eval judgeChecklist required the exact `// pipeline` annotation,
  which would penalize the now-correct `-- pipeline` SQL output (or reward
  invalid DuckDB syntax). Make both cases syntax-aware.

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

* fix(pipeline-ai): rebuild Global prompt on session preview folder retarget

Codex [P2]: open_preview(kind="pipeline", path="B") can retarget an existing
pipeline preview from folder A to B without remounting. The retarget effect
resets editor state and the helper methods read the new path, but the
registration effect only depends on isActiveSession, so the Global system
message stayed scoped to /pipeline/A. Mirror the route-page fix: rebuild the
global system message on retarget (gated on isActiveSession — only the active
session's helpers are registered; a hidden session reconfigures when it next
becomes active).

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

* fix(pipeline-ai): edit_pipeline_node preserves deployed script metadata

Codex [P1]: editNode kept only the deployed script's language and staged a fresh
makePipelineScript draft with empty hash/summary/description/tag/schema/settings.
Deploying that edit from the pane (auto_parent) would update the script while
wiping its metadata, and the route "Save all" path (no parent_hash) could hit
the backend path-conflict branch on the occupied path. Base the draft on the
existing draft's / deployed script object and replace ONLY content (+ inferred
output assets), preserving hash and metadata. + regression test.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:33:50 +00:00
GuilhemandClaude Opus 4.8 e98df38ac4 feat(apps): show raw-app fork diffs as per-file tree items (#9491)
* feat(apps): show raw-app fork diffs as per-file tree items

Raw-app diffs previously rendered as one big YAML diff of the whole
serialized app. This explodes a raw app into separate, independently
collapsible diff items — one per file, one per runnable, and an
app.yaml metadata item — that flow through the existing fork-diff
list, sidebar tree, search and count via composite paths
(<appPath>/<file>). Runnables render as script/flow rows (code shown
in a Content tab), and files get extension-specific icons reused from
the raw-app editor.

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

* chore: remove raw-app tree-diff plan doc from the branch

The implementation plan was an authoring aid, not product documentation; drop it so it doesn't ship in the PR.

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

* feat: present raw app as an app-headed folder in the diff tree

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

* fix: narrow RawAppFileItem in diff viewer branch (fixes svelte-check)

DiffRow.kind is a plain string so the kind check didn't narrow the union; assert the synthetic item. Also size-guard on the larger side's line count instead of the doubled total, and document normalizeRawApp's per-field value-wrapper precedence.

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

* style: single-line, lighter diff-tree rows for all item kinds

Add a singleLine mode to WorkspaceItemRow (summary ?? path on one line; DRY'd via a shared body snippet) and use it for every diff-tree leaf, so scripts/flows/triggers/resources/etc. match the raw-app header. Bump rows to py-1.5, force font-normal, and split colours: items in text-primary, folders in text-secondary.

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

* refactor: extract pure diffTree model from WorkspaceDiffDrawer

Move tree construction + keyboard-nav traversal + the folder-keying convention out of the 775-line component into a pure, generic, tested module (buildDiffTree → root/order/parentKeyOf/firstChildKeyOf). Parent and first-child come from a child→parent map built during construction, not from re-splitting a path at the call site, so a node's tree position and its nav parent can't drift — the class of bug behind the ArrowLeft regression. Deletes the forkDiffNav half-seam (its bug lived in the untested caller). 12 new unit tests cover order/parent/first-child incl. the storage-key-vs-friendly-path case.

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

* fix(apps): keep raw-app metadata flag + dedup runnables across path collisions

Addresses two P2 review nits (Codex/claude): (1) rawAppDiffToItems marked metadata by matching path==='app.yaml', so when a real file is named app.yaml the reserved app.yaml~2 metadata item lost its flag/full-YAML toggle — now parseRawAppDiff tags the entry with isMetadata and the items read the flag; (2) runnable composite leaves weren't deduped against real files, so a real file at runnables/<name> could produce a duplicate leaf — now reserved (slash-normalized) like parseRawAppDiff. +2 tests.

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

* fix(apps): dedup /app.yaml metadata collision + disambiguate synthetic row keys

Two follow-up P2s from Pi/Codex re-review of the prior fix: (1) parseRawAppDiff's collision set used raw file keys, so a real file /app.yaml (leading slash, which joinAppPath strips) still collided with the synthetic app.yaml leaf — now slash-normalized via a shared stripLeadingSlash, +test. (2) synthetic raw-app items (runnables rendered as script/flow) could share kind+path identity with a real workspace script/flow at <appPath>/runnables/<name>, causing duplicate {#each} keys and broken nav — itemKey now prefixes synthetic items (rawapp:).

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

* fix(apps): canonicalize raw-app file keys to dedup leading-slash collisions

Codex P2: a file keyed /App.tsx on one side and App.tsx on the other became two entries that joinAppPath collapsed to one composite path → duplicate row key. asFileMap now strips the leading slash so both sides resolve to one file. +test.

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

* perf(apps): lazy-mount per-file diff editors as they scroll into view

Exploding a raw app into N per-file rows mounted N Monaco DiffEditors at once (3 reviews flagged it). Each block's editor now mounts only when it scrolls within ~200px of the viewport (IntersectionObserver rooted on the scroll container), showing a light placeholder until then; mountedRows latches so it never unmounts on scroll-away. Verified: ~6 of 13 mount initially, the rest on scroll.

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-24 09:23:14 +02:00
GuilhemandClaude Opus 4.8 e4bfeb29bc fix(frontend): persist session-editor draft path/summary edits + per-line diff tooltips (#9622)
* fix(frontend): persist raw-app draft path edits in the session editor

Renaming a raw app's path in the session preview editor never triggered a draft
save: the header surfaced the typed path as `pendingDraftPath`, but
RawAppEditorView ignored it (it never reached runtime.rawApp.val), and the
RawAppDraft codec didn't serialize a path field — so the autosave signature
(JSON.stringify(draft)) was unchanged and nothing was written. The rename was
lost and the home/review/Drafts lists kept the original `draft_path`.

- appDraftCodec: make `draft_path` a real draft + runtime field, serialized by
  runtimeRawAppToDraft and round-tripped by applyDraftToRuntimeRawApp, so a path
  change moves the sig and fires a save.
- sessionRuntime.loadRawApp (+ inline rawApp.val type): seed `draft_path` from
  the loaded draft so it survives reloads.
- RawAppEditorView: bind the header's `pendingDraftPath`, mirror it into
  runtime.rawApp.val.draft_path (guarded so the initial undefined can't clobber
  the seed or fire a spurious save), and seed the path widget from
  `draft_path ?? path`. Mirrors the full-page /apps_raw/edit route.
- appDraftCodec.test: add a draft_path round-trip test.

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

* fix(frontend): per-line tooltips in workspace item diff rows

The summary line now shows the full summary on hover and the path line the
full path, instead of one row-level title surfacing the path everywhere.

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

* fix(frontend): persist flow/script draft path edits in the session editor

The session sync dedups on a per-kind signature that omitted the path, so a
rename never moved the signature and never autosaved. Add path/draft_path to
the flow signature, and derive draft_path in the script codec (scripts bind
the Path widget to script.path directly) so the rename both autosaves and
shows the typed name in the home/Drafts lists. Mirrors the raw-app fix.

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

* fix(frontend): commit editor summary edits live instead of on blur

EditableInput only fired onSave on Enter/blur, so the summary in the shared
editor header only updated when the field lost focus. Add an opt-in
commitOnInput that fires onSave per keystroke and enable it for the header
summary, so flow/script/raw-app summaries autosave as you type.

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

* fix(frontend): preserve renamed never-deployed script path on session re-seed

loadScript seeded a draft-only script's baseline path from the storage key, so
re-running it with the draft still in memory (e.g. a script→script switch)
reset the path to draft_<uuid> and the next autosave dropped draft_path,
clobbering the rename. Seed from the draft's own draft_path/path instead.

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

* fix(frontend): clear raw-app draft_path when the path rename is reverted

The mirror effect only ever set draft_path, so reverting/clearing the path
field left a stale friendly name in the draft (persisted by the codec and shown
in the home/Drafts lists). Track whether a real typed path was surfaced so a
revert clears draft_path while the initial pre-bind undefined still can't
clobber the loadRawApp-seeded value. Mirrors the script codec's drop-on-revert.

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 23:19:12 +02: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
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