From 95031903ebe223dc03b49a6bcd3e4ee67cefc4bb Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 7 Jul 2026 14:27:00 +0200 Subject: [PATCH] feat(sessions): v2 unified sidebar with family/fork scoping and preview router (#9816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sessions): prototype session-mode layout wrapper (design exploration) Do not merge — design exploration of an optional full-page 'session mode' layout for AI sessions. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): add full-screen toggle for the session panel Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): workspace-tree rail with browse mode and collapsible sidebar Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): restore sessions page with iframe preview of current view Roll back the session-mode layout wrapper: sessions is a dedicated /sessions page again rather than a layout toggled over the live app. Opening a session from a Windmill page captures that page as the session's preview target; the page shows the chat beside a preview panel that iframes the target, with a breadcrumb and full-screen toggle. - Remove SessionShell wrapper and the sticky sessionLayout flag; +layout.svelte always renders the normal global sidebar. Sidebar components introduced alongside the wrapper are kept for the upcoming sidebar rework. - sessionMode.svelte.ts: per-session preview-URL map (captureSessionView / sessionPreviewUrl) + withMenuHidden to drop the previewed page's own sidebar via the nomenubar flag. - Drop the #content sidebar gutter (pl-12/pl-40) when the menu is hidden, so the nomenubar preview fills the panel edge-to-edge. - SessionPicker: activate() navigates to /sessions; createAndOpen() seeds the new session's preview from the current page. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): add exit (X) button to chat header Add a close button at the top-right of the session chat header that leaves the sessions page and navigates to the session's target (the previewed page), so exiting lands on exactly what was being previewed, full-screen with the sidebar. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sidebar): promote workspace picker and widen the sidebar Replace the Windmill logo header with the workspace picker so the active workspace is the sidebar's anchor: show the workspace name (not the id) in a stronger weight, with a down-chevron and a bottom-aligned dropdown. Add the same dropdown chevron to every other sidebar menu trigger (Favorites, User, Settings, secondary/Help groups) via an opt-in MenuButton option, and widen the expanded sidebar from w-40 to w-48 (content offset kept in sync). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): collapsible preview panel + sidebar session entry polish Add a collapse control to the sessions preview panel (top-left, matching the legacy editor's PanelRightClose), animated with an x-axis slide. The panes carry no explicit size so Splitpanes auto-distributes — the chat fills the width when the preview collapses and splits evenly when both are shown. When collapsed, a floating "Open side panel" Button (top-right) brings it back. Also gather the AI sessions section into the Favorites/Search container via a new embedded mode on SessionPicker, replace the small "+" with a full sidebar "New AI session" entry, and drop the chat header's exit (X) button. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): split family/fork picking with a global breadcrumb Separate workspace-family selection from fork selection. The sidebar workspace picker now lists families (roots) only and shows the active family name even inside a fork. A persistent `family · fork` breadcrumb lives in the global logged layout (WorkspaceBreadcrumb, rendered via a new AiChatLayout topBar snippet): the fork segment opens the fork picker popover, staging a pending fork on a draft session (the old in-chat SessionWorkspaceBar semantics) or switching workspace directly elsewhere. WorkspaceFamilyPicker gains onRequestCreateFork to route create-fork to the global fork modal in non-session contexts. Co-Authored-By: Claude Opus 4.8 (1M context) * revert(sessions): drop the global fork breadcrumb top bar Remove WorkspaceBreadcrumb and its AiChatLayout topBar wiring; restore the in-chat SessionWorkspaceBar for draft fork-picking and the original WorkspaceFamilyPicker. The sidebar workspace picker stays family-only (roots, no forks listed). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): family/fork-scoped sidebar with scope header Restructure the sidebar into a family-scoped region (workspace family header → New AI session → session list) and a workspace-scoped region (a Fork scope header → Favorites + Search → workspace items), split by a full-width divider. The new WorkspaceScopeHeader is a full-width root/fork picker: accent-styled on a fork (text + faded border), with a bottom " settings" link; picking a different fork from a session navigates home. The family header keeps the root's color when inside a fork, and drops "Fork current workspace" / "Workspace settings" (now surfaced via the scope header and the bottom Settings dropdown). The session preview header shows "family · fork ". Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): drop colon from "Workspace root" scope label Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): turn the preview breadcrumb into a page router Every breadcrumb segment now opens a drill picker that lists workspace pages (Home, Runs, Workspace settings, …) alongside scripts/flows/apps. Picking either steers the preview iframe without leaving the sessions page. The non-item case resolves to the page's real name (e.g. "Workspace settings"). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): tabbed preview with mounted tabs + Home quick-access The preview is now a tiny tabbed browser: the first tab is pinned to the session's view, "+" opens the router picker to add more, and every tab stays mounted (stacked + visibility-toggled) so switching preserves each page's state. Per tab, the commanded `url` is decoupled from the observed `loc` so in-iframe navigation never reloads the frame. Home is also pulled up as the first quick-access item in the router picker. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): persist preview tabs with the session in IndexedDB Save the open preview tabs (+ active tab) onto the session record so reopening a session restores its tabs. Write-behind is debounced since a tab's observed location churns as the user browses; transient (unsent) sessions skip it. Co-Authored-By: Claude Opus 4.8 (1M context) * perf(sessions): lazy-mount preview tab iframes Only boot a tab's iframe the first time it's activated, then keep it mounted. Restoring a session with N saved tabs now boots just the active tab instead of N full Windmill apps at once. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): fold the breadcrumb picker into the preview tabs Drop the separate family·fork/path breadcrumb bar. The active tab now doubles as its own router picker (click it to re-point the tab); inactive tabs switch on click. Removes the now-unused PreviewRouterSegment. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(raw-apps): auto-compact the editor when it opens narrow On the first measured layout, if the editor container is under 800px, drop to the merged single-pane view and retract the file sidebar (e.g. when shown in the narrow session preview pane). Applied once on open; the sidebar is set without persisting so it never overrides the user's saved preference. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): auto-refresh preview tabs after mutating chat tools Add a tool-completion hook in the shared chat dispatcher; the sessions page subscribes and debounced-reloads every mounted preview tab when a write/deploy/ delete tool finishes (matched by verb prefix, so read/test/navigate tools skip). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): keep nav bar hidden across in-preview navigation The sessions preview iframes load pages with `nomenubar=true`, but the layout recomputed `menuHidden` from the current URL on every navigation, so a client-side nav inside the preview (an in-page link or redirect) dropped the flag and the global nav popped back in. Make the hidden state sticky for the document's lifetime when running inside an iframe; the top window is unaffected so the oauth-callback toggle still works. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): persist hidden nav across full reloads in preview iframe The in-memory sticky flag was lost on a full document load inside the preview (a navigation that drops the `nomenubar` query param), so the global nav — including the mobile burger — reappeared. Store the sticky state in sessionStorage so it survives full reloads within the iframe's browsing context. The top window is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): refuse to mount sessions UI inside a preview iframe A preview tab navigating back to /sessions would mount another sessions page with its own preview iframes, nesting endlessly. When the page detects it is running inside an iframe, render a stub that breaks out to the top-level window instead of mounting the full UI. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): Workspace ⇄ AI Sessions mode switch + workspace-decoupled session chat Add a route-derived mode switch that flips the sidebar rail between the classic workspace navigation and a dedicated AI-sessions sidebar, cleanly separating sessions from the workspace nav. - SessionModeSwitch (Workspace | AI Sessions) in the rail; session mode is exactly "on /sessions", so the switch just navigates in/out (sessionSwitch). - Session chats target their own (possibly forked) workspace via AIChatManager.operatingWorkspace/workspaceResolver without mutating the global workspaceStore; "Acting on" header strip shown once a session has started. - Flow editor AI button becomes "Open in AI session": saves the draft, then opens a new session targeting the current flow. - New sessions: no default preview (empty state instead of iframing home, panel collapsed); preview-panel collapse persisted per-session on the record. - Persist nav-rail collapse (manual toggle only) and drop the editor-route auto-collapse that fought it. - Smaller fork picker; add a `preview` proxy so `vite preview` reaches the backend for production-build demos. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ai-chat): replay assistant turns verbatim so thinking blocks validate The global AI chat reconstructs each assistant turn from an OpenAI-shaped message, keeping only the thinking/redacted_thinking blocks and re-injecting them at the front of the content array. When a turn interleaves thinking with the native web_search tool and ends in a tool call, this reorders the thinking blocks and drops the server_tool_use / web_search_tool_result blocks. Anthropic validates each thinking block's signature against the blocks that precede it in the latest assistant message, so the replayed turn is rejected: 400 invalid_request_error "messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response." Preserve the full `finalMessage.content` verbatim (`_anthropicContent`) and re-emit it unchanged, instead of extracting and reordering thinking blocks. Skip the standalone text message that the streamer emits for the same turn (its text is already inside `_anthropicContent`). The previous thinking-only path is kept as a fallback for sessions persisted before this change. Co-Authored-By: Claude Opus 4.8 * fix(sessions): give empty-state preview picker its own open state * feat(sessions): render preview editors as components, not iframes Introduce a PreviewTabHost seam that routes each preview tab to either an in-process editor (the session's script/flow/raw_app target, reusing the existing *EditorView wrappers + shared runtime) or an iframe fallback for pages and other items, behind a uniform reload(). resolvePreviewTab classifies a tab from its URL + the session target. Also intercept in-iframe navigation to an editor route (logged layout beforeNavigate): post the target up to the sessions page, which promotes the active tab to the live editor component, so an editor is never booted inside an iframe. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): drive open_preview tool through the multi-tab model Co-Authored-By: Claude Opus 4.8 (1M context) * docs: plan SessionPreviewTabs deep module for sessions preview tabs Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(sessions): own preview tabs in a SessionPreviewTabs deep module Collapse the three drifting preview-tab copies (page-local state, session record, legacy previewUrls localStorage) into one live owner held on SessionRuntime.previewTabs. Both the sessions page (renderer) and the open_preview/get_preview_status tools cross it, so both sync effects and the localStorage seed disappear; url/target writes become atomic. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): gate the Workspace/Sessions switch behind the global-AI dev flag The SessionModeSwitch is the only entry point into the AI-sessions experience, so gate it on wm_dev_global_ai like the global chat and the sessions page — otherwise the unfinished mode ships to prod. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): pin the settings footer and normalize row text in the fork dropdown Split the family picker menu into a scrollable body + a pinned settings footer so the workspace-settings link stays visible while the fork list scrolls. Give every row a uniform text-primary font-normal style (rows were inheriting a bold 600 weight; the settings link was text-secondary). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sidebar): fold theme switch into the settings dropdown and keep it in session mode Move the Switch-theme toggle into the sidebar Settings dropdown and reorder its entries (bottom-to-top: Instance, Workspace, User). The dropdown now renders in both navigation and session modes; session mode hides only the workspace-settings entry (the rail's global workspace doesn't map to a session's forked workspace). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(fork): validate fork name/id length before creation Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): title open-in-workspace button "Open in workspace" Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): keep AI chat working when the sessions dev flag is off Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): match burger drawer width and keep it open on mode toggle Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): surface the dev-workspace badge across session workspace pickers Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): dedup navigate, sanitize hydration, cap mounted tabs Co-Authored-By: Claude Fable 5 * feat(fork): support forks of forks via a base-workspace picker Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sidebar): family expansion, pinned menu actions, animated popovers Co-Authored-By: Claude Fable 5 * feat(sessions): persist unsent drafts, gate preview, loading state Co-Authored-By: Claude Fable 5 * fix(sidebar): group fork picker on top and unfold the session list Co-Authored-By: Claude Fable 5 * fix(sessions): capture splitter pointer so off-window release ends drag Co-Authored-By: Claude Fable 5 * refactor(sessions): retire the pinned preview tab (dot and no-close) Co-Authored-By: Claude Fable 5 * feat(sessions): shared open-in-AI-session button across editors Co-Authored-By: Claude Fable 5 * fix(sessions): dedup page tabs, flush on hide, review cleanups Co-Authored-By: Claude Fable 5 * feat(sessions): give unsent drafts a side panel, reset tabs on retarget Co-Authored-By: Claude Fable 5 * fix(sessions): keep the session fork icon neutral except when detached Co-Authored-By: Claude Fable 5 * fix(sessions): scope session-mode restore and transient reuse to family * fix(sessions): preserve session mode across workspace switches Co-Authored-By: Claude Fable 5 * fix(sessions): reconcile open session with family on workspace switch Co-Authored-By: Claude Fable 5 * fix(sessions): lazy-load runtime in session switch to keep it node-testable Co-Authored-By: Claude Fable 5 * feat(sidebar): add bottom brand mark and standalone workers/logs links * feat(sidebar): add name+id copy tooltip to workspace picker * style(sidebar): add spacing between settings and brand mark * feat(forks): id-based fork creation, fork color theming, picker polish * feat(forks): copy-id in session header, inert chip, fork form polish * fix(sidebar): restore logs, help, user and leave-workspace menus * feat(sidebar): carry active tick on collapsed family root * feat(dev): add settings-menu kitchen sink page * fix(sessions): fail closed for unbound persisted sessions in family scope * fix(sidebar): keep workspace URL param in sync across switches * feat(sessions): remove home page from preview tab navigation * refactor(sidebar): dedupe shared helpers and address review findings * feat(sessions): keep preview hosts alive across session switches * feat: workspace settings links in session rail, acting badge and family picker Co-Authored-By: Claude Fable 5 * fix: refresh session changes bar after out-of-window deploy The session "Edits" bar re-fetched its draft list and existence checks only on AI turn-end, tab visibilitychange, and drawer open. Deploying an item from a full-page editor in a second browser window left the bar stale: that tab never goes hidden, so visibilitychange never fires, and the badge kept reading "1 draft" while opening the drawer showed no pending change. Add a window `focus` listener alongside visibilitychange so returning to the session window re-syncs the bar, and refresh the dock when a badge is clicked so the drawer always opens on fresh state. Co-Authored-By: Claude Fable 5 * feat(sessions): show name/id copy tooltip on acting badge, drop inline copy * fix: keep editor header cloud indicator visible at narrow widths Co-Authored-By: Claude Opus 4.8 * fix(sessions): scope preview reload to the mutated item Reloading every mounted preview tab on any mutating chat tool blank- rebooted unrelated raw-app previews: a raw app that isn't the session's live-editor target renders as an /apps_raw/edit iframe, and reloadAllTabs hard-reloaded it (frame.location.reload) on every write/deploy elsewhere. Pass the tool args through the completion listener and scope the reload: an item-route iframe reloads only when its item was actually touched. The changed item is args.path for workspace-path tools; the raw-app file tools (write_app_file, …) pass a leading-'/' frontend file path and edit the active session's target app, so scope to the target; anything else is unresolved and reloads everything (safe fallback). Changed paths accumulate across the 500ms debounce. Non-item pages still always reload; live-editor slots still no-op. Co-Authored-By: Claude Opus 4.8 * fix(diff): honor side-by-side/unified toggle and widen draft drawer Monaco forces inline view below its 900px renderSideBySideInlineBreakpoint, which overrode our SIDE_BY_SIDE_MIN_WIDTH gate and made the toggle a no-op in the ~800px draft drawer. Disable useInlineViewWhenSpaceIsLimited so our width logic wins, and widen the drawer default 1200->1500px. Co-Authored-By: Claude Fable 5 * fix(diff): vertically center the element-header icon with its path The path renders as ExternalEditLink's inline-flex in production, which sat ~2px low on the wrapper's line-box baseline. Make the path wrapper flex+items-center so the icon and path align by box. Co-Authored-By: Claude Fable 5 * fix(diff): reflect the auto-unified downgrade in the drawer view toggle The side-by-side/unified downgrade lived inside each DiffEditor's width gate, so the drawer toggle still showed side-by-side when the narrow column rendered inline. Measure the diff column, make the drawer authoritative (force inline when narrow), and reflect it in the toggle (unified selected, side-by-side disabled) while preserving the user's preference for when it widens again. Shared SIDE_BY_SIDE_MIN_WIDTH via diffEditorTypes. Co-Authored-By: Claude Fable 5 * feat(sidebar): make the nav rail resizable with rem scaling Co-Authored-By: Claude Opus 4.8 * fix(diff): gate Monaco auto-inline behind a prop to keep narrow diffs unified Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ui): restore instant popover/dropdown default, opt sidebar and sessions in Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sidebar): restore delete-forked-workspace action in the settings menu Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tooltip): add cursor anchoring option and use it for the name/id tooltip Co-Authored-By: Claude Opus 4.8 (1M context) * chore(dev): remove settings-menu kitchen sink scaffolding Co-Authored-By: Claude Opus 4.8 (1M context) * chore(docs): remove session-preview-tabs owner plan doc Co-Authored-By: Claude Opus 4.8 (1M context) * chore(dev): drop vite preview-server proxy scaffolding Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sidebar): scroll nav as one block with fade hints, pin settings to bottom Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sidebar): guard against concurrent pointer drags leaking resize listeners Co-Authored-By: Claude Opus 4.8 (1M context) * fix: scope session preview, LLM proxy, and raw-app workspace switch Address PR review findings: session preview iframes, the AI chat LLM proxy client, and the raw-app workspace-switch guard all now resolve the session's effective workspace instead of the global navigation workspace. - withMenuHidden appends the session workspace as ?workspace= so preview iframes render fork-scoped pages against the fork, not the nav workspace. - AIChatManager builds the proxy clients from operatingWorkspace so the LLM request hits the session workspace's /ai/proxy, not the global singleton (init'd only on global workspace changes). - workspaceSwitchUrl adds /apps_raw/edit|get to EDIT_PAGES so switching workspace from a raw-app editor/viewer goes home like other item pages. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: scope session model, preview picker, and open-in-workspace to session Second-layer workspace-scoping fixes from PR review: three more paths resolved the global navigation workspace instead of the session's effective workspace. - SessionWrapper loads copilot config (models/providers) for the session's acting workspace, so getCurrentModel/modelProvider match the workspace the chat writes to, not the nav workspace. - PreviewRouterPicker takes a workspaceId prop; the sessions page passes the session's effective workspace so the breadcrumb/+ picker lists fork items and its drafts, not the nav workspace's. - 'Open in workspace' appends ?workspace= via the new withWorkspaceParam so the full-page link opens the active preview under the session workspace. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): only the active session loads global copilot config Follow-up to the session-workspace copilot fix: SessionWrapper's loadCopilot effect ran in every warm/hidden wrapper, and since copilotInfo/copilotSessionModel are global, a background session in a different workspace could finish loading after the active one and leave the active chat on the wrong provider/model. Gate the load on currentSessionId so only the active session writes the shared config. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): guard copilot load race + scope app handoff to workspace Two more session-vs-navigation workspace fixes from PR review: - loadCopilot now applies only the most recent call's result via a monotonic token, so a stale async load from a just-switched-away session can't clobber the active session's global model/provider config. - navigateEditorTo carries the session workspace on the low-code app handoff (goto /apps/edit) so the app opens in the fork the session acts on, not the navigation workspace. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): scope live-editor breadcrumb picker to session workspace The session preview's live script/flow/raw-app editors mount with a session workspaceId, but their EditorHeader breadcrumb picker (WorkspaceItemDrillPicker) still loaded items and drafts from the global navigation workspace. Thread an optional workspaceId prop from each builder's autosaveWorkspace through EditorHeader -> BreadcrumbSegment -> WorkspaceItemDrillPicker; it falls back to $workspaceStore, so non-session editors are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(sessions): fix stale setSessionTabs transient-persistence comment Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): scope live-editor deploy/save/triggers to session workspace The session preview's live script/flow/raw-app editors load and autosave against the session's acting workspace, but their internal deploy, save-draft, trigger-loading, fork-eligibility, worker-tags and live-editor-draft operations read $workspaceStore directly. Since a session deliberately leaves $workspaceStore on the navigation workspace, a fork-scoped session deployed/saved to the wrong workspace (verified: deploy POSTed to the nav workspace and 400'd). Introduce an opWorkspace derived (autosaveWorkspace ?? $workspaceStore) in each builder and route the operation reads through it. autosaveWorkspace is only set by the session editor views, so opWorkspace equals $workspaceStore for every non-session editor — no behavior change outside sessions. Verified in-browser: a fork-session deploy now POSTs to the fork (201 Created) while a normal editor still targets the navigation workspace. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): staged pending fork chip uses default accent, not parent's color A staged pending fork's effective workspace resolves to its parent (setSessionPendingFork sets pending_workspace_id = parent_workspace_id), so WorkspaceScopeTrigger read the parent workspace's color and painted the 'Acting on' chip in the parent's hue (e.g. yellow) instead of the neutral fork accent. A real fork shows its own color; a not-yet-created one has none, so fall back to the default fork accent unless the creation form passes an explicit color preview. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): scope raw-app deploy/save/version to session workspace The raw-app create/update/version/diff/save operations live in RawAppEditorHeader (not RawAppEditor), and still read $workspaceStore — so a fork-scoped session's raw-app deploy targeted the navigation workspace, the same class of bug already fixed for scripts and flows. Route those operation reads through opWorkspace (autosaveWorkspace ?? $workspaceStore); the inSessionPane-guarded draft-cleanup blocks are intentionally non-session and keep $workspaceStore. Verified in-browser: a fork-session raw-app deploy POSTs update_raw to the session fork (200). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): key live-editor load cache on workspace, not just path The script/flow/raw-app loaders returned early when loadedPath matched the requested path, ignoring the workspace. Retargeting a session to the same item path in a different fork kept the old workspace's loaded content while the editor props switched to the new workspace — so save/deploy/autosave could write stale old-workspace content into the new fork. Add loadedWorkspace to the load slot and include it in the early-return guard so a same-path/different-workspace retarget reloads. Verified in-browser: the script re-fetches from the new fork on an acting-workspace switch. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): drop stale content when a live editor retargets to a new workspace Follow-up to keying the load cache on workspace: the loaders reloaded on a workspace retarget but did not clear loadedPath during the fetch, so SessionEditorTarget's loadedPath-keyed ready/notFound/stale gates still treated the editor as ready on the old workspace's content — the outbound draft sync (now wired to the new workspace) could write stale content into the new fork, and a 404 kept rendering the old editor. Clear loadedPath on a workspace change too (like a force reload), so the loading/not-found gates and the draft-sync ready check resolve correctly. Same-workspace path swaps are unaffected (loadedWorkspace still matches, so the old editor stays visible during the swap). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): await committed-workspace copilot config before a session send getCurrentModel() reads the global copilotInfo when the request builds, but SessionWrapper's loadCopilot for the active session is fire-and-forget — so a send right after switching to a session in another workspace could pick the previous workspace's provider/model while the proxy clients and tools target the new workspace. Track the workspace copilotInfo reflects (copilotWorkspace) and, in the session beforeSend hook (awaited before the request builds), load the committed workspace's config when it doesn't already match. Verified in-browser: sending a session committed to a workspace whose copilot config wasn't yet loaded fires get_copilot_info for it just before the LLM proxy call. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): navigate to a fresh session on reset; dedupe preview page tabs Two review findings: - resetToNewSession (deleting/archiving the open session) and the sidebar delete of the active last session created/selected a fresh session but left the URL on the old session_name. The page derives the visible session from that query, not currentSessionId, so it showed the deleted session's not-found state (or stayed on the archived one). Navigate to the fresh session, matching how activate()/enterSessionMode already switch sessions. - Preview page-tab dedupe: the iframe reports its location with the injected nomenubar/workspace params, but tabs dedupe the observed loc against the workspace-less canonical url, so reopening a page spawned a duplicate tab. Canonicalize the observed loc in observeLocation (dropping both params); covered by a new sessionPreviewTabs test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): don't persist preview-iframe workspace; scope fork ducklakes to base Two review findings: - A sessions-preview iframe runs the logged layout, which persisted its ?workspace= (the session's fork) to localStorage — shared with the top-level app, so opening a fork preview clobbered the navigation workspace and reloads restored into the fork. Skip the persist when embedded; $workspaceStore is still set in-memory for the iframe's own API calls. Verified: opening a fork /runs preview leaves localStorage.workspace on the top-level workspace. - ForkDucklakeSection listed ducklakes from $workspaceStore while a fork-of-fork is created from the selected base, so it could show the root's lakes and submit shared_ducklakes the base doesn't have. Add a sourceWorkspace prop (base ?? $workspaceStore) like ForkDatatableSection, and pass baseWorkspaceId at the mount. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): keep preview iframe on session fork across reloads and open-in-workspace Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): scope worker-tag pickers to the session's effective workspace Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/aiStore.ts | 15 + .../lib/components/BreadcrumbSegment.svelte | 14 +- frontend/src/lib/components/DiffEditor.svelte | 17 +- frontend/src/lib/components/DropdownV2.svelte | 4 +- .../src/lib/components/EditorHeader.svelte | 10 +- .../src/lib/components/FlowBuilder.svelte | 85 +- .../src/lib/components/FlowDiffViewer.svelte | 4 + frontend/src/lib/components/PageHeader.svelte | 20 +- .../src/lib/components/PrefixedInput.svelte | 181 ++-- .../src/lib/components/ScriptBuilder.svelte | 78 +- .../src/lib/components/ScriptEditor.svelte | 71 +- .../src/lib/components/WorkerTagPicker.svelte | 38 +- .../src/lib/components/WorkerTagSelect.svelte | 44 +- .../components/WorkspaceItemDiffViewer.svelte | 17 +- .../WorkspaceItemDrillPicker.svelte | 13 +- .../components/WorkspaceScopeTrigger.svelte | 205 +++++ .../apps/editor/AppEditorHeader.svelte | 33 +- .../common/button/CopyButton.svelte | 44 + frontend/src/lib/components/common/index.ts | 2 + .../common/tooltip/NameIdTooltip.svelte | 50 ++ .../components/copilot/chat/AIButton.svelte | 20 +- .../lib/components/copilot/chat/AIChat.svelte | 9 +- .../copilot/chat/AIChatDisplay.svelte | 9 +- .../copilot/chat/AIChatInput.svelte | 11 +- .../copilot/chat/AIChatManager.svelte.ts | 38 +- .../copilot/chat/AiChatLayout.svelte | 25 +- .../src/lib/components/copilot/chat/shared.ts | 16 +- .../src/lib/components/diffEditorTypes.ts | 5 + .../lib/components/flows/FlowEditor.svelte | 4 + .../content/FlowModuleWorkerTagSelect.svelte | 30 +- .../flows/content/FlowSettings.svelte | 9 +- .../flows/map/FlowModuleSchemaMap.svelte | 4 + .../flows/map/FlowStickyNode.svelte | 33 +- frontend/src/lib/components/flows/types.ts | 4 + .../lib/components/meltComponents/Menu.svelte | 20 +- .../components/meltComponents/Popover.svelte | 4 +- .../components/meltComponents/Tooltip.svelte | 53 +- .../components/raw_apps/RawAppEditor.svelte | 50 +- .../raw_apps/RawAppEditorHeader.svelte | 106 ++- .../components/raw_apps/RawAppFileDiff.svelte | 4 + .../sessions/OpenInSessionButton.svelte | 61 ++ .../sessions/PreviewRouterPicker.svelte | 192 ++++ .../components/sessions/PreviewTabHost.svelte | 105 +++ .../sessions/SessionChangesBar.svelte | 29 +- .../sessions/SessionEditorTarget.svelte | 10 +- .../sessions/SessionFilterMenu.svelte | 18 +- .../sessions/SessionModeSwitch.svelte | 55 ++ .../components/sessions/SessionPicker.svelte | 559 ++++++++---- .../sessions/SessionStatusDot.svelte | 11 +- .../sessions/SessionWorkspaceBar.svelte | 52 +- .../components/sessions/SessionWrapper.svelte | 432 +++++---- .../sessions/WorkspaceDiffDrawer.svelte | 47 +- .../sessions/WorkspaceFamilyPicker.svelte | 502 ++++++----- .../components/sessions/previewRouter.test.ts | 78 ++ .../lib/components/sessions/previewRouter.ts | 106 +++ .../components/sessions/sessionMode.svelte.ts | 50 ++ .../components/sessions/sessionMode.test.ts | 63 ++ .../sessions/sessionPreviewTabs.svelte.ts | 321 +++++++ .../sessions/sessionPreviewTabs.test.ts | 411 +++++++++ .../sessions/sessionRuntime.svelte.ts | 145 ++- .../sessions/sessionState.svelte.ts | 215 ++++- .../components/sessions/sessionState.test.ts | 144 ++- .../sessions/sessionStateIndexedDb.test.ts | 92 +- .../sessions/sessionSwitch.svelte.ts | 97 ++ .../components/sessions/sessionSwitch.test.ts | 96 ++ .../settings/ChangeWorkspaceColor.svelte | 37 +- .../settings/ChangeWorkspaceName.svelte | 65 +- .../sidebar/DeleteForkedWorkspaceModal.svelte | 207 +++++ .../components/sidebar/FavoriteMenu.svelte | 14 +- .../lib/components/sidebar/MenuButton.svelte | 26 +- .../components/sidebar/SettingsMenu.svelte | 378 ++++++++ .../components/sidebar/SidebarContent.svelte | 828 +++++++----------- .../sidebar/SidebarScrollArea.svelte | 68 ++ .../lib/components/sidebar/UserMenu.svelte | 1 + .../components/sidebar/WorkspaceMenu.svelte | 399 ++++++--- .../sidebar/WorkspaceScopeHeader.svelte | 69 ++ .../src/lib/components/sidebar/changelogs.ts | 20 +- .../lib/components/sidebar/leaveWorkspace.ts | 15 + .../lib/components/sidebar/sidebarChrome.ts | 5 + .../sidebar/workspaceMenuHref.test.ts | 34 +- .../components/sidebar/workspaceMenuHref.ts | 24 +- .../components/sidebar/workspaceSwitchUrl.ts | 30 + .../components/workspace/WorkspaceCard.svelte | 18 +- .../components/workspace/WorkspaceIcon.svelte | 34 +- .../CreateWorkspaceInner.svelte | 255 ++++-- .../ForkDatatableSection.svelte | 16 +- .../ForkDucklakeSection.svelte | 10 +- frontend/src/lib/utils/forkColor.ts | 50 ++ frontend/src/lib/utils/placementFly.ts | 26 + .../src/lib/utils/splitterPointerCapture.ts | 24 + frontend/src/lib/utils/workspaceHierarchy.ts | 19 + .../src/routes/(root)/(logged)/+layout.svelte | 650 +++++++++++--- .../(logged)/dev/session-tree/+page.svelte | 361 ++++++++ .../(root)/(logged)/sessions/+page.svelte | 788 +++++++++++++++-- .../(logged)/workspace_settings/+page.svelte | 11 +- 95 files changed, 7645 insertions(+), 2127 deletions(-) create mode 100644 frontend/src/lib/components/WorkspaceScopeTrigger.svelte create mode 100644 frontend/src/lib/components/common/button/CopyButton.svelte create mode 100644 frontend/src/lib/components/common/tooltip/NameIdTooltip.svelte create mode 100644 frontend/src/lib/components/sessions/OpenInSessionButton.svelte create mode 100644 frontend/src/lib/components/sessions/PreviewRouterPicker.svelte create mode 100644 frontend/src/lib/components/sessions/PreviewTabHost.svelte create mode 100644 frontend/src/lib/components/sessions/SessionModeSwitch.svelte create mode 100644 frontend/src/lib/components/sessions/previewRouter.test.ts create mode 100644 frontend/src/lib/components/sessions/previewRouter.ts create mode 100644 frontend/src/lib/components/sessions/sessionMode.svelte.ts create mode 100644 frontend/src/lib/components/sessions/sessionMode.test.ts create mode 100644 frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts create mode 100644 frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts create mode 100644 frontend/src/lib/components/sessions/sessionSwitch.svelte.ts create mode 100644 frontend/src/lib/components/sessions/sessionSwitch.test.ts create mode 100644 frontend/src/lib/components/sidebar/DeleteForkedWorkspaceModal.svelte create mode 100644 frontend/src/lib/components/sidebar/SettingsMenu.svelte create mode 100644 frontend/src/lib/components/sidebar/SidebarScrollArea.svelte create mode 100644 frontend/src/lib/components/sidebar/WorkspaceScopeHeader.svelte create mode 100644 frontend/src/lib/components/sidebar/leaveWorkspace.ts create mode 100644 frontend/src/lib/components/sidebar/sidebarChrome.ts create mode 100644 frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts create mode 100644 frontend/src/lib/utils/forkColor.ts create mode 100644 frontend/src/lib/utils/placementFly.ts create mode 100644 frontend/src/lib/utils/splitterPointerCapture.ts create mode 100644 frontend/src/routes/(root)/(logged)/dev/session-tree/+page.svelte diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index 60c39903f4..bd03f1baa4 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -77,13 +77,28 @@ function dedupeModels(models: AIProviderModel[]): AIProviderModel[] { }) } +// copilotInfo/copilotSessionModel are global, so concurrent loads (e.g. a fast +// session switch between workspaces) race: an earlier call resolving last would +// clobber the active workspace's config. Apply only the most recent call's +// result via a monotonic token — last invocation wins regardless of resolution +// order. init() is synchronous so its ordering already matches. +let loadCopilotToken = 0 +// The workspace copilotInfo currently reflects. A session send awaits this +// matching its committed workspace so getCurrentModel() can't read the previous +// workspace's provider/model while the scoped load is still in flight. +export const copilotWorkspace = writable(undefined) export async function loadCopilot(workspace: string) { + const token = ++loadCopilotToken workspaceAIClients.init(workspace) try { const info = await WorkspaceService.getCopilotInfo({ workspace }) + if (token !== loadCopilotToken) return setCopilotInfo(info) + copilotWorkspace.set(workspace) } catch (err) { + if (token !== loadCopilotToken) return setCopilotInfo({}) + copilotWorkspace.set(workspace) console.error('Could not get copilot info', err) } } diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte index 8b18503f75..8a266e4fc4 100644 --- a/frontend/src/lib/components/BreadcrumbSegment.svelte +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -26,6 +26,9 @@ close siblings. isCurrent?: boolean currentItem?: WorkspaceItem & { savedPath?: string } onPick: (item: WorkspaceItem) => void + /** Load the picker's items from this workspace (session editors pass their + * acting workspace); falls back to $workspaceStore inside the picker. */ + workspaceId?: string } let { @@ -37,7 +40,8 @@ close siblings. initialHighlight, isCurrent = false, currentItem, - onPick + onPick, + workspaceId }: Props = $props() let isOpen = $state(false) @@ -71,6 +75,12 @@ close siblings. >{:else}{label}{/if} {/snippet} {#snippet content()} - + {/snippet} diff --git a/frontend/src/lib/components/DiffEditor.svelte b/frontend/src/lib/components/DiffEditor.svelte index 59bc8a33a5..8ebfe1c23f 100644 --- a/frontend/src/lib/components/DiffEditor.svelte +++ b/frontend/src/lib/components/DiffEditor.svelte @@ -13,9 +13,7 @@ import EditorTheme from './EditorTheme.svelte' import Button from '$lib/components/common/button/Button.svelte' import { twMerge } from 'tailwind-merge' - import type { ButtonProp } from './diffEditorTypes' - - const SIDE_BY_SIDE_MIN_WIDTH = 700 + import { SIDE_BY_SIDE_MIN_WIDTH, type ButtonProp } from './diffEditorTypes' interface Props { open?: boolean @@ -30,6 +28,10 @@ buttons?: ButtonProp[] modifiedModel?: meditor.ITextModel | meditor.IEditorModel inlineDiff?: boolean + // Opt out of Monaco's auto-inline fallback (see useInlineViewWhenSpaceIsLimited + // below). Only set this when the consumer fully owns the inline/side-by-side + // decision; otherwise the default keeps Monaco's built-in narrow fallback. + disableAutoInline?: boolean } let { @@ -44,7 +46,8 @@ readOnly = false, buttons = [], modifiedModel, - inlineDiff = false + inlineDiff = false, + disableAutoInline = false }: Props = $props() let diffEditor: meditor.IStandaloneDiffEditor | undefined = $state(undefined) @@ -62,6 +65,12 @@ diffEditor = meditor.createDiffEditor(diffDivEl!, { automaticLayout, renderSideBySide: inlineDiff ? false : editorWidth >= SIDE_BY_SIDE_MIN_WIDTH, + // Monaco forces the inline view below renderSideBySideInlineBreakpoint (900px), + // overriding our SIDE_BY_SIDE_MIN_WIDTH gate. Consumers that fully own the + // inline/side-by-side decision (e.g. the diff drawer's toggle) opt out via + // disableAutoInline; everyone else keeps Monaco's auto-inline fallback so + // narrow panels (inline scripts, flow modules) stay readable in unified view. + useInlineViewWhenSpaceIsLimited: !disableAutoInline, originalEditable: false, readOnly, minimap: { diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index bf83d59886..3a12198188 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -19,7 +19,7 @@ import { twMerge } from 'tailwind-merge' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import { untrack } from 'svelte' - import { fly } from 'svelte/transition' + import { placementFly } from '$lib/utils/placementFly' import { ButtonType } from './common/button/model' interface Props { @@ -187,7 +187,7 @@ use:melt={$menuEl} data-menu class="z-[6000] transition-all duration-100" - transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }} + transition:placementFly={{ duration: enableFlyTransition ? 100 : 0, placement }} > {#if customMenu} {@render menu?.({ item, close, builders })} diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 87a0c6e1da..7812e8a6a5 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -43,6 +43,10 @@ * inline. Breadcrumb navigation still works — only the rename UI is * gated. */ pathEditable?: boolean + /** Workspace whose items the breadcrumb picker lists. Session live + * editors pass their acting workspace so the picker isn't scoped to the + * navigation workspace; falls back to $workspaceStore in the picker. */ + workspaceId?: string } let { @@ -55,7 +59,8 @@ onBehalfOfEmail, penVisibility = 'hover', summaryEditable = true, - pathEditable = true + pathEditable = true, + workspaceId }: Props = $props() let pathPopoverOpen = $state(false) @@ -138,6 +143,7 @@ initialHighlight={kindKey(kind)} isCurrent={!segments} {currentItem} + {workspaceId} onPick={handlePickerSelect} /> {#if segments} @@ -152,6 +158,7 @@ : { kind: 'all', dir: segments.dirs[i - 1].fullPath }} initialHighlight={dKey} {currentItem} + {workspaceId} onPick={handlePickerSelect} /> {/each} @@ -165,6 +172,7 @@ initialHighlight={leafKey} isCurrent {currentItem} + {workspaceId} onPick={handlePickerSelect} /> {/if} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index d4be7a3b1b..418b33b77f 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -139,10 +139,13 @@ onTestJob }: FlowBuilderProps = $props() - // Key the AutosaveIndicator watches. Falls back to this component's own - // draft key, so the full-page editor is unchanged; the sessions preview - // overrides both to the (forked) workspace + path its autosave saves under. - const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) + // The workspace this editor operates on: deploy, save-draft, trigger loading + // and the AutosaveIndicator all target it. Falls back to the global store, so + // the full-page editor is unchanged; the sessions preview overrides it to the + // session's (forked) workspace, so an embedded editor acts on the session's + // fork rather than the navigation workspace ($workspaceStore, which stays put). + // indicatorPath is the matching draft path. + const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath) let initialPathStore = writable(initialPath) @@ -237,7 +240,7 @@ try { if (initialPath && initialPath != '') { const flowVersion = await FlowService.getFlowLatestVersion({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath }) @@ -289,9 +292,9 @@ // failure: `flush` never rejects (postSave catches and routes errors // to the failures map), so the success branch fired regardless. export async function saveDraft(): Promise { - if (!$workspaceStore || !liveEditorDraftStoragePath) return + if (!opWorkspace || !liveEditorDraftStoragePath) return await UserDraftDbSyncer.flush({ - workspace: $workspaceStore, + workspace: opWorkspace, itemKind: 'flow', path: liveEditorDraftStoragePath }) @@ -339,7 +342,7 @@ } async function syncWithDeployed() { const flow = await FlowService.getFlowByPath({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath, withStarredInfo: true }) @@ -396,7 +399,7 @@ if (newFlow) { await FlowService.createFlow({ - workspace: $workspaceStore!, + workspace: opWorkspace!, requestBody: { path: $pathStore, summary: flow.summary ?? '', @@ -414,7 +417,7 @@ } }) await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: fakeInitialPath, requestBody: { new_path: $pathStore @@ -424,7 +427,7 @@ if (triggersToDeploy) { await deployTriggers( triggersToDeploy, - $workspaceStore, + opWorkspace, !!$userStore?.is_admin || !!$userStore?.is_super_admin, usedTriggerKinds, $pathStore, @@ -435,7 +438,7 @@ if (triggersToDeploy) { await deployTriggers( triggersToDeploy, - $workspaceStore, + opWorkspace, !!$userStore?.is_admin || !!$userStore?.is_super_admin, usedTriggerKinds, initialPath @@ -443,7 +446,7 @@ } await FlowService.updateFlow({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath, requestBody: { path: $pathStore, @@ -465,7 +468,7 @@ // New/updated path now exists server-side — drop the autocomplete // cache so it shows up immediately instead of after the 60s TTL. - invalidateWorkspacePaths($workspaceStore!) + invalidateWorkspacePaths(opWorkspace!) const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & { draft_triggers: Trigger[] @@ -505,8 +508,8 @@ const pathStore = writable(untrack(() => pathStoreInit) ?? initialPath) $effect(() => { - if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return - const workspace = $workspaceStore + if (liveEditorDraftStoragePath === undefined || !opWorkspace) return + const workspace = opWorkspace UserDraft.setLiveEditorDraft({ workspace, itemKind: 'flow', @@ -561,7 +564,8 @@ modulesTestStates, outputPickerOpenFns, preserveOnBehalfOf, - savedOnBehalfOfEmail + savedOnBehalfOfEmail, + opWorkspace: () => opWorkspace }) // Set up NoteEditor context for note editing capabilities @@ -606,14 +610,14 @@ export async function loadTriggers() { if (initialPath == '') return $triggersCount = await FlowService.getTriggersCountOfFlow({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath }) // Initialize triggers using utility function await triggersState.fetchTriggers( triggersCount, - $workspaceStore, + opWorkspace, initialPath, true, $primaryScheduleStore, @@ -740,10 +744,10 @@ if ( !untrack(() => newFlow) && !isCloudHosted() && - editInForkAllowed($workspaceStore, $userWorkspaces) + editInForkAllowed(opWorkspace, $userWorkspaces) ) { dropdownItems.push({ - label: editInForkLabel($workspaceStore, $userWorkspaces), + label: editInForkLabel(opWorkspace, $userWorkspaces), onClick: () => window.open(buildForkEditUrl('flow', initialPath)) }) } @@ -980,7 +984,7 @@ selectedId && untrack(() => select(selectedId)) }) $effect.pre(() => { - initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers()) + initialPath && initialPath != '' && opWorkspace && untrack(() => loadTriggers()) }) $effect.pre(() => { const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false @@ -991,7 +995,7 @@ await stepHistoryLoader.loadIndividualStepsStates( flowStore.val as Flow, flowStateStore, - $workspaceStore!, + opWorkspace!, $initialPathStore, $pathStore ) @@ -1099,17 +1103,20 @@ bind:clientWidth={topbarWidth} class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative" > -
- onNavigate?.(item)} - /> - {#if indicatorWorkspace && indicatorPath !== undefined} +
+
+ onNavigate?.(item)} + /> +
+ {#if opWorkspace && indicatorPath !== undefined} {/if}
-
+
{#if $enterpriseLicense && !newFlow} {/if} @@ -1240,6 +1247,16 @@ aiChatOpen={aiChatManager.open} showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false} toggleAiChat={() => aiChatManager.toggleOpen()} + sessionOpen={$pathStore + ? { + target: { kind: 'flow', path: $pathStore }, + workspaceId: opWorkspace ?? undefined, + // Persist unsaved edits so the session preview + // (/flows/edit/) opens the flow exactly as it is in the + // editor right now. + beforeOpen: saveDraft + } + : undefined} onOpenPreview={flowPreviewButtons?.openPreview} localModuleStates={showJobStatus ? localModuleStates : {}} {showJobStatus} diff --git a/frontend/src/lib/components/FlowDiffViewer.svelte b/frontend/src/lib/components/FlowDiffViewer.svelte index 3539cbc5fe..56a15687a8 100644 --- a/frontend/src/lib/components/FlowDiffViewer.svelte +++ b/frontend/src/lib/components/FlowDiffViewer.svelte @@ -10,6 +10,8 @@ * FlowGraphDiffViewer show its own user-facing toggle (matches the * pre-fork-diff-drawer behavior). */ inlineDiff?: boolean + /** Forward Monaco's auto-inline opt-out to the YAML-mode DiffEditor. */ + disableAutoInline?: boolean /** Forwarded to FlowGraphDiffViewer — render an empty surface * placeholder for the "before" / "after" pane when the item is * added / removed. */ @@ -21,6 +23,7 @@ beforeYaml, afterYaml, inlineDiff = undefined, + disableAutoInline = false, beforeMissing = false, afterMissing = false }: Props = $props() @@ -46,6 +49,7 @@ defaultOriginal={beforeYaml} defaultModified={afterYaml} {inlineDiff} + {disableAutoInline} readOnly /> {/await} diff --git a/frontend/src/lib/components/PageHeader.svelte b/frontend/src/lib/components/PageHeader.svelte index 556ac1b0cc..9a927c52fa 100644 --- a/frontend/src/lib/components/PageHeader.svelte +++ b/frontend/src/lib/components/PageHeader.svelte @@ -2,12 +2,15 @@ import Tooltip from './Tooltip.svelte' interface Props { - title: string; - tooltip?: string; - documentationLink?: string | undefined; - primary?: boolean; - childrenWrapperDivClasses?: string; - children?: import('svelte').Snippet; + title: string + tooltip?: string + documentationLink?: string | undefined + primary?: boolean + childrenWrapperDivClasses?: string + // Inline actions rendered right after the title (e.g. a copy-id button), + // as opposed to `children` which lands on the far right of the header row. + titleActions?: import('svelte').Snippet + children?: import('svelte').Snippet } let { @@ -16,8 +19,9 @@ documentationLink = undefined, primary = true, childrenWrapperDivClasses = '', + titleActions, children - }: Props = $props(); + }: Props = $props()
@@ -31,6 +35,7 @@ {tooltip} {/if} + {@render titleActions?.()} {:else} @@ -40,6 +45,7 @@ {tooltip} {/if} + {@render titleActions?.()} {/if} diff --git a/frontend/src/lib/components/PrefixedInput.svelte b/frontend/src/lib/components/PrefixedInput.svelte index 2a1dccf386..adc578b545 100644 --- a/frontend/src/lib/components/PrefixedInput.svelte +++ b/frontend/src/lib/components/PrefixedInput.svelte @@ -1,139 +1,68 @@ - - - + +
+ + +
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7d639b2c75..5b86f7dbad 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -173,15 +173,24 @@ let topbarWidth = $state(0) const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 900) - // AutosaveIndicator watch key. Falls back to the full-page editor's - // global store + URL draft path; the sessions preview overrides both so the - // icon tracks the session's (forked) workspace + target path where autosave - // actually happens. - const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) + // The workspace this editor operates on: deploy, save-draft, trigger loading + // and the AutosaveIndicator all target it. Falls back to the full-page + // editor's global store; the sessions preview overrides it to the session's + // (forked) workspace, so an embedded editor acts on the session's fork rather + // than the navigation workspace ($workspaceStore, which stays put). indicatorPath + // is the matching draft path (URL path full-page, session target in preview). + const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) const indicatorPath = $derived(autosavePath ?? userDraftPath) + // The shared `workerTags` store caches tags for the navigation workspace. A + // session editor deploys to `opWorkspace` (a fork), so it keeps a local list to + // gate/populate the tag picker without reading or clobbering the shared cache. + const usesLocalTags = $derived(opWorkspace != undefined && opWorkspace !== $workspaceStore) + let localWorkerTags = $state(undefined) + const scriptWorkerTags = $derived(usesLocalTags ? localWorkerTags : $workerTags) + function getCompactMenuItems(): Item[] { - const hasTags = ($workerTags?.length ?? 0) > 0 + const hasTags = (scriptWorkerTags?.length ?? 0) > 0 return [ ...(customUi?.topBar?.tagEdit != false && hasTags ? [ @@ -285,13 +294,13 @@ return } $triggersCount = await ScriptService.getTriggersCountOfScript({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath }) await triggersState.fetchTriggers( triggersCount, - $workspaceStore, + opWorkspace, initialPath, false, $primaryScheduleStore, @@ -443,7 +452,7 @@ } try { const templateScript = await PostgresTriggerService.getTemplateScript({ - workspace: $workspaceStore!, + workspace: opWorkspace!, id: templateId }) return templateScript @@ -497,7 +506,7 @@ if (initialPath && initialPath != '') { actual_parent_hash = ( await ScriptService.getScriptLatestVersion({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath }) )?.script_hash @@ -544,7 +553,7 @@ async function syncWithDeployed() { const latestScript = await ScriptService.getScriptByPath({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath, withStarredInfo: true }) @@ -608,7 +617,7 @@ } const newHash = await ScriptService.createScript({ - workspace: $workspaceStore!, + workspace: opWorkspace!, requestBody: { path: script.path, summary: script.summary, @@ -653,16 +662,16 @@ // New/updated path now exists server-side — drop the autocomplete // cache so it shows up immediately instead of after the 60s TTL. - invalidateWorkspacePaths($workspaceStore!) + invalidateWorkspacePaths(opWorkspace!) // Authoritative save-time schema-contract check (pipelines gap #2b): // warn-only, post-commit so a self-produced target resolves to the // content just deployed. Fire-and-forget — must never gate the deploy. - notifyContractWarnings($workspaceStore!, script.language, script.content) + notifyContractWarnings(opWorkspace!, script.language, script.content) if (!initialPath) { await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: fakeInitialPath, requestBody: { new_path: script.path @@ -674,7 +683,7 @@ if (triggersToDeploy) { await deployTriggers( triggersToDeploy, - $workspaceStore, + opWorkspace, !!$userStore?.is_admin || !!$userStore?.is_super_admin, usedTriggerKinds, script.path, @@ -718,11 +727,11 @@ // syncer flushes. No toast — the AutosaveIndicator narrates the result, and // `flush` never rejects (postSave routes errors to the failures map). async function saveDraft(): Promise { - if (!$workspaceStore || !userDraftPath) return + if (!opWorkspace || !userDraftPath) return editor?.flushPendingChanges() await tick() await UserDraftDbSyncer.flush({ - workspace: $workspaceStore, + workspace: opWorkspace, itemKind: 'script', path: userDraftPath }) @@ -786,10 +795,10 @@ window.open(`/scripts/add?template=${initialPath}`) } }, - ...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) + ...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces) ? [ { - label: editInForkLabel($workspaceStore, $userWorkspaces), + label: editInForkLabel(opWorkspace, $userWorkspaces), onClick: () => { window.open(buildForkEditUrl('script', initialPath)) } @@ -990,8 +999,12 @@ loadWorkerTags() async function loadWorkerTags() { - if (!$workerTags) { - $workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: $workspaceStore! }) + if (usesLocalTags) { + if (!localWorkerTags) { + localWorkerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: opWorkspace! }) + } + } else if (!$workerTags) { + $workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: opWorkspace! }) } } @@ -1748,7 +1761,7 @@ /> {#if script.on_behalf_of_email && canPreserve} → { @@ -1906,13 +1919,14 @@ kind="script" summaryEditable={customUi?.topBar?.editableSummary != false} pathEditable={customUi?.topBar?.editablePath != false} + workspaceId={autosaveWorkspace} onNavigate={(item) => onNavigate?.(item)} />
{/if} - {#if indicatorWorkspace} + {#if opWorkspace} 0} + {#if scriptWorkerTags} + {#if scriptWorkerTags?.length ?? 0 > 0}
{/if} @@ -2056,6 +2071,15 @@
+ + {#snippet fallback()} + {/snippet} - + {/if} {/if}
diff --git a/frontend/src/lib/components/WorkerTagPicker.svelte b/frontend/src/lib/components/WorkerTagPicker.svelte index 1b316a61f5..ccc3266ced 100644 --- a/frontend/src/lib/components/WorkerTagPicker.svelte +++ b/frontend/src/lib/components/WorkerTagPicker.svelte @@ -11,28 +11,53 @@ popupPlacement?: 'bottom-end' | 'top-end' disabled?: boolean placeholder?: string + // Workspace to read tags from; defaults to $workspaceStore. A fork-scoped + // session passes its effective workspace so the picker matches the deploy target. + workspaceId?: string } let { tag = $bindable(), popupPlacement = 'bottom-end', disabled = false, - placeholder + placeholder, + workspaceId = undefined }: Props = $props() + // See WorkerTagSelect: the shared `workerTags` cache is navigation-scoped, so a + // different target workspace reads/writes a local list to avoid clobbering it. + let effectiveWorkspace = $derived(workspaceId ?? $workspaceStore) + let usesLocal = $derived(workspaceId != undefined && workspaceId !== $workspaceStore) + let localWorkerTags = $state(undefined) + let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags) + loadWorkerTags() async function loadWorkerTags(force = false) { - if (!$workerTags || force) { - $workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: $workspaceStore! }) + if (usesLocal) { + if (!localWorkerTags || force) { + localWorkerTags = await WorkerService.getCustomTagsForWorkspace({ + workspace: effectiveWorkspace! + }) + } + } else if (!$workerTags || force) { + $workerTags = await WorkerService.getCustomTagsForWorkspace({ + workspace: effectiveWorkspace! + }) } }
- {#if $workerTags} - {#if $workerTags?.length ?? 0 > 0} - + {#if currentTags} + {#if currentTags?.length ?? 0 > 0} + {:else}
No custom worker group tag defined on this instance in "Workers {'->'} Custom tags" @@ -56,7 +81,6 @@
{:else if kind === 'raw_app_file' && rawFile} @@ -119,6 +130,7 @@ doesn't reflow the parent. fullYamlOriginal={rawFile.fullYamlOriginal} fullYamlCurrent={rawFile.fullYamlCurrent} {inlineDiff} + {disableAutoInline} /> {:else if hasContent}
@@ -139,6 +151,7 @@ doesn't reflow the parent. defaultOriginal={original.content ?? ''} defaultModified={current.content ?? ''} {inlineDiff} + {disableAutoInline} readOnly /> {/await} @@ -154,6 +167,7 @@ doesn't reflow the parent. defaultOriginal={original.metadata} defaultModified={current.metadata} {inlineDiff} + {disableAutoInline} readOnly /> {/await} @@ -173,6 +187,7 @@ doesn't reflow the parent. defaultOriginal={original.metadata} defaultModified={current.metadata} {inlineDiff} + {disableAutoInline} readOnly />
diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index f9c538b50a..8ff680ef70 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -44,6 +44,10 @@ would be surprising. externalFilter?: string autoFocus?: boolean flush?: boolean + // Load items and drafts from this workspace instead of the navigation + // workspace. Set by session live editors, whose acting workspace can + // differ from $workspaceStore; falls back to $workspaceStore otherwise. + workspaceId?: string } let { @@ -54,9 +58,12 @@ would be surprising. currentItem, externalFilter, autoFocus = true, - flush = false + flush = false, + workspaceId }: Props = $props() + const effectiveWorkspace = $derived(workspaceId ?? $workspaceStore) + let inner = $state(undefined) export function focus() { @@ -70,7 +77,7 @@ would be surprising. } const loader = useWorkspaceItemsLoader( - () => $workspaceStore, + () => effectiveWorkspace, () => kinds ) @@ -86,7 +93,7 @@ would be surprising. // `listGlobalDrafts` is backend-backed (async); fetch once and derive the // per-kind lists synchronously from the resolved snapshot. const globalDraftsResource = resource( - () => ({ ws: $workspaceStore, enabled: isGlobalAiEnabled() }), + () => ({ ws: effectiveWorkspace, enabled: isGlobalAiEnabled() }), async ({ ws, enabled }) => (enabled && ws ? await listGlobalDrafts(ws) : []) ) function aiDraftsForKind(k: Kind): WorkspaceItem[] { diff --git a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte new file mode 100644 index 0000000000..ac132bdca2 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte @@ -0,0 +1,205 @@ + + +{#snippet chipButton(grouped: boolean)} + +{/snippet} + +{#if menuItems?.length && !isCollapsed} +
+ {@render chipButton(true)} + + {#snippet buttonReplacement()} + + + + {/snippet} + +
+{:else} + {@render chipButton(false)} +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index ed471e8262..84261bcf80 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -908,15 +908,22 @@ bind:clientWidth={topbarWidth} class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto max-h-12 h-12 shrink-0" > -
- (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} - /> -
+ +
+
+ (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} + /> +
+
{#if $app} {#if $mode !== 'preview'} -
+