From eadeac248bd022c2796cfe638eb617c6143b8fc4 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 1 Jun 2026 10:22:50 +0200 Subject: [PATCH] feat: sessions page with isolated AI chat + flow editor (#9034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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', 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 * 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.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 * 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 * 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 * 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 * 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 * 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 * 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 * Revert "feat(sessions): surface local-storage drafts in fork diff & compare page" This reverts commit 3cfd858e363c5dced28cda3426f99bf2f4665e2c. * 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 * 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 * 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 * 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) * 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 * 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 * 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) * 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 * 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 * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * feat(sessions): sync preview with the deployed version on editor + chat deploy Co-Authored-By: Claude Opus 4.7 (1M context) * 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 * 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 branch gains role="option" + aria-selected to match the + {/if} - - - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 710ce9bd5d..9dc36e3503 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -81,7 +81,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import LabelsInput from './LabelsInput.svelte' @@ -134,7 +134,9 @@ onSaveDraftError, onSaveDraft, onNavigate, - disableAi + disableAi, + initialTestPanelCollapsed = false, + initialPathChosen = false }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -626,17 +628,23 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if ( + // "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the + // editor in place rather than navigating to the deployed item. + const stayHere = stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language)) - ) { + if (stayHere) { + // Re-pin parent_hash so the next deploy's conflict check is against + // the version we just wrote. script.parent_hash = newHash - sendUserToast('Deployed') - } else { - onDeploy?.({ path: script.path, hash: newHash }) } + // Always notify on a successful deploy; the consumer decides whether to + // navigate (route) or stay + sync the preview (session). Previously the + // stay/lib branch skipped onDeploy, so session previews didn't sync after + // a "Deploy & Stay here" or lib-script deploy. + onDeploy?.({ path: script.path, hash: newHash, stay: stayHere }) } catch (error) { onDeployError?.({ path: script.path, error }) sendUserToast(`Error while saving the script: ${error.body || error.message}`, true) @@ -793,6 +801,12 @@ loadingDraft = false } + // Inside an AI session pane (which injects an aiChatManager via context) the + // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace + // fork, Exit & See details, Export — don't make sense: the session always + // stays put and is already scoped to a fork. Only "Show diff" is kept. + const inSessionPane = !!getContext('aiChatManager') + function computeDropdownItems( initialPath: string, savedScript: NewScriptWithDraftAndDraftTriggers | undefined, @@ -801,26 +815,30 @@ let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ - { - label: 'Deploy & Stay here', - onClick: () => { - handleEditScript(true) - } - }, - { - label: 'Fork', - onClick: () => { - window.open(`/scripts/add?template=${initialPath}`) - } - }, - ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ...(!inSessionPane ? [ { - label: 'Edit in workspace fork', + label: 'Deploy & Stay here', onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + handleEditScript(true) } - } + }, + { + label: 'Fork', + onClick: () => { + window.open(`/scripts/add?template=${initialPath}`) + } + }, + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ? [ + { + label: 'Edit in workspace fork', + onClick: () => { + window.open(buildForkEditUrl('script', initialPath)) + } + } + ] + : []) ] : []), ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer @@ -852,7 +870,10 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.auto_kind + ...(!inSessionPane && + !script.draft_only && + script.kind === 'script' && + !script.auto_kind ? [ { label: 'Exit & See details', @@ -862,7 +883,7 @@ } ] : []), - ...(isWorkflowAsCode(script.content, script.language) + ...(!inSessionPane && isWorkflowAsCode(script.content, script.language) ? [ { label: 'Export as YAML/JSON', @@ -875,7 +896,11 @@ ] : [] - if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + if ( + !inSessionPane && + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { dropdownItems = [ { label: 'Export as YAML/JSON', @@ -901,7 +926,11 @@ } let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) + // Seed "path is already chosen" so the summary→path auto-slug (which only + // runs for new scripts with initialPath == '') doesn't clobber a path the + // caller pre-assigned. The session preview opens AI-created scripts as new + // (empty initialPath) but with a path the AI already picked. + let dirtyPath = $state(initialPathChosen) let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( (() => { @@ -2091,6 +2120,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9fe173f0ba..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,11 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -193,7 +198,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + initialTestPanelCollapsed = false }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1360,8 +1366,11 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - let rawTestPanelSize = $state(30) - let storedTestPanelSize = untrack(() => rawTestPanelSize) + // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // keeping the "remembered" size at 30, so the user's first toggle expands + // the pane to a sensible width rather than 0. + let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) ) diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..9136f52205 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' import { @@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} +
+ +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index d6b2a7818d..cd9acdbffa 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -79,20 +79,29 @@ gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -112,6 +121,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -884,6 +894,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 7a55f28860..5bbd0d17c7 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -64,7 +64,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -110,6 +110,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -130,7 +131,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -170,6 +172,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false, @@ -229,7 +239,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -313,7 +323,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -330,7 +339,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -406,7 +415,7 @@ // The initial draft was promoted to a real path on the backend — // drop the autosave keyed on the prior (possibly empty) path so // a future "+ App" click opens on a clean slate. - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -497,7 +506,7 @@ } sendUserToast('Draft saved') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) @@ -1006,7 +1015,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 02ce70f64f..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -139,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -264,10 +264,10 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref}
diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 2fe6536aa8..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,8 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 07ccd1efb1..c6366113c9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -3,30 +3,61 @@ import { untrack } from 'svelte' import { type ScriptLang } from '$lib/gen' import { dbSchemas, userStore, workspaceStore } from '$lib/stores' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() import { base } from '$lib/base' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' + let { + hideHeader = false, + hideModeSelector = false, + forceDisabled = false, + forceDisabledMessage = '', + wideLayout = false, + emptyHint, + inputPreface + }: { + hideHeader?: boolean + hideModeSelector?: boolean + // External "you can't type here" override. Used by sessions when + // the session's committed workspace was deleted/archived so the + // chat is effectively read-only until the user moves or discards + // the session. Wins over the internal disabled derivation. + forceDisabled?: boolean + forceDisabledMessage?: string + // Forwarded to AIChatDisplay. When true, the messages / input + // columns are centered in a max-w-3xl px-8 box. Sessions opt + // in; the narrow global-chat panel leaves it off. + wideLayout?: boolean + emptyHint?: import('svelte').Snippet + inputPreface?: import('svelte').Snippet + } = $props() + const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) const hasCopilot = $derived($copilotInfo.enabled) const disabled = $derived( - !hasCopilot || + forceDisabled || + !hasCopilot || (aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)) ) const disabledMessage = $derived( - !hasCopilot - ? isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' - : aiChatManager.mode === AIMode.SCRIPT && - aiChatManager.scriptEditorOptions?.lang && - !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) - ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` - : '' + forceDisabled + ? forceDisabledMessage + : !hasCopilot + ? isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + : aiChatManager.mode === AIMode.SCRIPT && + aiChatManager.scriptEditorOptions?.lang && + !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) + ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` + : '' ) const suggestions = [ @@ -53,6 +84,10 @@ aiChatManager.sendRequest(options) } + export function focusInput() { + aiChatDisplay?.focusInput() + } + const historyManager = aiChatManager.historyManager let aiChatDisplay: AIChatDisplay | undefined = $state(undefined) @@ -129,4 +164,9 @@ {disabled} {disabledMessage} {suggestions} + {hideHeader} + {hideModeSelector} + {wideLayout} + {emptyHint} + {inputPreface} > diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e2f21ce96..5b0e549dd0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -61,7 +61,7 @@ import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' -import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' +import { globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message @@ -208,13 +208,26 @@ export class AIChatManager { private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined + disabledModes: Partial> = $state({}) + // Set by AI sessions. Enables the session-only preview tools (open_preview / + // get_preview_status) and their system-prompt guidance in GLOBAL mode; the + // global side-panel chat leaves it false so those tools aren't offered. + isSessionChat = false + // The session this manager belongs to (session chats only). Carried into the + // tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS + // session rather than the UI-active one — keeps backgrounded sessions isolated. + sessionId: string | undefined = undefined + allowedModes: Record = $derived({ - script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined, - flow: this.flowAiChatHelpers !== undefined, - app: this.appAiChatHelpers !== undefined, - navigator: true, - ask: true, - API: true, + script: + this.flowAiChatHelpers === undefined && + this.scriptEditorOptions !== undefined && + !this.disabledModes.script, + flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + navigator: !this.disabledModes.navigator, + ask: !this.disabledModes.ask, + API: !this.disabledModes.API, // Dev-only gate. See `./global/gate.ts` for how to enable. global: isAIModeVisible(AIMode.GLOBAL) }) @@ -495,9 +508,11 @@ export class AIChatManager { this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) - this.systemMessage = prepareGlobalSystemMessage(customPrompt) - this.tools = [...globalTools] - this.helpers = {} + this.systemMessage = prepareGlobalSystemMessage(customPrompt, { + previewTools: this.isSessionChat + }) + this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) + this.helpers = this.isSessionChat ? { sessionId: this.sessionId } : {} } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -795,6 +810,12 @@ export class AIChatManager { } } + // Optional pre-flight hook called once per send, after validation but + // before any UI state mutates or backend calls go out. Sessions use + // this to commit/materialise the workspace (creating a staged fork via + // the API) so the first message targets the correct workspace. + beforeSend?: () => Promise | void + sendRequest = async ( options: { removeDiff?: boolean @@ -819,6 +840,24 @@ export class AIChatManager { if (!this.instructions.trim()) { return } + if (this.beforeSend) { + try { + await this.beforeSend() + } catch (e) { + // beforeSend commits the session's workspace before the first + // message hits the backend. If it throws, sending anyway would + // silently target the wrong workspace (typically the parent), so + // abort and tell the user — their message text stays in the input. + console.error('AIChatManager beforeSend hook failed', e) + sendUserToast( + `Could not prepare the session before sending: ${ + e instanceof Error ? e.message : String(e) + }. Your message was not sent — please try again.`, + true + ) + return + } + } try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 27adb56ef3..2ab480a706 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -76,7 +76,7 @@ onClick={() => onMenuOpen?.()} startIcon={{ icon: Menu }} iconOnly - > + />
{@render children?.()} @@ -96,5 +96,13 @@ {/if} {:else} - {@render children?.()} +
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index e59ed4b513..5b4258d4e7 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -3,9 +3,16 @@ import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { aiChatManager } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' import type { UserQuestionDisplay } from './shared' + // Sessions inject a per-pane `AIChatManager` via context; outside of + // sessions getAiChatManager falls back to the global singleton. Without + // this, answers clicked inside a session would dispatch to the singleton's + // pending callbacks map (which doesn't have the session manager's question + // callback), and the AI loop would stall. + const aiChatManager = getAiChatManager() + interface Props { toolCallId: string userQuestion: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 5a40c49e36..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -2,7 +2,10 @@ import { ChevronDown } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode' diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 2b9f4a09f1..56a594ff1e 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -867,6 +867,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { @@ -930,6 +931,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 563a387184..fbe07ac7b8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -200,6 +200,9 @@ markRemovedAsShadowed?: boolean controlsPosition?: 'top' | 'bottom' outerDivClass?: string + /** Fires when the computed graph height changes. Diff views can use + * this to equalize heights of side-by-side graphs. */ + onHeight?: (height: number) => void } let { @@ -273,7 +276,8 @@ onMoveMultiple = undefined, movingIds = undefined, controlsPosition = 'top', - outerDivClass = '' + outerDivClass = '', + onHeight = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -759,6 +763,7 @@ const computed = maxBottom - minY height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight) } + onHeight?.(height) } $effect(() => { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7249f0ae05..8121ca8b70 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -66,6 +66,9 @@ } | undefined diffDrawer?: DiffDrawer | undefined + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void + /** Fired after a successful deploy; the session preview reloads on it. */ + onDeploy?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -75,6 +78,14 @@ * preference. */ sidebarStorageKey?: string liveEditorDraftStoragePath?: string + /** Initial value for the "Split with Preview" tab-bar toggle. Defaults + * to `true` (split mode, preview always pinned to the right). Set + * `false` when the editor mounts inside a context that wants single- + * view by default with the Preview tab selected — e.g. session + * previews, where the editor pane is already narrow. The user can + * still toggle the mode after mount; this prop only seeds the + * initial state. */ + defaultSplitWithPreview?: boolean } let { @@ -88,9 +99,12 @@ newPath = undefined, savedApp = $bindable(undefined), diffDrawer = undefined, + onNavigate, + onDeploy = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', - liveEditorDraftStoragePath = undefined + liveEditorDraftStoragePath = undefined, + defaultSplitWithPreview = true }: Props = $props() export const version: number | undefined = undefined @@ -225,7 +239,9 @@ } let tabs: TabItem[] = $state([previewTab]) let activeTabId: string = $state(PREVIEW_TAB_ID) - let splitWithPreview: boolean = $state(true) + // Seed from the prop, then own the state locally so the user's toggle + // after mount sticks even if the prop reference changes. + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -255,11 +271,23 @@ const showRunnable = $derived(activeTabKind === 'runnable') // Mount the UI Builder iframe the first time a file is shown (paneA has // width then; mounting it at 0-width breaks the VS Code workbench), and - // keep it mounted so tab switches don't reload it. + // keep it mounted so tab switches don't reload it. Mount it as soon as + // either pane needs it: `showSource` for the source-editor view, OR the + // preview tab is active — the Preview iframe is fed by `preview` + // postMessages bundled by the UI Builder iframe, so it needs to be + // mounted even when the user opens the editor straight on Preview (e.g. + // session previews seeded with `defaultSplitWithPreview=false`). let iframeShouldMount = $state(false) $effect(() => { - if (showSource) iframeShouldMount = true + if (showSource || activeTabKind === 'preview') iframeShouldMount = true }) + // Width of the editor area (both inner panes). The UI Builder iframe is + // pre-mounted while it's the inactive tab so the editor is ready instantly; + // but the VS Code workbench inside crashes if it boots at 0 size. So while + // inactive we keep the iframe at this real width and hide it with + // `visibility` instead of collapsing it — Monaco boots correctly and + // revealing a file is just an unhide (no reload, no relayout, no latency). + let editorAreaWidth = $state(0) // Inner pane sizes are a pure function of mode + active tab → derived. // `paneARatio` is the user's last manual split drag (set by rememberPaneDrag). @@ -994,7 +1022,11 @@ ensureFileTab(selectedDocument) // Don't auto-activate — the user's tab choice wins. // But if no file tab is currently active, fall in line. - if (activeTabKind === 'preview' && tabs.length === 2) { + // Skip this auto-activation in single-view-with-preview + // mode (the caller seeded `defaultSplitWithPreview=false` + // because Preview is the intended starting tab); the + // iframe's first setActiveDocument shouldn't fight that. + if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) { activateTab(id) } } @@ -1158,9 +1190,14 @@ }) }) - // Open a default file on mount (boots the iframe; avoids a blank preview). - // Layout isn't persisted — each open starts fresh in split mode. + // Open a default file on mount (boots the iframe in split mode and gives + // the user something to edit on the left). When the caller seeded + // `defaultSplitWithPreview=false` we instead want the Preview tab as the + // only-visible / active surface, so skip the file-tab activation — the + // iframe still boots via `populateFiles`/`setFilesInIframe` even without + // a selected document. onMount(() => { + if (!splitWithPreview) return if (tabs.length === 1) { const def = pickDefaultFile(files) if (def) activateTab(ensureFileTab(def)) @@ -1332,6 +1369,8 @@ {data} {runnables} {getBundle} + {onNavigate} + {onDeploy} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1415,6 +1454,7 @@ Preview previously hid every tab. -->
-
+ +
{#if iframeShouldMount}