mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(sessions): v2 unified sidebar with family/fork scoping and preview router (#9816)
* 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) <noreply@anthropic.com> * feat(sessions): add full-screen toggle for the session panel Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sessions): workspace-tree rail with browse mode and collapsible sidebar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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 "<workspace> 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 <page path>". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): drop colon from "Workspace root" scope label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(sessions): drive open_preview tool through the multi-tab model Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: plan SessionPreviewTabs deep module for sessions preview tabs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(fork): validate fork name/id length before creation Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): title open-in-workspace button "Open in workspace" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): keep AI chat working when the sessions dev flag is off Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): match burger drawer width and keep it open on mode toggle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sessions): surface the dev-workspace badge across session workspace pickers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): dedup navigate, sanitize hydration, cap mounted tabs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(fork): support forks of forks via a base-workspace picker Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sidebar): family expansion, pinned menu actions, animated popovers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sessions): persist unsent drafts, gate preview, loading state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sidebar): group fork picker on top and unfold the session list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sessions): capture splitter pointer so off-window release ends drag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(sessions): retire the pinned preview tab (dot and no-close) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sessions): shared open-in-AI-session button across editors Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sessions): dedup page tabs, flush on hide, review cleanups Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sessions): give unsent drafts a side panel, reset tabs on retarget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sessions): keep the session fork icon neutral except when detached Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(sessions): reconcile open session with family on workspace switch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sessions): lazy-load runtime in session switch to keep it node-testable Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(diff): vertically center the element-header icon with its path The path renders as ExternalEditLink's inline-flex <a> 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(sidebar): make the nav rail resizable with rem scaling Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(diff): gate Monaco auto-inline behind a prop to keep narrow diffs unified Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): restore instant popover/dropdown default, opt sidebar and sessions in Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sidebar): restore delete-forked-workspace action in the settings menu Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tooltip): add cursor anchoring option and use it for the name/id tooltip Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(dev): remove settings-menu kitchen sink scaffolding Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(docs): remove session-preview-tabs owner plan doc Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(dev): drop vite preview-server proxy scaffolding Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sidebar): scroll nav as one block with fade hints, pin settings to bottom Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sidebar): guard against concurrent pointer drags leaking resize listeners Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * docs(sessions): fix stale setSessionTabs transient-persistence comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(sessions): keep preview iframe on session fork across reloads and open-in-workspace Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): scope worker-tag pickers to the session's effective workspace Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | undefined>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
<WorkspaceItemDrillPicker {initialScope} {initialHighlight} {currentItem} onPick={handlePick} />
|
||||
<WorkspaceItemDrillPicker
|
||||
{initialScope}
|
||||
{initialHighlight}
|
||||
{currentItem}
|
||||
{workspaceId}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<void> {
|
||||
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<string>(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"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 min-w-[200px] max-w-full">
|
||||
<EditorHeader
|
||||
bind:summary={flowStore.val.summary}
|
||||
bind:path={$pathStore}
|
||||
savedPath={initialPath}
|
||||
onBehalfOfEmail={$savedOnBehalfOfEmail}
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
/>
|
||||
{#if indicatorWorkspace && indicatorPath !== undefined}
|
||||
<div class="flex flex-row items-center gap-2 min-w-0">
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<EditorHeader
|
||||
bind:summary={flowStore.val.summary}
|
||||
bind:path={$pathStore}
|
||||
savedPath={initialPath}
|
||||
onBehalfOfEmail={$savedOnBehalfOfEmail}
|
||||
workspaceId={autosaveWorkspace}
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
/>
|
||||
</div>
|
||||
{#if opWorkspace && indicatorPath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={indicatorWorkspace}
|
||||
workspace={opWorkspace}
|
||||
itemKind="flow"
|
||||
path={indicatorPath}
|
||||
draftOnly={newFlow}
|
||||
@@ -1120,7 +1127,7 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<div class="flex flex-row gap-2 items-center shrink-0">
|
||||
{#if $enterpriseLicense && !newFlow}
|
||||
<Awareness />
|
||||
{/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/<path>) opens the flow exactly as it is in the
|
||||
// editor right now.
|
||||
beforeOpen: saveDraft
|
||||
}
|
||||
: undefined}
|
||||
onOpenPreview={flowPreviewButtons?.openPreview}
|
||||
localModuleStates={showJobStatus ? localModuleStates : {}}
|
||||
{showJobStatus}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row flex-wrap justify-between items-center pb-2 my-4 mr-2 min-h-16">
|
||||
@@ -31,6 +35,7 @@
|
||||
{tooltip}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{@render titleActions?.()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -40,6 +45,7 @@
|
||||
{tooltip}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{@render titleActions?.()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,139 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { tick } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||
|
||||
let {
|
||||
prefix = '',
|
||||
value = $bindable(''),
|
||||
value = $bindable(),
|
||||
placeholder = '',
|
||||
error = false,
|
||||
autofocus = false,
|
||||
class: className = '',
|
||||
...restProps
|
||||
} = $props()
|
||||
}: {
|
||||
prefix?: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
// Red border, matching TextInput's error styling.
|
||||
error?: boolean
|
||||
autofocus?: boolean
|
||||
class?: string
|
||||
} & Omit<
|
||||
HTMLInputAttributes,
|
||||
'prefix' | 'value' | 'placeholder' | 'autofocus' | 'class'
|
||||
> = $props()
|
||||
|
||||
let inputElement: HTMLInputElement = $state(null!)
|
||||
let internalValue = $state(untrack(() => prefix) + value)
|
||||
let inputElement: HTMLInputElement | undefined = $state()
|
||||
|
||||
// Update internal value when prop changes
|
||||
// Programmatic focus: the native autofocus attribute is unreliable on content
|
||||
// mounted after page load (e.g. inside a modal).
|
||||
$effect(() => {
|
||||
internalValue = prefix + value
|
||||
if (!autofocus) return
|
||||
void tick().then(() => {
|
||||
inputElement?.focus()
|
||||
inputElement?.select()
|
||||
})
|
||||
})
|
||||
|
||||
function handleInput(e) {
|
||||
const newValue = e.target.value
|
||||
|
||||
// Ensure the value always starts with the prefix
|
||||
if (newValue.startsWith(prefix)) {
|
||||
internalValue = newValue
|
||||
value = newValue.slice(prefix.length)
|
||||
} else {
|
||||
internalValue = prefix
|
||||
value = ''
|
||||
// Reset cursor position
|
||||
if (inputElement) {
|
||||
inputElement.value = prefix
|
||||
inputElement.setSelectionRange(prefix.length, prefix.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
const cursorPos = e.target.selectionStart
|
||||
const selectionEnd = e.target.selectionEnd
|
||||
|
||||
// Prevent backspace if cursor is at or before prefix end
|
||||
if (e.key === 'Backspace') {
|
||||
if (cursorPos <= prefix.length && selectionEnd <= prefix.length) {
|
||||
e.preventDefault()
|
||||
} else if (cursorPos <= prefix.length && selectionEnd > prefix.length) {
|
||||
// If selection spans across prefix, only delete after prefix
|
||||
e.preventDefault()
|
||||
const newValue = prefix + internalValue.slice(selectionEnd)
|
||||
internalValue = newValue
|
||||
value = newValue.slice(prefix.length)
|
||||
// Set cursor position after prefix
|
||||
setTimeout(() => {
|
||||
inputElement.setSelectionRange(prefix.length, prefix.length)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent delete key within prefix
|
||||
if (e.key === 'Delete' && cursorPos < prefix.length) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// Prevent selecting all (Ctrl+A) from selecting the prefix
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault()
|
||||
inputElement.setSelectionRange(prefix.length, internalValue.length)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
// Prevent cursor from being placed within prefix
|
||||
if ((inputElement.selectionStart ?? 0) < prefix.length) {
|
||||
inputElement.setSelectionRange(prefix.length, prefix.length)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
// Ensure cursor starts after prefix when focusing
|
||||
if ((inputElement.selectionStart ?? 0) < prefix.length) {
|
||||
inputElement.setSelectionRange(prefix.length, prefix.length)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e) {
|
||||
e.preventDefault()
|
||||
const pasteData = e.clipboardData.getData('text')
|
||||
const cursorPos = inputElement.selectionStart ?? 0
|
||||
const selectionEnd = inputElement.selectionEnd ?? 0
|
||||
|
||||
if (cursorPos < prefix.length) {
|
||||
// Paste at the end of prefix
|
||||
internalValue = prefix + pasteData + internalValue.slice(prefix.length)
|
||||
} else {
|
||||
// Normal paste
|
||||
internalValue =
|
||||
internalValue.slice(0, cursorPos) + pasteData + internalValue.slice(selectionEnd)
|
||||
}
|
||||
|
||||
value = internalValue.slice(prefix.length)
|
||||
|
||||
// Set cursor position after pasted content
|
||||
const newCursorPos = Math.max(prefix.length, cursorPos) + pasteData.length
|
||||
setTimeout(() => {
|
||||
inputElement.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}, 0)
|
||||
}
|
||||
const borderClasses = $derived(
|
||||
error
|
||||
? 'border !border-red-300 dark:!border-red-400/45 focus-within:!border-red-400 hover:!border-red-500 dark:hover:!border-red-400/75'
|
||||
: 'border border-border-light hover:border-border-selected/50 focus-within:border-border-selected'
|
||||
)
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
{placeholder}
|
||||
class="prefixed-input {className}"
|
||||
value={internalValue}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
onclick={handleClick}
|
||||
onfocus={handleFocus}
|
||||
onpaste={handlePaste}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.prefixed-input {
|
||||
/* Default styles - can be overridden by class prop */
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.prefixed-input:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
</style>
|
||||
<!-- The prefix renders outside the <input> (non-editable) while the wrapper
|
||||
carries the input look, so the two read as a single field. The bound value
|
||||
is only the editable part after the prefix. The prefix is text-tertiary:
|
||||
lighter than typed text but darker than the hint-colored placeholder, so
|
||||
the fixed prefix doesn't read as placeholder. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center w-full min-h-8 px-2 rounded-md text-xs font-normal cursor-text',
|
||||
'bg-surface-input transition-colors',
|
||||
borderClasses,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span class="text-tertiary select-none shrink-0" aria-hidden="true">{prefix}</span>
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:value
|
||||
class="no-default-style grow min-w-0 !p-0 !border-none !outline-none focus:!ring-0 !bg-transparent text-xs text-primary !placeholder-hint"
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string[] | undefined>(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<void> {
|
||||
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! })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1748,7 +1761,7 @@
|
||||
/>
|
||||
{#if script.on_behalf_of_email && canPreserve}
|
||||
→ <OnBehalfOfSelector
|
||||
targetWorkspace={$workspaceStore ?? ''}
|
||||
targetWorkspace={opWorkspace ?? ''}
|
||||
targetValue={originalOnBehalfOfEmail}
|
||||
selected={onBehalfOfChoice}
|
||||
onSelect={(choice, details) => {
|
||||
@@ -1906,13 +1919,14 @@
|
||||
kind="script"
|
||||
summaryEditable={customUi?.topBar?.editableSummary != false}
|
||||
pathEditable={customUi?.topBar?.editablePath != false}
|
||||
workspaceId={autosaveWorkspace}
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if indicatorWorkspace}
|
||||
{#if opWorkspace}
|
||||
<AutosaveIndicator
|
||||
workspace={indicatorWorkspace}
|
||||
workspace={opWorkspace}
|
||||
itemKind="script"
|
||||
path={indicatorPath}
|
||||
draftOnly={savedScript?.no_deployed === true}
|
||||
@@ -1994,13 +2008,14 @@
|
||||
{:else}
|
||||
{@render diffButton()}
|
||||
{#if customUi?.topBar?.tagEdit != false}
|
||||
{#if $workerTags}
|
||||
{#if $workerTags?.length ?? 0 > 0}
|
||||
{#if scriptWorkerTags}
|
||||
{#if scriptWorkerTags?.length ?? 0 > 0}
|
||||
<div class="max-w-[200px]">
|
||||
<WorkerTagSelect
|
||||
nullTag={script.language}
|
||||
placeholder={customUi?.tagSelectPlaceholder}
|
||||
bind:tag={script.tag}
|
||||
workspaceId={opWorkspace}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2056,6 +2071,15 @@
|
||||
|
||||
<ScriptEditor
|
||||
{disableAi}
|
||||
sessionOpen={script.path
|
||||
? {
|
||||
target: { kind: 'script', path: script.path },
|
||||
workspaceId: opWorkspace ?? undefined,
|
||||
// Flush the per-user draft so the session preview opens the script
|
||||
// exactly as it is in the editor right now.
|
||||
beforeOpen: saveDraft
|
||||
}
|
||||
: undefined}
|
||||
bind:selectedTab={selectedInputTab}
|
||||
{customUi}
|
||||
{onTestJob}
|
||||
|
||||
@@ -91,6 +91,9 @@
|
||||
import { getStringError } from './copilot/chat/utils'
|
||||
import type { ScriptOptions } from './copilot/chat/ContextManager.svelte'
|
||||
import { aiChatManager, AIMode } from './copilot/chat/AIChatManager.svelte'
|
||||
import OpenInSessionButton, {
|
||||
type OpenInSessionSource
|
||||
} from './sessions/OpenInSessionButton.svelte'
|
||||
|
||||
// Forward-looking hook for the upcoming session-pane feature: that PR will
|
||||
// `setContext('aiChatManager', ...)` from the session wrapper so this editor
|
||||
@@ -212,6 +215,9 @@
|
||||
// regular /scripts/edit route keeps its current open-by-default UX;
|
||||
// the session preview opts in to save vertical real estate.
|
||||
initialTestPanelCollapsed?: boolean
|
||||
// Lets the AI toolbar button open the script in a fresh AI session
|
||||
// instead of the inline chat panel (see OpenInSessionButton for gating).
|
||||
sessionOpen?: OpenInSessionSource
|
||||
// Producer-side facts for the live schema-contract diagnostics
|
||||
// (`on_schema_change=ignore` suppression + scd2 `_current` fallback),
|
||||
// built by the pipeline page from the resolved graph. Absent outside the
|
||||
@@ -259,6 +265,7 @@
|
||||
onTestStateChange,
|
||||
onTestJob,
|
||||
initialTestPanelCollapsed = false,
|
||||
sessionOpen = undefined,
|
||||
schemaContractContext = undefined
|
||||
}: Props = $props()
|
||||
|
||||
@@ -2592,37 +2599,41 @@
|
||||
{/if}
|
||||
{#if !aiChatManager.open && !disableAi && !inSessionPane}
|
||||
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
|
||||
<HideButton
|
||||
hidden={true}
|
||||
direction="right"
|
||||
panelName="AI"
|
||||
shortcut="L"
|
||||
unifiedSize="sm"
|
||||
usePopoverOverride={!$copilotInfo.enabled}
|
||||
customHiddenIcon={{
|
||||
icon: WandSparkles
|
||||
}}
|
||||
btnClasses="!text-ai"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (!aiChatManager.open) {
|
||||
aiChatManager.changeMode(AIMode.SCRIPT)
|
||||
}
|
||||
aiChatManager.toggleOpen()
|
||||
}}
|
||||
>
|
||||
{#snippet popoverOverride()}
|
||||
<div class="text-sm">
|
||||
Enable Windmill AI in the <a
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
target="_blank"
|
||||
class="inline-flex flex-row items-center gap-1"
|
||||
>
|
||||
workspace settings <ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
<OpenInSessionButton source={sessionOpen}>
|
||||
{#snippet fallback()}
|
||||
<HideButton
|
||||
hidden={true}
|
||||
direction="right"
|
||||
panelName="AI"
|
||||
shortcut="L"
|
||||
unifiedSize="sm"
|
||||
usePopoverOverride={!$copilotInfo.enabled}
|
||||
customHiddenIcon={{
|
||||
icon: WandSparkles
|
||||
}}
|
||||
btnClasses="!text-ai"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (!aiChatManager.open) {
|
||||
aiChatManager.changeMode(AIMode.SCRIPT)
|
||||
}
|
||||
aiChatManager.toggleOpen()
|
||||
}}
|
||||
>
|
||||
{#snippet popoverOverride()}
|
||||
<div class="text-sm">
|
||||
Enable Windmill AI in the <a
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
target="_blank"
|
||||
class="inline-flex flex-row items-center gap-1"
|
||||
>
|
||||
workspace settings <ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
{/snippet}
|
||||
</HideButton>
|
||||
{/snippet}
|
||||
</HideButton>
|
||||
</OpenInSessionButton>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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<string[] | undefined>(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!
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="max-w-sm grow">
|
||||
{#if $workerTags}
|
||||
{#if $workerTags?.length ?? 0 > 0}
|
||||
<WorkerTagSelect {placeholder} noLabel bind:tag {disabled} />
|
||||
{#if currentTags}
|
||||
{#if currentTags?.length ?? 0 > 0}
|
||||
<WorkerTagSelect
|
||||
{placeholder}
|
||||
noLabel
|
||||
bind:tag
|
||||
{disabled}
|
||||
workspaceId={usesLocal ? effectiveWorkspace : undefined}
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-sm text-secondary flex flex-row gap-2">
|
||||
No custom worker group tag defined on this instance in "Workers {'->'} Custom tags"
|
||||
@@ -56,7 +81,6 @@
|
||||
<Button
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
$workerTags = undefined
|
||||
loadWorkerTags(true)
|
||||
}}
|
||||
startIcon={{ icon: RotateCw }}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
nullTag = undefined,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
inputClass
|
||||
inputClass,
|
||||
workspaceId = undefined
|
||||
}: {
|
||||
tag: string | undefined
|
||||
noLabel?: boolean
|
||||
@@ -26,6 +27,11 @@
|
||||
language?: string
|
||||
class?: string
|
||||
inputClass?: string
|
||||
// Workspace to read custom tags and worker availability from. Defaults to
|
||||
// $workspaceStore. Session editors act on a workspace that differs from the
|
||||
// navigation one, so they pass their effective workspace to keep the tag
|
||||
// list and availability dots matching the deploy target.
|
||||
workspaceId?: string
|
||||
} = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
@@ -33,6 +39,14 @@
|
||||
let timeout: number | undefined = undefined
|
||||
let tagsToWorkerExists = $state<Record<string, boolean> | undefined>(undefined)
|
||||
|
||||
// The shared `workerTags` store caches tags for the navigation workspace. When
|
||||
// this select targets a different workspace, read/write a local list instead so
|
||||
// it neither shows the navigation workspace's tags nor clobbers the shared cache.
|
||||
let effectiveWorkspace = $derived(workspaceId ?? $workspaceStore)
|
||||
let usesLocal = $derived(workspaceId != undefined && workspaceId !== $workspaceStore)
|
||||
let localWorkerTags = $state<string[] | undefined>(undefined)
|
||||
let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags)
|
||||
|
||||
onMount(() => {
|
||||
visible = true
|
||||
})
|
||||
@@ -51,19 +65,31 @@
|
||||
async function loadWorkerGroups(force = false) {
|
||||
loading = true
|
||||
try {
|
||||
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!
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
$workerTags = []
|
||||
if (usesLocal) {
|
||||
localWorkerTags = []
|
||||
} else {
|
||||
$workerTags = []
|
||||
}
|
||||
sendUserToast('Error loading custom tags', true)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
let items = $derived([
|
||||
// ...(tag ? ['reset to default'] : [nullTag ? `default: ${nullTag}` : '']),
|
||||
...(tag && tag != '' && !($workerTags ?? []).includes(tag) ? [tag] : []),
|
||||
...($workerTags ?? [])
|
||||
...(tag && tag != '' && !(currentTags ?? []).includes(tag) ? [tag] : []),
|
||||
...(currentTags ?? [])
|
||||
])
|
||||
|
||||
let lastCheck: number | undefined = undefined
|
||||
@@ -77,7 +103,7 @@
|
||||
if (open) {
|
||||
tagsToWorkerExists = await WorkerService.existsWorkersWithTags({
|
||||
tags: tags.join(','),
|
||||
workspace: $workspaceStore
|
||||
workspace: effectiveWorkspace
|
||||
})
|
||||
lastCheck = Date.now()
|
||||
if (visible) {
|
||||
@@ -103,8 +129,8 @@
|
||||
// )
|
||||
|
||||
$effect(() => {
|
||||
if ($workerTags && open) {
|
||||
loadTagsToWorkerExists($workerTags)
|
||||
if (currentTags && open) {
|
||||
loadTagsToWorkerExists(currentTags)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -39,11 +39,21 @@ doesn't reflow the parent.
|
||||
currentRaw?: unknown
|
||||
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
|
||||
inlineDiff?: boolean
|
||||
/** Opt out of Monaco's auto-inline fallback so the parent's inlineDiff/width
|
||||
* decision fully controls the view (used by the drawer's toggle). Default false. */
|
||||
disableAutoInline?: boolean
|
||||
/** For `raw_app_file`: the synthesized per-file diff item to render. */
|
||||
rawFile?: RawAppFileItem
|
||||
}
|
||||
|
||||
let { kind, originalRaw, currentRaw, inlineDiff = false, rawFile }: Props = $props()
|
||||
let {
|
||||
kind,
|
||||
originalRaw,
|
||||
currentRaw,
|
||||
inlineDiff = false,
|
||||
disableAutoInline = false,
|
||||
rawFile
|
||||
}: Props = $props()
|
||||
|
||||
type Prepared = { lang?: string; content?: string; metadata: string }
|
||||
|
||||
@@ -108,6 +118,7 @@ doesn't reflow the parent.
|
||||
beforeMissing={originalRaw == null}
|
||||
afterMissing={currentRaw == null}
|
||||
{inlineDiff}
|
||||
{disableAutoInline}
|
||||
/>
|
||||
</div>
|
||||
{: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}
|
||||
<div class="flex flex-col">
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<DrillPickerHandle | undefined>(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[] {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { Building, ChevronDown, EllipsisVertical, GitFork } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { type Item } from '$lib/utils'
|
||||
|
||||
// Shared chip that displays the workspace a fork picker points at. A fork (or a staged pending fork)
|
||||
// renders "<GitFork> <parent> → <fork>" with the fork accent-highlighted; a root renders
|
||||
// "<Building> <name>". Used as the trigger for both the sidebar workspace header and the session
|
||||
// "Run in" bar, so the two look identical — the only real difference between those consumers is how
|
||||
// they create forks.
|
||||
let {
|
||||
workspaceId,
|
||||
pendingFork,
|
||||
isCollapsed = false,
|
||||
showChevron = true,
|
||||
rootLabel = undefined,
|
||||
wrap = false,
|
||||
color = undefined,
|
||||
interactive = true,
|
||||
menuItems = undefined,
|
||||
disableTitle = false,
|
||||
class: className = ''
|
||||
}: {
|
||||
workspaceId?: string
|
||||
pendingFork?: { id: string; name: string; parent_workspace_id: string }
|
||||
isCollapsed?: boolean
|
||||
// The dropdown-affordance chevron. Off for read-only displays (e.g. the started-session header).
|
||||
showChevron?: boolean
|
||||
// Replaces the root workspace's name with a muted, icon-less label (e.g.
|
||||
// "in root (2 forks)" in the sidebar, where the workspace menu right above
|
||||
// already shows the family name). Fork display is unaffected; the title
|
||||
// keeps the real name.
|
||||
rootLabel?: string
|
||||
// Two-row fork layout for narrow containers like the sidebar: "parent →" on
|
||||
// the first row, the fork name on its own full row (so it isn't sacrificed
|
||||
// to the parent). Each row truncates independently when even a full row is
|
||||
// too narrow. Root display is unaffected.
|
||||
wrap?: boolean
|
||||
// Overrides the fork accent color (used by the creation form's live
|
||||
// preview). Real forks read their workspace's `color` automatically.
|
||||
color?: string
|
||||
// False for read-only displays (e.g. the session header): keeps the chip
|
||||
// visually inert — no hover/press feedback suggesting a click action.
|
||||
interactive?: boolean
|
||||
// Actions menu rendered as an ellipsis segment fused to the end of the
|
||||
// chip (same pill background, own hover). Lets an inert chip carry
|
||||
// workspace-scoped actions without becoming clickable itself. Ignored
|
||||
// in collapsed (icon-only) mode.
|
||||
menuItems?: Item[]
|
||||
// Suppress the chip's native `title` so it doesn't double-pop when a
|
||||
// consumer wraps the chip in a richer hover tooltip (e.g. NameIdTooltip).
|
||||
disableTitle?: boolean
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const root = $derived(findWorkspaceRoot(workspaceId, $userWorkspaces))
|
||||
const currentWs = $derived(
|
||||
workspaceId ? $userWorkspaces.find((w) => w.id === workspaceId) : undefined
|
||||
)
|
||||
const isFork = $derived(!!currentWs && !!root && currentWs.id !== root.id)
|
||||
const showFork = $derived(!!pendingFork || isFork)
|
||||
const name = $derived(pendingFork?.name ?? currentWs?.name ?? workspaceId ?? 'Pick workspace')
|
||||
const parentId = $derived(pendingFork?.parent_workspace_id ?? currentWs?.parent_workspace_id)
|
||||
const parentName = $derived(
|
||||
parentId ? ($userWorkspaces.find((w) => w.id === parentId)?.name ?? parentId) : undefined
|
||||
)
|
||||
// The parent segment only appears for a fork of a fork — forking off the
|
||||
// root is the default and doesn't need spelling out.
|
||||
const shownParent = $derived(parentId !== root?.id ? parentName : undefined)
|
||||
|
||||
// A fork with a workspace color re-themes the whole chip: forkAccentStyle
|
||||
// derives light/dark bg+text at that hue (matching the default blue's
|
||||
// profile) into CSS vars consumed by the accent classes below. Muted
|
||||
// segments (parent, arrow, "(new)") use opacity so they follow whichever
|
||||
// accent is active.
|
||||
// A staged pending fork has no color of its own yet, and its `workspaceId`
|
||||
// resolves to the parent (pending_workspace_id = parent), so reading
|
||||
// `currentWs.color` here would paint it in the parent's hue. Use the default
|
||||
// fork accent instead — unless the creation form passes an explicit `color`
|
||||
// preview.
|
||||
const accentStyle = $derived(
|
||||
showFork ? forkAccentStyle(color ?? (pendingFork ? undefined : currentWs?.color)) : undefined
|
||||
)
|
||||
const forkAccentClasses = $derived(
|
||||
accentStyle
|
||||
? 'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
|
||||
: 'bg-surface-accent-selected text-accent font-semibold'
|
||||
)
|
||||
// Inert mode: pin the hover background to the resting one and drop the
|
||||
// pointer/press affordances (the title tooltip still works).
|
||||
const inertClasses = $derived(
|
||||
interactive
|
||||
? ''
|
||||
: `cursor-default active:opacity-100 ${
|
||||
accentStyle
|
||||
? 'hover:bg-[color:var(--fork-accent-bg)] dark:hover:bg-[color:var(--fork-accent-bg-dark)]'
|
||||
: showFork
|
||||
? 'hover:bg-surface-accent-selected'
|
||||
: 'hover:bg-surface-secondary'
|
||||
}`
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet chipButton(grouped: boolean)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
title={disableTitle ? undefined : showFork && shownParent ? `${shownParent} → ${name}` : name}
|
||||
tabindex={interactive ? undefined : -1}
|
||||
style={accentStyle ?? ''}
|
||||
startIcon={isCollapsed
|
||||
? // Icon-only mode is the fork-picker affordance, whatever is selected.
|
||||
{ icon: GitFork }
|
||||
: !showFork && rootLabel
|
||||
? undefined
|
||||
: showFork && shownParent
|
||||
? // Fork-of-a-fork renders a fork icon inline before EACH name (the
|
||||
// parent is a fork too) — a third leading icon would be noise.
|
||||
undefined
|
||||
: { icon: showFork ? GitFork : Building }}
|
||||
endIcon={showChevron && !isCollapsed ? { icon: ChevronDown } : undefined}
|
||||
wrapperClasses={grouped ? 'min-w-0 flex-1' : className}
|
||||
btnClasses="min-w-0 w-full rounded-md text-2xs {isCollapsed
|
||||
? 'justify-center'
|
||||
: 'justify-start'} {showFork ? forkAccentClasses : 'bg-surface-secondary'} {wrap
|
||||
? // The unified size's fixed h-5 would clip the two-row layout — let the
|
||||
// content set the height and breathe with vertical padding instead. The
|
||||
// min height keeps the single-line variant from looking squashed next
|
||||
// to the two-row one.
|
||||
'h-auto py-1 min-h-6'
|
||||
: ''} {grouped ? 'rounded-r-none' : ''} {inertClasses}"
|
||||
>
|
||||
{#if !isCollapsed}
|
||||
{#if showFork && shownParent}
|
||||
{#if wrap}
|
||||
<!-- Hierarchy shown tree-style: the fork row indents under its parent. -->
|
||||
<span class="min-w-0 flex-1 flex flex-col gap-0.5 text-left">
|
||||
<span class="flex items-center gap-1 min-w-0 opacity-60 font-normal">
|
||||
<GitFork size={12} class="shrink-0" />
|
||||
<span class="truncate">{shownParent}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 min-w-0 pl-3">
|
||||
<GitFork size={12} class="shrink-0" />
|
||||
<span class="truncate">{name}</span>
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 flex items-center gap-1 text-left">
|
||||
<GitFork size={12} class="shrink-0 opacity-60" />
|
||||
<span class="truncate opacity-60 font-normal">{shownParent}</span>
|
||||
<span class="shrink-0 opacity-40 font-normal">→</span>
|
||||
<GitFork size={12} class="shrink-0" />
|
||||
<span class="truncate">{name}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="truncate min-w-0 flex-1 text-left {!showFork && rootLabel ? 'text-hint' : ''}">
|
||||
{showFork ? name : (rootLabel ?? name)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if pendingFork}
|
||||
<span class="shrink-0 opacity-70 font-normal">(new)</span>
|
||||
{:else if currentWs?.is_dev_workspace}
|
||||
<Badge
|
||||
color="dark-blue"
|
||||
small
|
||||
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
|
||||
>{devBadgeText(currentWs.dev_workspace_label)}</Badge
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if menuItems?.length && !isCollapsed}
|
||||
<div class="inline-flex items-stretch min-w-0 {className}">
|
||||
{@render chipButton(true)}
|
||||
<DropdownV2
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
enableFlyTransition
|
||||
class="shrink-0 flex"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
style={accentStyle ?? ''}
|
||||
title="Workspace actions"
|
||||
class="h-full inline-flex items-center px-0.5 rounded-r-md {showFork
|
||||
? forkAccentClasses
|
||||
: 'bg-surface-secondary'} hover:brightness-95 dark:hover:brightness-110"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{:else}
|
||||
{@render chipButton(false)}
|
||||
{/if}
|
||||
@@ -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"
|
||||
>
|
||||
<div class="flex flex-row gap-2 items-center min-w-[200px]">
|
||||
<EditorHeader
|
||||
bind:summary={$summary}
|
||||
bind:path={newEditedPath}
|
||||
savedPath={$appPath || newPath || undefined}
|
||||
kind="app"
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
<div class="flex gap-2 {compactTopbar ? 'hidden' : ''}">
|
||||
<!-- Identity block shrinks/truncates first (min-w-0) so the cloud indicator
|
||||
and the pinned action groups (shrink-0 below) stay visible on narrow
|
||||
widths instead of the breadcrumb overflowing and hiding them. Kept at
|
||||
min-w-0 (not flex-1) so justify-between still positions the panel-hide
|
||||
buttons between the identity and the actions. -->
|
||||
<div class="flex flex-row gap-2 items-center min-w-0">
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<EditorHeader
|
||||
bind:summary={$summary}
|
||||
bind:path={newEditedPath}
|
||||
savedPath={$appPath || newPath || undefined}
|
||||
kind="app"
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0 {compactTopbar ? 'hidden' : ''}">
|
||||
{#if $app}
|
||||
<ToggleButtonGroup
|
||||
selected={$app.fullscreen ? 'true' : 'false'}
|
||||
@@ -1033,7 +1040,7 @@
|
||||
</div>
|
||||
|
||||
{#if $mode !== 'preview'}
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<HideButton
|
||||
direction="left"
|
||||
hidden={leftPanelHidden}
|
||||
@@ -1070,9 +1077,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if $enterpriseLicense && $appPath != ''}
|
||||
<Awareness />
|
||||
<div class="shrink-0">
|
||||
<Awareness />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
|
||||
<div class="relative">
|
||||
<Dropdown items={moreItems} />
|
||||
{#if $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables')) || $tutorialsToDo.includes(getTutorialIndex('connection'))}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Check, Copy } from 'lucide-svelte'
|
||||
import Button from './Button.svelte'
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
|
||||
// Icon-only copy-to-clipboard button: swaps to a green check for a moment
|
||||
// after a successful copy. Safe inside menus/tooltips (clicks don't propagate).
|
||||
let {
|
||||
value,
|
||||
sendToast = false,
|
||||
title = 'Copy to clipboard',
|
||||
class: className = ''
|
||||
}: {
|
||||
value: string
|
||||
// Also pop the global "copied to clipboard" toast on success.
|
||||
sendToast?: boolean
|
||||
// Hover title while not copied (e.g. "Copy id: wm-fork-x"); "Copied!"
|
||||
// always takes over after a copy.
|
||||
title?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
let copied = $state(false)
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function copy() {
|
||||
if (!(await copyToClipboard(value, sendToast))) return
|
||||
copied = true
|
||||
clearTimeout(resetTimeout)
|
||||
resetTimeout = setTimeout(() => (copied = false), 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
title={copied ? 'Copied!' : title}
|
||||
startIcon={copied
|
||||
? { icon: Check, classes: 'text-green-600 dark:text-green-400' }
|
||||
: { icon: Copy }}
|
||||
onClick={copy}
|
||||
wrapperClasses={className}
|
||||
/>
|
||||
@@ -2,7 +2,9 @@ export { default as ActionRow } from './actionRow/ActionRow.svelte'
|
||||
export { default as Alert } from './alert/Alert.svelte'
|
||||
export { default as Badge } from './badge/Badge.svelte'
|
||||
export { default as Button } from './button/Button.svelte'
|
||||
export { default as CopyButton } from './button/CopyButton.svelte'
|
||||
export { default as UndoRedo } from './button/UndoRedo.svelte'
|
||||
export { default as NameIdTooltip } from './tooltip/NameIdTooltip.svelte'
|
||||
export { default as ClearableInput } from './clearableInput/ClearableInput.svelte'
|
||||
export { default as Drawer } from './drawer/Drawer.svelte'
|
||||
export { default as DrawerContent } from './drawer/DrawerContent.svelte'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import CopyButton from '../button/CopyButton.svelte'
|
||||
|
||||
// Rich hover tooltip for anything with a display name and a technical id
|
||||
// (workspaces, scripts, …): shows both plus a copy-id button. Wrap the
|
||||
// hoverable element as children; remove its native `title` so they don't
|
||||
// both pop.
|
||||
let {
|
||||
name,
|
||||
id,
|
||||
placement = 'bottom-start',
|
||||
disablePopup = false,
|
||||
class: className = '',
|
||||
children
|
||||
}: {
|
||||
name: string
|
||||
id: string
|
||||
placement?: Placement
|
||||
// Keeps the trigger rendered but suppresses the popup (e.g. when another
|
||||
// affordance like a collapsed-sidebar popover already covers hover).
|
||||
disablePopup?: boolean
|
||||
class?: string
|
||||
children?: import('svelte').Snippet
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<!-- openDelay keeps the tooltip from popping on casual pointer passes; closeDelay
|
||||
lets the pointer travel from the trigger into the tooltip to reach the copy
|
||||
button without the tooltip vanishing en route. -->
|
||||
<Tooltip
|
||||
{placement}
|
||||
{disablePopup}
|
||||
anchor="cursor"
|
||||
openDelay={700}
|
||||
closeDelay={200}
|
||||
class={className}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#snippet text()}
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="text-xs font-semibold text-emphasis truncate">{name}</div>
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<span class="font-mono text-2xs text-secondary truncate">{id}</span>
|
||||
<CopyButton value={id} />
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
@@ -4,29 +4,25 @@
|
||||
import { aiUserDisabled } from '$lib/stores'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import DarkPopover from '$lib/components/Popover.svelte'
|
||||
import { ExternalLink, WandSparkles } from 'lucide-svelte'
|
||||
import { getModifierKey } from '$lib/utils'
|
||||
import { ExternalLink, MessagesSquare } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
let {
|
||||
togglePanel,
|
||||
btnClasses
|
||||
btnClasses,
|
||||
label = 'Open in AI session'
|
||||
}: {
|
||||
togglePanel: () => void
|
||||
btnClasses?: string
|
||||
/** Tooltip + accessible text of the icon-only button. */
|
||||
label?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
{#if $copilotInfo.enabled}
|
||||
<DarkPopover>
|
||||
{#snippet text()}
|
||||
<div class="flex flex-row gap-1">
|
||||
Show the AI Panel.
|
||||
|
||||
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
|
||||
{getModifierKey()}L
|
||||
</div>
|
||||
</div>
|
||||
{label}
|
||||
{/snippet}
|
||||
{@render button({ onPress: () => togglePanel() })}
|
||||
</DarkPopover>
|
||||
@@ -60,10 +56,10 @@
|
||||
color="light"
|
||||
variant="default"
|
||||
onClick={onPress}
|
||||
startIcon={{ icon: WandSparkles }}
|
||||
startIcon={{ icon: MessagesSquare }}
|
||||
iconOnly
|
||||
{btnClasses}
|
||||
>
|
||||
AI Panel
|
||||
{label}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
forceDisabledMessage = '',
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
inputPreface
|
||||
inputPreface,
|
||||
initialInstructions = undefined,
|
||||
onDraftChange = undefined
|
||||
}: {
|
||||
hideHeader?: boolean
|
||||
hideModeSelector?: boolean
|
||||
@@ -35,6 +37,9 @@
|
||||
wideLayout?: boolean
|
||||
emptyHint?: import('svelte').Snippet
|
||||
inputPreface?: import('svelte').Snippet
|
||||
// Seed / observe the composer's draft text (forwarded to AIChatDisplay).
|
||||
initialInstructions?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
} = $props()
|
||||
|
||||
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
@@ -174,4 +179,6 @@
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
{inputPreface}
|
||||
{initialInstructions}
|
||||
{onDraftChange}
|
||||
></AIChatDisplay>
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
hideModeSelector = false,
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
inputPreface
|
||||
inputPreface,
|
||||
initialInstructions = undefined,
|
||||
onDraftChange = undefined
|
||||
}: {
|
||||
messages: DisplayMessage[]
|
||||
pastChats: { id: string; title: string }[]
|
||||
@@ -142,6 +144,9 @@
|
||||
wideLayout?: boolean
|
||||
emptyHint?: Snippet
|
||||
inputPreface?: Snippet
|
||||
// Seed / observe the main composer's draft text (see AIChatInput).
|
||||
initialInstructions?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
} = $props()
|
||||
|
||||
let aiChatInput: AIChatInput | undefined = $state()
|
||||
@@ -707,6 +712,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
bind:this={aiChatInput}
|
||||
bind:selectedContext
|
||||
{availableContext}
|
||||
{initialInstructions}
|
||||
{onDraftChange}
|
||||
showContext={aiChatManager.mode !== AIMode.GLOBAL}
|
||||
disabled={disabled || hasActiveUserQuestion}
|
||||
isFirstMessage={messages.length === 0}
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
loading?: boolean
|
||||
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
|
||||
onCancel?: () => void
|
||||
// Observe the composer draft as it changes (the text is local state —
|
||||
// `aiChatManager.instructions` only carries programmatic prompts). Used by
|
||||
// sessions to persist the typed-but-unsent prompt with the session draft.
|
||||
onDraftChange?: (text: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -61,7 +65,8 @@
|
||||
bottomRightSnippet,
|
||||
onKeyDown = undefined,
|
||||
loading,
|
||||
onCancel
|
||||
onCancel,
|
||||
onDraftChange = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// GLOBAL-mode suggestion pool. We pick one at mount-time so each new
|
||||
@@ -118,6 +123,10 @@
|
||||
let contextTextareaComponent: ContextTextarea | undefined = $state()
|
||||
let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state()
|
||||
let instructions = $state(untrack(() => initialInstructions))
|
||||
$effect(() => {
|
||||
const text = instructions
|
||||
untrack(() => onDraftChange?.(text))
|
||||
})
|
||||
// Collapsed big-paste blobs referenced by tokens in `instructions`.
|
||||
let pastes = $state<PasteAttachment[]>(untrack(() => initialPastes ?? []))
|
||||
|
||||
|
||||
@@ -345,6 +345,18 @@ export class AIChatManager {
|
||||
// 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
|
||||
// Resolves the workspace this chat operates on. Session chats set it to their
|
||||
// own (possibly forked) workspace so the chat targets it WITHOUT switching the
|
||||
// global workspaceStore. Undefined for the global side-panel chat, which
|
||||
// follows the active workspace. Always read via `operatingWorkspace`.
|
||||
workspaceResolver: (() => string | undefined) | undefined = undefined
|
||||
|
||||
// The workspace every workspace-scoped chat action targets — skills, tool
|
||||
// loop, logging, user-message context, and commit. Session-resolved when a
|
||||
// resolver is set, else the globally-active workspace.
|
||||
get operatingWorkspace(): string | undefined {
|
||||
return this.workspaceResolver?.() ?? get(workspaceStore)
|
||||
}
|
||||
|
||||
// Fired whenever the active chat id changes away from the one the consumer
|
||||
// knows (a "/clear" rotation or a history switch). Session runtimes wire this
|
||||
@@ -1088,7 +1100,7 @@ export class AIChatManager {
|
||||
this.systemMessage = systemMessage
|
||||
}
|
||||
|
||||
refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
|
||||
refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => {
|
||||
const refreshId = ++this.globalSkillsRefreshId
|
||||
const skills = await loadWorkspaceSkills(workspace)
|
||||
if (refreshId !== this.globalSkillsRefreshId) {
|
||||
@@ -1349,11 +1361,19 @@ export class AIChatManager {
|
||||
get webSearch() {
|
||||
return isWebSearchEnabledForProvider(getCurrentModel().provider)
|
||||
},
|
||||
clients: {
|
||||
openai: workspaceAIClients.getOpenaiClient(),
|
||||
anthropic: workspaceAIClients.getAnthropicClient()
|
||||
// Build the proxy clients against the operating workspace, not the global
|
||||
// singleton: a session deliberately leaves workspaceStore untouched, so the
|
||||
// singleton (init'd only on global workspace changes) would route the LLM
|
||||
// request through the navigation workspace's /ai/proxy instead of the
|
||||
// session's — sending it to the wrong workspace's AI credentials.
|
||||
get clients() {
|
||||
const ws = self.operatingWorkspace ?? ''
|
||||
return {
|
||||
openai: workspaceAIClients.createOpenaiClient(ws),
|
||||
anthropic: workspaceAIClients.createAnthropicClient(ws)
|
||||
}
|
||||
},
|
||||
workspace: get(workspaceStore) ?? '',
|
||||
workspace: this.operatingWorkspace ?? '',
|
||||
skipResponsesApi: this.skipResponsesApi,
|
||||
onSkipResponsesApi: () => {
|
||||
this.skipResponsesApi = true
|
||||
@@ -1378,7 +1398,7 @@ export class AIChatManager {
|
||||
return prepareGlobalUserMessage(
|
||||
pendingPrompt,
|
||||
this.contextManager.getSelectedContext(),
|
||||
{ workspace: get(workspaceStore) }
|
||||
{ workspace: this.operatingWorkspace }
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
@@ -1581,7 +1601,7 @@ export class AIChatManager {
|
||||
// Session chats commit their workspace in beforeSend; skills must match the
|
||||
// committed workspace before the system prompt is sent.
|
||||
if (this.mode === AIMode.GLOBAL) {
|
||||
await this.refreshGlobalSkills(get(workspaceStore) ?? '')
|
||||
await this.refreshGlobalSkills(this.operatingWorkspace ?? '')
|
||||
}
|
||||
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
|
||||
// Declared outside `try` so the catch can recover what the loop produced
|
||||
@@ -1609,7 +1629,7 @@ export class AIChatManager {
|
||||
const model = tryGetCurrentModel()
|
||||
if (model) {
|
||||
WorkspaceService.logAiChat({
|
||||
workspace: get(workspaceStore) ?? '',
|
||||
workspace: this.operatingWorkspace ?? '',
|
||||
requestBody: {
|
||||
session_id: this.historyManager.getCurrentChatId(),
|
||||
provider: model.provider,
|
||||
@@ -1693,7 +1713,7 @@ export class AIChatManager {
|
||||
break
|
||||
case AIMode.GLOBAL:
|
||||
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
|
||||
workspace: get(workspaceStore)
|
||||
workspace: this.operatingWorkspace
|
||||
})
|
||||
break
|
||||
case AIMode.APP:
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
interface Props {
|
||||
noPadding?: boolean
|
||||
isCollapsed?: boolean
|
||||
// Rail width in rem (scales with the root font-size, like the rail itself).
|
||||
sidebarWidth?: number
|
||||
transitionClass?: string
|
||||
isMobile?: boolean
|
||||
children: any
|
||||
onMenuOpen?: () => void
|
||||
@@ -22,13 +24,18 @@
|
||||
}
|
||||
let {
|
||||
noPadding: noBorder = false,
|
||||
isCollapsed = false,
|
||||
sidebarWidth = 13,
|
||||
transitionClass = 'transition-all ease-in-out duration-200',
|
||||
isMobile = false,
|
||||
children,
|
||||
onMenuOpen,
|
||||
disableAi
|
||||
}: Props = $props()
|
||||
|
||||
// The desktop rail is fixed-positioned, so the content is offset by a matching
|
||||
// left padding (in rem, matching the rail). Mobile/operator/borderless: no rail.
|
||||
let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth)
|
||||
|
||||
$effect(() => {
|
||||
if (disableAi) {
|
||||
chatState.size = 0
|
||||
@@ -73,11 +80,8 @@
|
||||
<Pane size={100 - chatState.size} minSize={50} class="flex flex-col grow min-h-0 ">
|
||||
<div
|
||||
id="content"
|
||||
class={classNames(
|
||||
'w-full flex-1 flex flex-col overflow-y-auto min-h-0',
|
||||
noBorder || $userStore?.operator || isMobile ? '!pl-0' : isCollapsed ? 'pl-12' : 'pl-40',
|
||||
'transition-all ease-in-out duration-200'
|
||||
)}
|
||||
class={classNames('w-full flex-1 flex flex-col overflow-y-auto min-h-0', transitionClass)}
|
||||
style:padding-left="{contentPadLeft}rem"
|
||||
>
|
||||
<main class="flex-1 flex flex-col min-h-0">
|
||||
<div class="relative w-full flex-1 flex flex-col min-h-0">
|
||||
@@ -102,11 +106,8 @@
|
||||
{:else}
|
||||
<div
|
||||
id="content"
|
||||
class={classNames(
|
||||
'flex-1 min-h-0 flex flex-col',
|
||||
noBorder || $userStore?.operator || isMobile ? '' : isCollapsed ? 'pl-12' : 'pl-40',
|
||||
'transition-all ease-in-out duration-200'
|
||||
)}
|
||||
class={classNames('flex-1 min-h-0 flex flex-col', transitionClass)}
|
||||
style:padding-left="{contentPadLeft}rem"
|
||||
>
|
||||
{@render burgerRow()}
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
|
||||
@@ -564,6 +564,18 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
|
||||
)
|
||||
}
|
||||
|
||||
// Fires after every tool call resolves, with the tool name. Lets a host (e.g.
|
||||
// the sessions page) react to mutating tools — refreshing previews — without
|
||||
// the tool layer knowing about the UI. Single slot; the consumer filters by name
|
||||
// and reads the tool args (e.g. the mutated item's `path`) to scope its refresh.
|
||||
let toolCompletionListener: ((toolName: string, args: any) => void) | undefined
|
||||
|
||||
export function setToolCompletionListener(
|
||||
fn: ((toolName: string, args: any) => void) | undefined
|
||||
): void {
|
||||
toolCompletionListener = fn
|
||||
}
|
||||
|
||||
async function callTool<T>({
|
||||
tools,
|
||||
functionName,
|
||||
@@ -587,7 +599,9 @@ async function callTool<T>({
|
||||
`Unknown tool call: ${functionName}. Probably not in the correct mode, use the change_mode tool to switch to the correct mode.`
|
||||
)
|
||||
}
|
||||
return tool.fn({ args, workspace, helpers, toolCallbacks, toolId })
|
||||
const result = await tool.fn({ args, workspace, helpers, toolCallbacks, toolId })
|
||||
toolCompletionListener?.(functionName, args)
|
||||
return result
|
||||
}
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
|
||||
@@ -5,3 +5,8 @@ export interface ButtonProp {
|
||||
color?: ButtonType.Color
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
// Below this editor width, side-by-side is too cramped and the diff falls back
|
||||
// to the unified/inline view. Shared so consumers (e.g. WorkspaceDiffDrawer's
|
||||
// view toggle) can mirror the same threshold instead of duplicating the number.
|
||||
export const SIDE_BY_SIDE_MIN_WIDTH = 700
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
|
||||
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
|
||||
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import WindmillIcon from '../icons/WindmillIcon.svelte'
|
||||
import { Skeleton } from '../common'
|
||||
import { getContext, onDestroy, onMount, setContext } from 'svelte'
|
||||
@@ -52,6 +53,7 @@
|
||||
aiChatOpen?: boolean
|
||||
showFlowAiButton?: boolean
|
||||
toggleAiChat?: () => void
|
||||
sessionOpen?: OpenInSessionSource
|
||||
localModuleStates?: Record<string, GraphModuleState>
|
||||
testModuleStates?: ModulesTestStates
|
||||
isOwner?: boolean
|
||||
@@ -90,6 +92,7 @@
|
||||
aiChatOpen,
|
||||
showFlowAiButton,
|
||||
toggleAiChat,
|
||||
sessionOpen,
|
||||
isOwner,
|
||||
onTestFlow,
|
||||
isRunning,
|
||||
@@ -207,6 +210,7 @@
|
||||
{aiChatOpen}
|
||||
{showFlowAiButton}
|
||||
{toggleAiChat}
|
||||
{sessionOpen}
|
||||
{isOwner}
|
||||
{onTestFlow}
|
||||
{isRunning}
|
||||
|
||||
@@ -19,20 +19,39 @@
|
||||
isPreprocessor: boolean
|
||||
} = $props()
|
||||
|
||||
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { flowStore, selectionManager, opWorkspace } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
// A fork-scoped session deploys to opWorkspace, not $workspaceStore. Keep a local
|
||||
// tag list in that case so the gate reflects the fork without clobbering the
|
||||
// shared, navigation-scoped `workerTags` cache.
|
||||
let effectiveWorkspace = $derived(opWorkspace?.() ?? $workspaceStore)
|
||||
let usesLocal = $derived(
|
||||
effectiveWorkspace != undefined && effectiveWorkspace !== $workspaceStore
|
||||
)
|
||||
let localWorkerTags = $state<string[] | undefined>(undefined)
|
||||
let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags)
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
loadWorkerGroups()
|
||||
|
||||
async function loadWorkerGroups() {
|
||||
if (!$workerTags) {
|
||||
$workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: $workspaceStore! })
|
||||
if (usesLocal) {
|
||||
if (!localWorkerTags) {
|
||||
localWorkerTags = await WorkerService.getCustomTagsForWorkspace({
|
||||
workspace: effectiveWorkspace!
|
||||
})
|
||||
}
|
||||
} else if (!$workerTags) {
|
||||
$workerTags = await WorkerService.getCustomTagsForWorkspace({
|
||||
workspace: effectiveWorkspace!
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $workerTags}
|
||||
{#if $workerTags?.length > 0}
|
||||
{#if currentTags}
|
||||
{#if currentTags?.length > 0}
|
||||
<div class="w-40">
|
||||
{#if flowStore.val.tag == undefined || isPreprocessor || flowStore.val.value?.preserve_step_tags}
|
||||
<WorkerTagSelect
|
||||
@@ -40,6 +59,7 @@
|
||||
{placeholder}
|
||||
{nullTag}
|
||||
bind:tag
|
||||
workspaceId={usesLocal ? effectiveWorkspace : undefined}
|
||||
on:change={(e) => dispatch('change', e.detail)}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
pathStore,
|
||||
customUi,
|
||||
preserveOnBehalfOf,
|
||||
savedOnBehalfOfEmail
|
||||
savedOnBehalfOfEmail,
|
||||
opWorkspace
|
||||
} = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
const WM_DEPLOYERS_GROUP = 'wm_deployers'
|
||||
@@ -205,7 +206,11 @@
|
||||
|
||||
{#if displayWorkerTagPicker}
|
||||
<div transition:slide={{ duration: 120 }} class="mt-2">
|
||||
<WorkerTagPicker bind:tag={flowStore.val.tag} popupPlacement="top-end" />
|
||||
<WorkerTagPicker
|
||||
bind:tag={flowStore.val.tag}
|
||||
popupPlacement="top-end"
|
||||
workspaceId={opWorkspace?.()}
|
||||
/>
|
||||
</div>
|
||||
{#if flowStore.val.tag}
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { createEventDispatcher, getContext, tick } from 'svelte'
|
||||
import {
|
||||
createInlineScriptModule,
|
||||
@@ -86,6 +87,7 @@
|
||||
aiChatOpen?: boolean
|
||||
showFlowAiButton?: boolean
|
||||
toggleAiChat?: () => void
|
||||
sessionOpen?: OpenInSessionSource
|
||||
isOwner?: boolean
|
||||
onTestFlow?: () => void
|
||||
isRunning?: boolean
|
||||
@@ -117,6 +119,7 @@
|
||||
aiChatOpen,
|
||||
showFlowAiButton,
|
||||
toggleAiChat,
|
||||
sessionOpen,
|
||||
isOwner,
|
||||
onTestFlow,
|
||||
isRunning,
|
||||
@@ -561,6 +564,7 @@
|
||||
on:generateStep
|
||||
{aiChatOpen}
|
||||
{toggleAiChat}
|
||||
{sessionOpen}
|
||||
{noteMode}
|
||||
{toggleNoteMode}
|
||||
{diffManager}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
import OpenInSessionButton, {
|
||||
type OpenInSessionSource
|
||||
} from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
|
||||
interface Props {
|
||||
disableSettings?: boolean
|
||||
@@ -17,6 +20,7 @@
|
||||
aiChatOpen?: boolean
|
||||
showFlowAiButton?: boolean
|
||||
toggleAiChat?: () => void
|
||||
sessionOpen?: OpenInSessionSource
|
||||
noteMode?: boolean
|
||||
toggleNoteMode?: () => void
|
||||
disableAi?: boolean
|
||||
@@ -31,6 +35,7 @@
|
||||
aiChatOpen,
|
||||
showFlowAiButton,
|
||||
toggleAiChat,
|
||||
sessionOpen,
|
||||
noteMode,
|
||||
toggleNoteMode,
|
||||
disableAi,
|
||||
@@ -68,7 +73,13 @@
|
||||
</Popover>
|
||||
{/if}
|
||||
<Popover>
|
||||
<FlowErrorHandlerItem {disableAi} small={smallErrorHandler} {compact} {diffManager} on:generateStep />
|
||||
<FlowErrorHandlerItem
|
||||
{disableAi}
|
||||
small={smallErrorHandler}
|
||||
{compact}
|
||||
{diffManager}
|
||||
on:generateStep
|
||||
/>
|
||||
{#snippet text()}
|
||||
Error Handler
|
||||
{/snippet}
|
||||
@@ -90,17 +101,17 @@
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if showFlowAiButton}
|
||||
<Popover>
|
||||
<AIButton
|
||||
togglePanel={() => {
|
||||
toggleAiChat?.()
|
||||
}}
|
||||
btnClasses={AIBtnClasses(aiChatOpen ? 'selected' : 'default')}
|
||||
/>
|
||||
{#snippet text()}
|
||||
Flow AI Chat
|
||||
<OpenInSessionButton source={sessionOpen}>
|
||||
{#snippet fallback()}
|
||||
<AIButton
|
||||
label="Open AI chat"
|
||||
togglePanel={() => {
|
||||
toggleAiChat?.()
|
||||
}}
|
||||
btnClasses={AIBtnClasses(aiChatOpen ? 'selected' : 'default')}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</OpenInSessionButton>
|
||||
{/if}
|
||||
<Popover>
|
||||
<Button
|
||||
|
||||
@@ -98,6 +98,10 @@ export type FlowEditorContext = {
|
||||
// locally-edited workspace scripts, passed as temp_script_refs on preview
|
||||
// runs so relative imports resolve from local (not-yet-deployed) content
|
||||
devTempScriptRefs?: () => Record<string, string> | undefined
|
||||
// The workspace this editor operates on (deploy/save target). Differs from
|
||||
// $workspaceStore inside a fork-scoped session; worker-tag pickers read it so
|
||||
// their tag list and availability match the deploy target. Getter for reactivity.
|
||||
opWorkspace?: () => string | undefined
|
||||
}
|
||||
|
||||
export type FlowGraphAssetContext = StateStore<{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createBubbler } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { placementFly } from '$lib/utils/placementFly'
|
||||
import { melt, createSync } from '@melt-ui/svelte'
|
||||
import type { MenubarBuilders } from '@melt-ui/svelte'
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
@@ -60,7 +61,15 @@
|
||||
fitViewport: true,
|
||||
strategy: 'fixed'
|
||||
},
|
||||
loop: true
|
||||
loop: true,
|
||||
// Hover tooltips (e.g. NameIdTooltip on menu rows) portal to body, so a
|
||||
// click inside one — like its copy button — registers as an outside click.
|
||||
// Veto the close so interacting with a tooltip doesn't tear the menu down.
|
||||
onOutsideClick: (e) => {
|
||||
if ((e.target as HTMLElement)?.closest?.('[data-melt-tooltip-content]')) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
//Melt
|
||||
@@ -81,7 +90,11 @@
|
||||
}
|
||||
|
||||
async function getMenuElements(): Promise<HTMLElement[]> {
|
||||
return Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[]
|
||||
// Tooltip content counts as menu territory for the same reason as the
|
||||
// onOutsideClick veto above.
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-menu], [data-melt-tooltip-content]')
|
||||
) as HTMLElement[]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -112,6 +125,7 @@
|
||||
<div
|
||||
use:melt={$menuElement}
|
||||
data-menu
|
||||
transition:placementFly={{ duration: 100, placement }}
|
||||
class={twMerge(
|
||||
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none',
|
||||
// Default: scroll on the melt element. submenuSafe moves it to the inner
|
||||
@@ -125,7 +139,7 @@
|
||||
>
|
||||
<div
|
||||
class={twMerge('py-1', submenuSafe ? 'overflow-y-auto' : '')}
|
||||
style="max-height: {maxHeight}px; "
|
||||
style="max-height: min({maxHeight}px, calc(100vh - 6rem)); "
|
||||
>
|
||||
{@render children?.({ item, open, builders })}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { createPopover, createSync, melt } from '@melt-ui/svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { placementFly } from '$lib/utils/placementFly'
|
||||
import { X, Minimize2, Maximize2 } from 'lucide-svelte'
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
import { debounce, pointerDownOutside } from '$lib/utils'
|
||||
@@ -264,7 +264,7 @@
|
||||
}}
|
||||
onmouseleave={debounceClose}
|
||||
use:melt={$_content}
|
||||
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
|
||||
transition:placementFly={{ duration: enableFlyTransition ? 100 : 0, placement }}
|
||||
class={twMerge(
|
||||
'relative dark:border rounded-md bg-surface-tertiary shadow-lg',
|
||||
fullScreen
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
customBgClass?: string | undefined
|
||||
style?: string
|
||||
class?: string
|
||||
// 'cursor' anchors the popup to the pointer position where hover started,
|
||||
// instead of the trigger element's box. Useful for wide/full-width triggers
|
||||
// where an element-anchored tooltip lands far from the cursor.
|
||||
anchor?: 'element' | 'cursor'
|
||||
children?: import('svelte').Snippet
|
||||
text?: import('svelte').Snippet
|
||||
}
|
||||
@@ -36,6 +40,7 @@
|
||||
customBgClass = undefined,
|
||||
style = '',
|
||||
class: className = '',
|
||||
anchor = 'element',
|
||||
children,
|
||||
text
|
||||
}: Props = $props()
|
||||
@@ -52,9 +57,55 @@
|
||||
group: true,
|
||||
portal: untrack(() => portal)
|
||||
})
|
||||
|
||||
// Cursor anchoring: floating-ui positions against `reference.getBoundingClientRect()`.
|
||||
// melt uses the trigger element as that reference, so we override its rect to a
|
||||
// zero-size box at the pointer. The coords are frozen while the tooltip is open so
|
||||
// it stays put (letting the pointer travel into the popup to reach the copy button);
|
||||
// they only track the pointer while closed, capturing where the next open will land.
|
||||
let triggerEl = $state<HTMLElement | undefined>(undefined)
|
||||
let cursorX = 0
|
||||
let cursorY = 0
|
||||
// Until a pointer is seen, fall back to the element rect so keyboard-focus opens
|
||||
// don't land the popup at (0, 0).
|
||||
let hasCursor = false
|
||||
$effect(() => {
|
||||
if (anchor !== 'cursor' || !triggerEl) return
|
||||
const el = triggerEl
|
||||
// Listeners added imperatively (not `onpointermove` attrs) so the static span
|
||||
// keeps no interaction handlers, avoiding an a11y_no_static_element warning.
|
||||
const track = (e: PointerEvent) => {
|
||||
if ($open) return
|
||||
cursorX = e.clientX
|
||||
cursorY = e.clientY
|
||||
hasCursor = true
|
||||
}
|
||||
el.addEventListener('pointerenter', track)
|
||||
el.addEventListener('pointermove', track)
|
||||
const original = el.getBoundingClientRect.bind(el)
|
||||
el.getBoundingClientRect = () =>
|
||||
hasCursor
|
||||
? ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
top: cursorY,
|
||||
left: cursorX,
|
||||
right: cursorX,
|
||||
bottom: cursorY,
|
||||
toJSON() {}
|
||||
} as DOMRect)
|
||||
: original()
|
||||
return () => {
|
||||
el.removeEventListener('pointerenter', track)
|
||||
el.removeEventListener('pointermove', track)
|
||||
el.getBoundingClientRect = original
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<span class={className} {style} use:melt={$trigger}>
|
||||
<span bind:this={triggerEl} class={className} {style} use:melt={$trigger}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{#if !children}
|
||||
|
||||
@@ -166,6 +166,11 @@
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
// Workspace this editor operates on: the session's acting workspace when
|
||||
// embedded in a session preview (autosaveWorkspace), else the navigation
|
||||
// workspace. Deploy/save/background-runner must target it, not $workspaceStore.
|
||||
const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
|
||||
|
||||
// Convert to object format for child components
|
||||
let dataTableRefsObjects = $derived(data.tables.map(parseDataTableRef))
|
||||
let dataTableWhitelist = $derived(buildDataTableWhitelist(dataTableRefsObjects))
|
||||
@@ -520,6 +525,24 @@
|
||||
'boolean'
|
||||
)
|
||||
|
||||
// Auto-compact when the editor opens in a narrow container (e.g. the session
|
||||
// preview pane): drop to the merged single-pane view and retract the file
|
||||
// sidebar. Applied once, on the first measured layout — later resizes are the
|
||||
// user's call. The sidebar is set without persisting so a transient narrow
|
||||
// open never overrides the user's saved expand/collapse preference.
|
||||
let rootWidth = $state(0)
|
||||
const NARROW_PX = 900
|
||||
let appliedNarrowDefault = false
|
||||
$effect(() => {
|
||||
const w = rootWidth
|
||||
if (appliedNarrowDefault || w <= 0) return
|
||||
appliedNarrowDefault = true
|
||||
if (w < NARROW_PX) {
|
||||
splitWithPreview = false
|
||||
sidebarCollapsed.setWithoutPersist(true)
|
||||
}
|
||||
})
|
||||
|
||||
function handleYamlApply(update: RawAppYamlUpdate) {
|
||||
if (update.summary !== undefined) {
|
||||
summary = update.summary
|
||||
@@ -602,10 +625,10 @@
|
||||
let sharedUiLoaded = $state(false)
|
||||
|
||||
async function loadSharedUi() {
|
||||
if (!$workspaceStore) return
|
||||
if (!opWorkspace) return
|
||||
try {
|
||||
const res = (await WorkspaceService.getSharedUi({
|
||||
workspace: $workspaceStore
|
||||
workspace: opWorkspace
|
||||
})) as { files?: Record<string, string>; version?: number }
|
||||
sharedUiFiles = res.files ?? {}
|
||||
sharedUiVersion = res.version ?? 0
|
||||
@@ -889,12 +912,12 @@
|
||||
handleHistorySelect(id)
|
||||
},
|
||||
listDatatableTables: async (): Promise<AppDatatableMetadata[]> => {
|
||||
if (!$workspaceStore) {
|
||||
if (!opWorkspace) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tables = await WorkspaceService.listDataTableTables({
|
||||
workspace: $workspaceStore
|
||||
workspace: opWorkspace
|
||||
})
|
||||
return filterDatatableTables(tables)
|
||||
},
|
||||
@@ -903,7 +926,7 @@
|
||||
schemaName: string,
|
||||
tableName: string
|
||||
): Promise<Record<string, string>> => {
|
||||
if (!$workspaceStore) {
|
||||
if (!opWorkspace) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -917,7 +940,7 @@
|
||||
}
|
||||
|
||||
const schema = await WorkspaceService.getDataTableTableSchema({
|
||||
workspace: $workspaceStore,
|
||||
workspace: opWorkspace,
|
||||
datatableName,
|
||||
schemaName,
|
||||
tableName
|
||||
@@ -933,13 +956,13 @@
|
||||
sql: string,
|
||||
newTable?: { schema: string; name: string }
|
||||
): Promise<{ success: boolean; result?: Record<string, any>[]; error?: string }> => {
|
||||
if (!$workspaceStore) {
|
||||
if (!opWorkspace) {
|
||||
return { success: false, error: 'Workspace not available' }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runScriptAndPollResult({
|
||||
workspace: $workspaceStore,
|
||||
workspace: opWorkspace,
|
||||
requestBody: {
|
||||
language: 'postgresql',
|
||||
content: sql,
|
||||
@@ -1540,9 +1563,9 @@
|
||||
// Force an immediate flush. No toast — the AutosaveIndicator narrates the
|
||||
// result, and `flush` never rejects (postSave routes errors to the failures map).
|
||||
function flushDraft() {
|
||||
if (!$workspaceStore || !liveEditorDraftStoragePath) return
|
||||
if (!opWorkspace || !liveEditorDraftStoragePath) return
|
||||
void UserDraftDbSyncer.flush({
|
||||
workspace: $workspaceStore,
|
||||
workspace: opWorkspace,
|
||||
itemKind: 'raw_app',
|
||||
path: liveEditorDraftStoragePath
|
||||
})
|
||||
@@ -1610,7 +1633,7 @@
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<RawAppBackgroundRunner
|
||||
workspace={$workspaceStore ?? ''}
|
||||
workspace={opWorkspace ?? ''}
|
||||
editor
|
||||
iframe={previewIframe}
|
||||
bind:jobs
|
||||
@@ -1620,7 +1643,10 @@
|
||||
gateJobIds={false}
|
||||
extraSourceWindow={() => externalPreviewWindow}
|
||||
/>
|
||||
<div class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col">
|
||||
<div
|
||||
bind:clientWidth={rootWidth}
|
||||
class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col"
|
||||
>
|
||||
<RawAppEditorHeader
|
||||
bind:jobs
|
||||
bind:jobsById
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
import {
|
||||
@@ -213,7 +215,7 @@
|
||||
|
||||
// The AutosaveIndicator watches these; in the sessions preview they're the
|
||||
// session's (workspace, path), else the full-page editor's own values.
|
||||
const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
|
||||
const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
|
||||
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
|
||||
|
||||
$effect(() => {
|
||||
@@ -240,8 +242,8 @@
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
if (liveEditorDraftStoragePath === undefined || !opWorkspace) return
|
||||
const workspace = opWorkspace
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'raw_app',
|
||||
@@ -331,7 +333,7 @@
|
||||
try {
|
||||
const { js, css } = await getBundle()
|
||||
await AppService.createAppRaw({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: opWorkspace!,
|
||||
formData: {
|
||||
app: {
|
||||
value: app,
|
||||
@@ -349,7 +351,7 @@
|
||||
})
|
||||
// New path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
invalidateWorkspacePaths(opWorkspace!)
|
||||
savedApp = {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -413,7 +415,7 @@
|
||||
|
||||
async function syncWithDeployed() {
|
||||
const deployedApp = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: opWorkspace!,
|
||||
path: appPath!,
|
||||
withStarredInfo: true
|
||||
})
|
||||
@@ -452,7 +454,7 @@
|
||||
policy.execution_mode = 'publisher'
|
||||
}
|
||||
await AppService.updateAppRaw({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: opWorkspace!,
|
||||
path: appPath!,
|
||||
formData: {
|
||||
app: {
|
||||
@@ -472,7 +474,7 @@
|
||||
css
|
||||
}
|
||||
})
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
invalidateWorkspacePaths(opWorkspace!)
|
||||
savedApp = {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -482,7 +484,7 @@
|
||||
labels: $state.snapshot(labels)
|
||||
}
|
||||
const appHistory = await AppService.getAppHistoryByPath({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: opWorkspace!,
|
||||
path: npath
|
||||
})
|
||||
version = appHistory[0]?.version
|
||||
@@ -506,7 +508,7 @@
|
||||
async function setPublishState(message?: string) {
|
||||
await computeTriggerables()
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: opWorkspace!,
|
||||
path: appPath,
|
||||
requestBody: { policy }
|
||||
})
|
||||
@@ -531,7 +533,7 @@
|
||||
}
|
||||
try {
|
||||
const appVersion = await AppService.getAppLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: opWorkspace!,
|
||||
path: appPath
|
||||
})
|
||||
onLatest = appVersion?.version === undefined || version === appVersion?.version
|
||||
@@ -770,7 +772,11 @@
|
||||
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"
|
||||
>
|
||||
<div class="flex flex-row gap-2 items-center min-w-[200px]">
|
||||
<!-- Identity block: shrinks/truncates first so the cloud indicator and the
|
||||
action buttons stay visible. Without min-w-0 the breadcrumb + summary
|
||||
overflow this box on narrow widths and get overlapped by the (formerly
|
||||
un-pinned) action group, hiding the autosave cloud. -->
|
||||
<div class="flex flex-row gap-2 items-center min-w-0">
|
||||
{#if onToggleSidebar}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
@@ -781,17 +787,20 @@
|
||||
on:click={() => onToggleSidebar?.()}
|
||||
/>
|
||||
{/if}
|
||||
<EditorHeader
|
||||
bind:summary
|
||||
bind:path={newEditedPath}
|
||||
savedPath={appPath || newPath || undefined}
|
||||
kind="app"
|
||||
raw_app
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
{#if indicatorWorkspace && indicatorPath !== undefined}
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<EditorHeader
|
||||
bind:summary
|
||||
bind:path={newEditedPath}
|
||||
savedPath={appPath || newPath || undefined}
|
||||
kind="app"
|
||||
raw_app
|
||||
workspaceId={autosaveWorkspace}
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
</div>
|
||||
{#if opWorkspace && indicatorPath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={indicatorWorkspace}
|
||||
workspace={opWorkspace}
|
||||
itemKind="raw_app"
|
||||
path={indicatorPath}
|
||||
draftOnly={newApp}
|
||||
@@ -804,9 +813,11 @@
|
||||
</div>
|
||||
|
||||
{#if $enterpriseLicense && appPath != ''}
|
||||
<Awareness />
|
||||
<div class="shrink-0">
|
||||
<Awareness />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
|
||||
<DropdownV2 items={moreItems} class="h-auto">
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
@@ -869,18 +880,37 @@
|
||||
</Button>
|
||||
</div>
|
||||
<AppExportButton bind:this={appExport} />
|
||||
{#if !inSessionPane}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
onClick={() => aiChatManager.toggleOpen()}
|
||||
startIcon={{ icon: WandSparkles }}
|
||||
iconOnly
|
||||
btnClasses={AIBtnClasses('default')}
|
||||
>
|
||||
AI
|
||||
</Button>
|
||||
{/if}
|
||||
<OpenInSessionButton
|
||||
source={appPath
|
||||
? {
|
||||
target: { kind: 'raw_app', path: appPath },
|
||||
workspaceId: opWorkspace ?? undefined,
|
||||
// Flush the autosaved draft so the session preview opens the app
|
||||
// exactly as it is in the editor right now.
|
||||
beforeOpen: () =>
|
||||
opWorkspace && indicatorPath !== undefined
|
||||
? UserDraftDbSyncer.flush({
|
||||
workspace: opWorkspace,
|
||||
itemKind: 'raw_app',
|
||||
path: indicatorPath
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet fallback()}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
onClick={() => aiChatManager.toggleOpen()}
|
||||
startIcon={{ icon: WandSparkles }}
|
||||
iconOnly
|
||||
btnClasses={AIBtnClasses('default')}
|
||||
>
|
||||
AI
|
||||
</Button>
|
||||
{/snippet}
|
||||
</OpenInSessionButton>
|
||||
<Button
|
||||
loading={loading.save}
|
||||
startIcon={{ icon: Save }}
|
||||
@@ -895,10 +925,10 @@
|
||||
window.open(`/apps/add?template=${appPath}`)
|
||||
}
|
||||
},
|
||||
...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces)
|
||||
? [
|
||||
{
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
label: editInForkLabel(opWorkspace, $userWorkspaces),
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('raw_app', appPath))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ the escape hatch, shown only on demand.
|
||||
current?: string
|
||||
lang: string
|
||||
inlineDiff?: boolean
|
||||
/** Forward Monaco's auto-inline opt-out to the underlying DiffEditor. */
|
||||
disableAutoInline?: boolean
|
||||
lineBudget?: number
|
||||
isMetadata?: boolean
|
||||
fullYamlOriginal?: string
|
||||
@@ -28,6 +30,7 @@ the escape hatch, shown only on demand.
|
||||
current,
|
||||
lang,
|
||||
inlineDiff = false,
|
||||
disableAutoInline = false,
|
||||
lineBudget = 1500,
|
||||
isMetadata = false,
|
||||
fullYamlOriginal,
|
||||
@@ -89,6 +92,7 @@ the escape hatch, shown only on demand.
|
||||
defaultOriginal={activeOriginal}
|
||||
defaultModified={activeModified}
|
||||
{inlineDiff}
|
||||
{disableAutoInline}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" module>
|
||||
import type { SessionTarget } from './sessionState.svelte'
|
||||
|
||||
// What an editor hands over for "Open in AI session": the session target it
|
||||
// maps to, the workspace it lives in, and a persist hook run before routing
|
||||
// so the session preview opens the item exactly as currently edited.
|
||||
export type OpenInSessionSource = {
|
||||
target: SessionTarget
|
||||
workspaceId?: string
|
||||
beforeOpen?: () => void | Promise<void>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from 'svelte'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import AIButton from '$lib/components/copilot/chat/AIButton.svelte'
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { openEditorInSession } from './sessionSwitch.svelte'
|
||||
|
||||
let {
|
||||
source,
|
||||
btnClasses,
|
||||
fallback
|
||||
}: {
|
||||
/** Undefined (e.g. an item without a path yet) renders the fallback. */
|
||||
source?: OpenInSessionSource
|
||||
btnClasses?: string
|
||||
/** Rendered instead when the global-AI flag is off (typically the
|
||||
* editor's inline-chat toggle). Never rendered inside the session panel. */
|
||||
fallback?: Snippet
|
||||
} = $props()
|
||||
|
||||
// Inside the session panel the chat is already on screen — a second entry
|
||||
// point would nest sessions, so render nothing at all there. In-realm
|
||||
// preview editors sit under the 'aiChatManager' context (set by
|
||||
// SessionEditorTarget / the session wrapper); iframe preview tabs are not
|
||||
// the top window.
|
||||
const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top)
|
||||
const show = $derived(!inSessionPanel && !!source && isGlobalAiEnabled())
|
||||
|
||||
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
|
||||
let opening = false
|
||||
async function open() {
|
||||
if (opening || !source) return
|
||||
opening = true
|
||||
try {
|
||||
await source.beforeOpen?.()
|
||||
await openEditorInSession(source.target, source.workspaceId)
|
||||
} finally {
|
||||
opening = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<AIButton togglePanel={open} btnClasses={btnClasses ?? AIBtnClasses('default')} />
|
||||
{:else if !inSessionPanel}
|
||||
{@render fallback?.()}
|
||||
{/if}
|
||||
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
@component
|
||||
Preview "router" picker — a `WorkspaceItemDrillPicker` superset used by the
|
||||
session preview breadcrumb. Same drill model and workspace-item branches, plus
|
||||
a leading "Pages" branch (Home, Runs, Workspace settings, …) so the breadcrumb
|
||||
can route the preview iframe to non-item pages too, not just scripts/flows/apps.
|
||||
|
||||
Kept separate from `WorkspaceItemDrillPicker` so the editor breadcrumb (which
|
||||
only ever navigates between items) doesn't grow a Pages section.
|
||||
-->
|
||||
<script lang="ts" module>
|
||||
export type Scope = { kind: 'flow' | 'script' | 'app' | 'all'; dir?: string } | undefined
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { Compass } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { type WorkspaceItem, type WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
import { useWorkspaceItemsLoader } from '$lib/components/workspaceItemsLoader.svelte'
|
||||
import DrillPicker from '../DrillPicker.svelte'
|
||||
import type { DrillBranch, DrillLeaf, DrillNode } from '$lib/components/drillPicker'
|
||||
import {
|
||||
buildWorkspaceTree,
|
||||
legacyScopeToPath,
|
||||
relativizeWorkspacePath
|
||||
} from '$lib/components/workspaceTree'
|
||||
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { PREVIEW_PAGES, pageHref, pageKey, type PreviewTarget } from './previewRouter'
|
||||
|
||||
type Kind = WorkspaceItemKind
|
||||
type DrillPickerHandle = {
|
||||
focus: () => void
|
||||
handleKeydown: (e: KeyboardEvent) => void
|
||||
pickHighlighted: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPick: (target: PreviewTarget) => void
|
||||
initialScope?: Scope
|
||||
initialHighlight?: string
|
||||
currentItem?: WorkspaceItem & { savedPath?: string }
|
||||
externalFilter?: string
|
||||
autoFocus?: boolean
|
||||
flush?: boolean
|
||||
// The session's effective workspace. Items and drafts must load from it,
|
||||
// not the navigation workspace ($workspaceStore) — a fork-scoped session
|
||||
// leaves the global store on the nav workspace, so keying off it would
|
||||
// list root items and miss fork-only ones. Falls back to $workspaceStore
|
||||
// for non-session consumers.
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
let {
|
||||
onPick,
|
||||
initialScope,
|
||||
initialHighlight,
|
||||
currentItem,
|
||||
externalFilter,
|
||||
autoFocus = true,
|
||||
flush = false,
|
||||
workspaceId
|
||||
}: Props = $props()
|
||||
|
||||
const kinds: Kind[] = ['flow', 'script', 'app']
|
||||
const effectiveWorkspace = $derived(workspaceId ?? $workspaceStore)
|
||||
|
||||
let inner = $state<DrillPickerHandle | undefined>(undefined)
|
||||
export function focus() {
|
||||
inner?.focus()
|
||||
}
|
||||
export function handleKeydown(e: KeyboardEvent) {
|
||||
inner?.handleKeydown(e)
|
||||
}
|
||||
export function pickHighlighted() {
|
||||
inner?.pickHighlighted()
|
||||
}
|
||||
|
||||
const loader = useWorkspaceItemsLoader(
|
||||
() => effectiveWorkspace,
|
||||
() => kinds
|
||||
)
|
||||
|
||||
// Surface AI-created drafts the same way the editor picker does so in-flight
|
||||
// chat-scaffolded items are navigable (see WorkspaceItemDrillPicker).
|
||||
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
|
||||
const globalDraftsResource = resource(
|
||||
() => ({ ws: effectiveWorkspace, enabled: isGlobalAiEnabled() }),
|
||||
async ({ ws, enabled }) => (enabled && ws ? await listGlobalDrafts(ws) : [])
|
||||
)
|
||||
function aiDraftsForKind(k: Kind): WorkspaceItem[] {
|
||||
const targetType = KIND_TO_DRAFT_TYPE[k]
|
||||
return (globalDraftsResource.current ?? [])
|
||||
.filter((d) => d.type === targetType)
|
||||
.map((d) => ({
|
||||
path: d.path,
|
||||
summary: d.summary ?? '',
|
||||
kind: k,
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
|
||||
}))
|
||||
}
|
||||
const extraItemsByKind = $derived<Partial<Record<Kind, WorkspaceItem[]>>>(
|
||||
Object.fromEntries(kinds.map((k) => [k, aiDraftsForKind(k)]))
|
||||
)
|
||||
|
||||
// Wrap item leaves as PreviewTargets so the tree can carry page leaves too.
|
||||
// Branch structure is untouched — only leaf `data` is re-tagged.
|
||||
function tagItems(nodes: DrillNode<WorkspaceItem>[]): DrillNode<PreviewTarget>[] {
|
||||
return nodes.map((n) =>
|
||||
n.type === 'leaf'
|
||||
? { ...n, data: { type: 'item', item: n.data } }
|
||||
: { ...n, children: tagItems(n.children) }
|
||||
)
|
||||
}
|
||||
|
||||
function pageLeaf(p: (typeof PREVIEW_PAGES)[number]): DrillLeaf<PreviewTarget> {
|
||||
return {
|
||||
type: 'leaf',
|
||||
key: pageKey(p.path),
|
||||
label: p.label,
|
||||
icon: p.icon,
|
||||
data: { type: 'page', href: pageHref(p.path), label: p.label }
|
||||
}
|
||||
}
|
||||
|
||||
// Home is deliberately not navigable from the session preview — it stays in
|
||||
// PREVIEW_PAGES only so a tab already sitting on '/' keeps its label.
|
||||
const pagesBranch = $derived<DrillBranch<PreviewTarget>>({
|
||||
type: 'branch',
|
||||
key: 'pages',
|
||||
label: 'Pages',
|
||||
icon: Compass,
|
||||
searchGroup: true,
|
||||
children: PREVIEW_PAGES.filter((p) => p.path !== '/').map(pageLeaf)
|
||||
})
|
||||
|
||||
const tree = $derived<DrillNode<PreviewTarget>[]>([
|
||||
pagesBranch,
|
||||
...tagItems(
|
||||
buildWorkspaceTree({
|
||||
loaded: loader.loaded,
|
||||
kinds,
|
||||
currentItem,
|
||||
loadingKind: loader.loadingKind,
|
||||
extraItemsByKind
|
||||
})
|
||||
)
|
||||
])
|
||||
|
||||
const computedInitialScope = untrack(() => legacyScopeToPath(initialScope, kinds))
|
||||
</script>
|
||||
|
||||
{#snippet leafIcon(leaf: DrillLeaf<PreviewTarget>)}
|
||||
{#if leaf.data.type === 'item'}
|
||||
<RowIcon kind={leaf.data.item.kind} size={12} />
|
||||
{:else if leaf.icon}
|
||||
{@const Icon = leaf.icon}
|
||||
<Icon size={12} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet branchIcon(branch: DrillBranch<PreviewTarget>)}
|
||||
{#if branch.key === 'kind:flow' || branch.key === 'kind:script' || branch.key === 'kind:app'}
|
||||
{@const k = branch.key.slice(5) as Kind}
|
||||
<RowIcon kind={k} size={12} />
|
||||
{:else if branch.icon}
|
||||
{@const Icon = branch.icon}
|
||||
<Icon size={12} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<DrillPicker
|
||||
bind:this={inner}
|
||||
{tree}
|
||||
onPick={(leaf) => onPick(leaf.data)}
|
||||
initialScope={computedInitialScope}
|
||||
{initialHighlight}
|
||||
{externalFilter}
|
||||
{autoFocus}
|
||||
{flush}
|
||||
{leafIcon}
|
||||
{branchIcon}
|
||||
leafSecondary={(leaf, scope) =>
|
||||
leaf.data.type === 'item' ? relativizeWorkspacePath(leaf.data.item.path, scope) : undefined}
|
||||
onScopeChange={(scope) => {
|
||||
if (scope.length > 0) loader.ensureForScopeSegment(scope[0])
|
||||
}}
|
||||
onFilterChange={loader.onFilterChange}
|
||||
/>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import {
|
||||
getEffectiveWorkspaceId,
|
||||
sessionState,
|
||||
type Session,
|
||||
type SessionPreviewTab
|
||||
} from './sessionState.svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { resolvePreviewTab } from './previewRouter'
|
||||
import { withMenuHidden } from './sessionMode.svelte'
|
||||
import ScriptEditorView from './ScriptEditorView.svelte'
|
||||
import FlowEditorView from './FlowEditorView.svelte'
|
||||
import RawAppEditorView from './RawAppEditorView.svelte'
|
||||
|
||||
let {
|
||||
tab,
|
||||
session,
|
||||
runtime,
|
||||
active,
|
||||
mounted,
|
||||
label,
|
||||
onNavigate,
|
||||
onLoad
|
||||
}: {
|
||||
tab: SessionPreviewTab
|
||||
session: Session | undefined
|
||||
runtime: SessionRuntime | undefined
|
||||
/** Visible tab — only one is at a time; the rest stay mounted but hidden. */
|
||||
active: boolean
|
||||
/** Lazy-mount gate: content only renders once the tab has been activated. */
|
||||
mounted: boolean
|
||||
/** Short tab label, for the iframe title. */
|
||||
label: string
|
||||
/** A link click inside a live editor re-points the session target + tab. */
|
||||
onNavigate: (item: WorkspaceItem) => void
|
||||
/** Iframe finished loading — the page reads back its observed location. */
|
||||
onLoad: (frame: HTMLIFrameElement) => void
|
||||
} = $props()
|
||||
|
||||
// Editor vs iframe is decided purely from the tab URL + the session's target
|
||||
// (see resolvePreviewTab). Only the target tab of a wrappable kind goes live.
|
||||
const slot = $derived(resolvePreviewTab(tab.url, session?.target))
|
||||
const workspaceId = $derived(
|
||||
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
|
||||
)
|
||||
const isActiveSession = $derived(!!session && sessionState.currentSessionId === session.id)
|
||||
|
||||
let frame: HTMLIFrameElement | undefined = $state()
|
||||
|
||||
export function reload() {
|
||||
// A live editor shares the runtime store the chat mutates, so generic chat
|
||||
// edits are already reflected — no reload needed. Deploys refresh it via
|
||||
// each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the
|
||||
// iframe fallback (a separate page) has to be told to refresh.
|
||||
if (slot.kind === 'editor') return
|
||||
try {
|
||||
const win = frame?.contentWindow
|
||||
if (!win) return
|
||||
// Reload the page the user is actually viewing (observed `loc`, canonical
|
||||
// with nomenubar/workspace stripped), re-injecting nomenubar + workspace.
|
||||
// A plain location.reload() would reload the iframe's current URL, which
|
||||
// in-frame navigation may have stripped of ?workspace= — booting the frame
|
||||
// into the top-level navigation workspace instead of the session fork
|
||||
// (sessionStorage/localStorage are shared with the top window, so the
|
||||
// scoping can only live in the URL). replace() forces the load even when
|
||||
// the target equals the current URL.
|
||||
win.location.replace(withMenuHidden(tab.loc || tab.url, workspaceId || undefined))
|
||||
} catch {
|
||||
// Cross-navigation timing — skip; the next mutation reloads again.
|
||||
}
|
||||
}
|
||||
|
||||
const visibility = $derived(
|
||||
active ? 'z-10 opacity-100 pointer-events-auto' : 'z-0 opacity-0 pointer-events-none'
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if slot.kind === 'editor' && mounted && runtime}
|
||||
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}" aria-hidden={!active}>
|
||||
{#if slot.editorKind === 'flow'}
|
||||
<FlowEditorView {runtime} path={slot.path} {workspaceId} {onNavigate} {isActiveSession} />
|
||||
{:else if slot.editorKind === 'script'}
|
||||
<ScriptEditorView
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
initialTestPanelCollapsed
|
||||
/>
|
||||
{:else}
|
||||
<RawAppEditorView {runtime} path={slot.path} {workspaceId} {onNavigate} {isActiveSession} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if mounted}
|
||||
<iframe
|
||||
bind:this={frame}
|
||||
src={withMenuHidden(tab.url, workspaceId || undefined)}
|
||||
onload={(e) => onLoad(e.currentTarget as HTMLIFrameElement)}
|
||||
title="Session preview: {label}"
|
||||
class="absolute inset-0 w-full h-full border-0 bg-surface {visibility}"
|
||||
></iframe>
|
||||
{/if}
|
||||
@@ -97,21 +97,36 @@
|
||||
wasLoading = isLoading
|
||||
})
|
||||
|
||||
// Refresh when the tab regains visibility — covers edits in another tab / by
|
||||
// another user while we were away.
|
||||
// Refresh when the user comes back to this view — covers edits made
|
||||
// elsewhere while we were away. Two signals: visibilitychange (another tab
|
||||
// in the same window) and window focus (a second browser window, where this
|
||||
// tab never goes hidden so visibilitychange never fires — e.g. deploying
|
||||
// from the full-page editor side by side).
|
||||
$effect(() => {
|
||||
if (!committedId) return
|
||||
function onVisibilityChange() {
|
||||
function refreshIfCurrent() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (sessionState.currentSessionId !== session.id) return
|
||||
refreshDock()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
document.addEventListener('visibilitychange', refreshIfCurrent)
|
||||
window.addEventListener('focus', refreshIfCurrent)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', refreshIfCurrent)
|
||||
window.removeEventListener('focus', refreshIfCurrent)
|
||||
}
|
||||
})
|
||||
|
||||
let diffDrawer: SessionDiffDrawer | undefined = $state(undefined)
|
||||
|
||||
// Opening the drawer re-fetches its own data, so it can show fresher state
|
||||
// than the badge just clicked (e.g. "1 draft" that was deployed elsewhere in
|
||||
// the meantime). Re-sync the dock alongside so the two never disagree.
|
||||
function openDrawer() {
|
||||
refreshDock()
|
||||
diffDrawer?.open()
|
||||
}
|
||||
|
||||
// The dock counts are computed from the same pure model over this chat's
|
||||
// drafts; the readout mirrors the drawer's item states.
|
||||
// Existence check for mask-only (deployed) items — without it they'd be
|
||||
@@ -162,12 +177,12 @@
|
||||
badges (draft/deployed) so the bar reads at a glance. -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#if dockCounts.draft > 0}
|
||||
<Badge small clickable color="indigo" onclick={() => diffDrawer?.open()}>
|
||||
<Badge small clickable color="indigo" onclick={openDrawer}>
|
||||
{dockCounts.draft} draft{dockCounts.draft === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if dockCounts.deployed > 0}
|
||||
<Badge small clickable color="green" onclick={() => diffDrawer?.open()}>
|
||||
<Badge small clickable color="green" onclick={openDrawer}>
|
||||
{dockCounts.deployed} deployed
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack, type Snippet } from 'svelte'
|
||||
import { setContext, untrack, type Snippet } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -39,6 +39,14 @@
|
||||
isActiveSession?: boolean
|
||||
} = $props()
|
||||
|
||||
// Mark this subtree as the session side panel: editors below detect the
|
||||
// context to hide their own AI entry points (an AI button here would nest
|
||||
// sessions), and chat-aware components resolve the session's scoped manager
|
||||
// instead of the app singleton. The value is captured at init — a reused
|
||||
// component instance keeps the first runtime's manager — so descendants may
|
||||
// rely on its presence, not its identity.
|
||||
setContext('aiChatManager', runtime.manager)
|
||||
|
||||
const slot = $derived(runtime.slot(kind))
|
||||
|
||||
function triggerLoad(): Promise<void> {
|
||||
|
||||
@@ -11,16 +11,10 @@
|
||||
// called against this specific menu instance (same pattern as DropdownSubmenuItem).
|
||||
builders: MenubarMenuBuilders
|
||||
showArchived: boolean
|
||||
showAllWorkspaces: boolean
|
||||
archivedCount: number
|
||||
}
|
||||
|
||||
let {
|
||||
builders,
|
||||
showArchived = $bindable(),
|
||||
showAllWorkspaces = $bindable(),
|
||||
archivedCount
|
||||
}: Props = $props()
|
||||
let { builders, showArchived = $bindable(), archivedCount }: Props = $props()
|
||||
|
||||
const {
|
||||
elements: { subTrigger, subMenu },
|
||||
@@ -29,7 +23,7 @@
|
||||
|
||||
// Count of active (non-default) filters, surfaced as a subtle badge on the
|
||||
// trigger so an applied filter is visible without opening the submenu.
|
||||
let activeCount = $derived((showArchived ? 1 : 0) + (showAllWorkspaces ? 1 : 0))
|
||||
let activeCount = $derived(showArchived ? 1 : 0)
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -56,14 +50,6 @@
|
||||
<!-- Rendered as plain content (not a MenuItem) so toggling a filter keeps
|
||||
the submenu open instead of selecting-and-closing the whole menu. -->
|
||||
<div class="px-3 py-1.5 flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle
|
||||
bind:checked={showAllWorkspaces}
|
||||
size="xs"
|
||||
options={{ right: 'Show all workspaces' }}
|
||||
/>
|
||||
<span class="text-2xs text-tertiary pl-1">Include sessions from every workspace.</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle bind:checked={showArchived} size="xs" options={{ right: 'Show archived' }} />
|
||||
{#if archivedCount > 0}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { House, MessagesSquare } from 'lucide-svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { enterSessionMode, exitSessionMode } from './sessionSwitch.svelte'
|
||||
|
||||
// Which side of the switch is active. `nav` = workspace navigation (the classic
|
||||
// app), `session` = the sessions sidebar + chat + preview.
|
||||
let {
|
||||
mode,
|
||||
isCollapsed = false,
|
||||
// Fired right before the toggle navigates. Lets a host (e.g. the mobile menu
|
||||
// drawer) keep itself open across the mode switch.
|
||||
onToggle
|
||||
}: { mode: 'nav' | 'session'; isCollapsed?: boolean; onToggle?: () => void } = $props()
|
||||
|
||||
function onSelected(next: 'nav' | 'session') {
|
||||
if (next === mode) return
|
||||
onToggle?.()
|
||||
if (next === 'session') void enterSessionMode()
|
||||
else void exitSessionMode()
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Each ToggleButton renders inside a Tooltip wrapper, which is the actual flex
|
||||
child of the group's track — so the buttons fill the rail width only if those
|
||||
wrappers grow. `[&>*]:flex-1` makes every direct child split the track evenly. -->
|
||||
<ToggleButtonGroup
|
||||
selected={mode}
|
||||
{onSelected}
|
||||
tabListClass={isCollapsed ? 'flex-col w-full [&>*]:w-full' : 'w-full [&>*]:flex-1'}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="nav"
|
||||
icon={isCollapsed ? House : undefined}
|
||||
label="Workspace"
|
||||
iconOnly={isCollapsed}
|
||||
tooltip={isCollapsed ? 'Workspace' : undefined}
|
||||
size="sm"
|
||||
class="w-full justify-center"
|
||||
/>
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="session"
|
||||
icon={isCollapsed ? MessagesSquare : undefined}
|
||||
label="AI Sessions"
|
||||
iconOnly={isCollapsed}
|
||||
tooltip={isCollapsed ? 'AI Sessions' : undefined}
|
||||
size="sm"
|
||||
class="w-full justify-center"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Building,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
EllipsisVertical,
|
||||
Filter,
|
||||
GitFork,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
PencilLine,
|
||||
@@ -15,8 +16,8 @@
|
||||
Trash2
|
||||
} from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import {
|
||||
@@ -38,12 +39,15 @@
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
getSessionChatStatus,
|
||||
removeSession
|
||||
removeSession,
|
||||
resetSessionPreviewTabs
|
||||
} from './sessionRuntime.svelte'
|
||||
import SessionStatusDot from './SessionStatusDot.svelte'
|
||||
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
|
||||
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
|
||||
import SessionFilterMenu from './SessionFilterMenu.svelte'
|
||||
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import MenuButton, { sidebarClasses } from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
@@ -51,6 +55,15 @@
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { base } from '$app/paths'
|
||||
|
||||
// The row icon only distinguishes "the session's fork workspace no longer
|
||||
// exists" (detached) — never the fork's ahead/behind sync state, which is
|
||||
// the fork bar's job.
|
||||
function forkDetachedFor(session: Session): boolean {
|
||||
return isUnavailableFork(session)
|
||||
}
|
||||
|
||||
function isForkFor(session: Session): boolean {
|
||||
return isForkSession(session, $userWorkspaces)
|
||||
@@ -77,16 +90,39 @@
|
||||
// the feature flag is off, the sidebar section is hidden entirely.
|
||||
const globalEnabled = isGlobalAiEnabled()
|
||||
|
||||
// Only highlight the active session while we're actually on the session
|
||||
// page — once the user navigates away, `currentSessionId` lingers but no
|
||||
// row should appear selected.
|
||||
const onSessionsPage = $derived(page.route.id?.includes('/sessions') ?? false)
|
||||
// Only highlight the active session while the sessions page is open — elsewhere
|
||||
// `currentSessionId` lingers but no row should appear selected.
|
||||
const onSessionsPage = $derived(page.url.pathname.startsWith(`${base}/sessions`))
|
||||
const sessionActive = $derived(onSessionsPage)
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
// When false, the section is always expanded (no collapse chevron) — used
|
||||
// where the picker is the whole rail rather than one sidebar section.
|
||||
collapsible?: boolean
|
||||
// Render the full workspace tree (every workspace, not just ones with
|
||||
// sessions) with clickable workspace rows on top of the nested sessions.
|
||||
workspaceTree?: boolean
|
||||
// The workspace currently being browsed (highlighted) in tree mode.
|
||||
browsedWorkspaceId?: string
|
||||
// Clicking a workspace row → browse it (preview its home, no chat).
|
||||
onBrowseWorkspace?: (workspaceId: string) => void
|
||||
// Clicking a session row → leave browse mode (bring the chat back).
|
||||
onSelectSession?: () => void
|
||||
// Drop the section's own outer padding/border so it sits flush inside a
|
||||
// parent container (e.g. gathered with Favorites/Search in the sidebar).
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
let { isCollapsed = false }: Props = $props()
|
||||
let {
|
||||
isCollapsed = false,
|
||||
collapsible = true,
|
||||
workspaceTree = false,
|
||||
browsedWorkspaceId = undefined,
|
||||
onBrowseWorkspace = undefined,
|
||||
onSelectSession = undefined,
|
||||
embedded = false
|
||||
}: Props = $props()
|
||||
|
||||
const sectionCollapsed = useLocalStorageValue(
|
||||
'windmill_sessions_section_collapsed',
|
||||
@@ -94,15 +130,6 @@
|
||||
'boolean'
|
||||
)
|
||||
const showArchived = useLocalStorageValue('windmill_sessions_show_archived', false, 'boolean')
|
||||
// Off by default: the list is scoped to the current workspace family. Turn on
|
||||
// to include sessions from every workspace (grouped by family) — handy when
|
||||
// switching sessions across workspaces without switching workspace first.
|
||||
const showAllWorkspaces = useLocalStorageValue(
|
||||
'windmill_sessions_show_all_workspaces',
|
||||
false,
|
||||
'boolean'
|
||||
)
|
||||
|
||||
let listRoot: HTMLDivElement | undefined = $state()
|
||||
|
||||
// A session's family root: the stored grouping id, else derived live.
|
||||
@@ -122,10 +149,9 @@
|
||||
// The open session always stays in the list, ignoring both filters.
|
||||
if (s.id === sessionState.currentSessionId) return true
|
||||
if (s.archived && !showArchived.val) return false
|
||||
if (!showAllWorkspaces.val) {
|
||||
const currentRoot = $currentWorkspaceRootId
|
||||
if (currentRoot && sessionRootOf(s) !== currentRoot) return false
|
||||
}
|
||||
// Scope to the current workspace family only.
|
||||
const currentRoot = $currentWorkspaceRootId
|
||||
if (currentRoot && sessionRootOf(s) !== currentRoot) return false
|
||||
return true
|
||||
})
|
||||
)
|
||||
@@ -155,16 +181,69 @@
|
||||
return groups
|
||||
})
|
||||
|
||||
// Family labels are redundant when scoped to the current workspace (a single
|
||||
// family) — show them when including all workspaces, and also if the
|
||||
// active-session override surfaces a second family while scoped (avoids
|
||||
// ambiguity).
|
||||
const showGroupHeaders = $derived(showAllWorkspaces.val || sessionGroups.length > 1)
|
||||
// Workspace tree scoped to the current family (root + its forks), so the rail
|
||||
// only shows the workspaces in the family we're in — not every workspace.
|
||||
const workspaceTreeItems = $derived.by(() => {
|
||||
const all = buildWorkspaceHierarchy($userWorkspaces)
|
||||
const currentRoot = $currentWorkspaceRootId
|
||||
if (!currentRoot) return all
|
||||
return all.filter((i) => workspaceRootId(i.workspace.id, $userWorkspaces) === currentRoot)
|
||||
})
|
||||
|
||||
// Sessions keyed by their exact workspace, newest-first — nested under each
|
||||
// workspace node in tree mode.
|
||||
const sessionsByWorkspace = $derived.by(() => {
|
||||
const map = new Map<string, Session[]>()
|
||||
for (const s of visibleSessions) {
|
||||
const wsId = s.workspace_id ?? s.pending_workspace_id
|
||||
if (!wsId) continue
|
||||
const arr = map.get(wsId)
|
||||
if (arr) arr.push(s)
|
||||
else map.set(wsId, [s])
|
||||
}
|
||||
for (const arr of map.values()) arr.sort((a, b) => b.createdAt - a.createdAt)
|
||||
return map
|
||||
})
|
||||
|
||||
// Collapsed workspaces in the tree, persisted to localStorage so the state is
|
||||
// shared between the rail and the collapsed popover (separate picker instances)
|
||||
// and survives reloads. A collapsed workspace hides its sessions + fork subtree.
|
||||
const collapsedWorkspaces = useLocalStorageValue<string[]>(
|
||||
'windmill_sessions_collapsed_workspaces',
|
||||
[]
|
||||
)
|
||||
function isWorkspaceCollapsed(id: string): boolean {
|
||||
return collapsedWorkspaces.val.includes(id)
|
||||
}
|
||||
function toggleWorkspaceCollapsed(id: string) {
|
||||
collapsedWorkspaces.val = isWorkspaceCollapsed(id)
|
||||
? collapsedWorkspaces.val.filter((x) => x !== id)
|
||||
: [...collapsedWorkspaces.val, id]
|
||||
}
|
||||
|
||||
// Ids of tree items hidden because an ancestor workspace is collapsed. Computed
|
||||
// in pre-order: once a collapsed node is seen, everything deeper is hidden until
|
||||
// the depth returns to its level or shallower.
|
||||
const hiddenWorkspaceIds = $derived.by(() => {
|
||||
const hidden = new Set<string>()
|
||||
let collapseDepth = Infinity
|
||||
for (const item of workspaceTreeItems) {
|
||||
if (item.depth > collapseDepth) {
|
||||
hidden.add(item.workspace.id)
|
||||
continue
|
||||
}
|
||||
collapseDepth = isWorkspaceCollapsed(item.workspace.id) ? item.depth : Infinity
|
||||
}
|
||||
return hidden
|
||||
})
|
||||
|
||||
// Family labels are redundant when scoped to a single family — only show them
|
||||
// if the active-session override surfaces a second family.
|
||||
const showGroupHeaders = $derived(sessionGroups.length > 1)
|
||||
|
||||
const archivedCount = $derived(
|
||||
sessionState.sessions.filter((s) => {
|
||||
if (!s.archived || s.transient) return false
|
||||
if (showAllWorkspaces.val) return true
|
||||
const currentRoot = $currentWorkspaceRootId
|
||||
return (
|
||||
!currentRoot || sessionRootOf(s) === currentRoot || s.id === sessionState.currentSessionId
|
||||
@@ -206,14 +285,14 @@
|
||||
}
|
||||
|
||||
async function activate(session: Session, restoreFocus: boolean = false) {
|
||||
// Picking a session leaves browse mode so the chat comes back.
|
||||
onSelectSession?.()
|
||||
selectSession(session.id)
|
||||
// If the session has a committed workspace different from the
|
||||
// active one, switch globally so the editor/forks resolve correctly.
|
||||
// Skip for unavailable forks — switching to a deleted workspace
|
||||
// would error out and leave the user in limbo.
|
||||
if (!isUnavailableFork(session)) {
|
||||
syncWorkspaceTo(session.workspace_id)
|
||||
}
|
||||
// The global workspaceStore is intentionally NOT switched here: a session
|
||||
// runs against its own workspace via the chat manager's workspace resolver,
|
||||
// so opening one must not change the user's active (navigation) workspace.
|
||||
// Open the dedicated sessions page; its preview panel iframes the
|
||||
// session's view (captured page / editor target).
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
|
||||
if (restoreFocus) {
|
||||
// goto() resets focus to <body> — put it back on the active session button
|
||||
@@ -228,7 +307,17 @@
|
||||
}
|
||||
|
||||
async function createAndOpen() {
|
||||
await activate(createSession())
|
||||
const fresh = createSession()
|
||||
// A new session opened from a Windmill page adopts that page as its first
|
||||
// preview tab (resetSessionPreviewTabs handles a reused transient whose
|
||||
// tabs still show a previous destination). Skip when already on the
|
||||
// sessions page (nothing meaningful to capture) so the preview seeds from
|
||||
// the session's editor target (or stays empty) instead.
|
||||
if (!onSessionsPage) {
|
||||
const url = page.url.pathname + page.url.search
|
||||
resetSessionPreviewTabs(fresh.id, url)
|
||||
}
|
||||
await activate(fresh)
|
||||
}
|
||||
|
||||
let editingId: string | undefined = $state(undefined)
|
||||
@@ -298,7 +387,14 @@
|
||||
if (wasActive) {
|
||||
const next = sessionState.sessions[0]
|
||||
if (next) await activate(next)
|
||||
else await goto('/sessions')
|
||||
else {
|
||||
// No sessions left — create a fresh one and navigate to it. The page
|
||||
// derives the visible session from the `session_name` query, so just
|
||||
// clearing currentSessionId would strand the URL on the deleted session
|
||||
// and render its not-found state instead of a ready-to-type composer.
|
||||
const fresh = createSession()
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +436,7 @@
|
||||
the section still shows — the per-session chat input is disabled with an
|
||||
explanatory message, mirroring the sidebar AI chat. -->
|
||||
{:else if isCollapsed}
|
||||
<div class="px-2 pt-3 pb-2 border-b border-light dark:border-gray-700">
|
||||
<div class={embedded ? '' : 'px-2 pt-3 pb-2 border-b border-light'}>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside submenuSafe>
|
||||
@@ -355,7 +451,7 @@
|
||||
/>
|
||||
{#if totalUnread > 0}
|
||||
<span
|
||||
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-blue-500"
|
||||
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-surface-accent-primary"
|
||||
aria-label="{totalUnread} unread message{totalUnread === 1
|
||||
? ''
|
||||
: 's'} across all sessions"
|
||||
@@ -375,7 +471,6 @@
|
||||
<SessionFilterMenu
|
||||
{builders}
|
||||
bind:showArchived={showArchived.val}
|
||||
bind:showAllWorkspaces={showAllWorkspaces.val}
|
||||
{archivedCount}
|
||||
/>
|
||||
</div>
|
||||
@@ -394,18 +489,23 @@
|
||||
{@const runtime = getRuntime(session.id)}
|
||||
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
|
||||
{@const isSelected =
|
||||
onSessionsPage && session.id === sessionState.currentSessionId}
|
||||
sessionActive && session.id === sessionState.currentSessionId}
|
||||
{@const unread = unreadFor(session)}
|
||||
{@const draft = hasDraft(session)}
|
||||
<MenuItem
|
||||
class={twMerge(menuItemBase, isSelected ? 'bg-surface-hover' : '')}
|
||||
class={twMerge(
|
||||
menuItemBase,
|
||||
isSelected
|
||||
? twMerge(sidebarClasses.selectedBg, sidebarClasses.selectedText)
|
||||
: ''
|
||||
)}
|
||||
onClick={() => activate(session)}
|
||||
{item}
|
||||
>
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={isForkFor(session)}
|
||||
unavailable={isUnavailableFork(session)}
|
||||
forkDetached={forkDetachedFor(session)}
|
||||
/>
|
||||
<span
|
||||
class={twMerge(
|
||||
@@ -422,7 +522,7 @@
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
@@ -441,9 +541,13 @@
|
||||
</Menubar>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-2 pt-3 pb-2 flex flex-col gap-1 border-b border-light dark:border-gray-700">
|
||||
<div
|
||||
class="flex flex-col gap-1 {embedded ? '' : 'px-2 pt-3 pb-2'} {!embedded && collapsible
|
||||
? 'border-b border-light'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-row items-center justify-between pl-1 pr-0.5">
|
||||
{#if visibleSessions.length > 0}
|
||||
{#if collapsible && visibleSessions.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (sectionCollapsed.val = !sectionCollapsed.val)}
|
||||
@@ -466,14 +570,22 @@
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="New session"
|
||||
aria-label="New session"
|
||||
onclick={createAndOpen}
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<Popover placement="bottom-end" usePointerDownOutside disableFocusTrap class="inline-flex">
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
title="Filter sessions"
|
||||
aria-label="Filter sessions"
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary {showArchived.val ||
|
||||
showAllWorkspaces.val
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary {showArchived.val
|
||||
? 'text-emphasis'
|
||||
: ''}"
|
||||
>
|
||||
@@ -484,16 +596,6 @@
|
||||
<div
|
||||
class="w-56 p-2 bg-surface-tertiary dark:border rounded-md shadow-lg flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle
|
||||
bind:checked={showAllWorkspaces.val}
|
||||
size="xs"
|
||||
options={{ right: 'Show all workspaces' }}
|
||||
/>
|
||||
<span class="text-2xs text-tertiary pl-1">
|
||||
Include sessions from every workspace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle
|
||||
bind:checked={showArchived.val}
|
||||
@@ -509,157 +611,246 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs2"
|
||||
iconOnly
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={createAndOpen}
|
||||
title="New session"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if !sectionCollapsed.val}
|
||||
{#if !collapsible || !sectionCollapsed.val}
|
||||
<div
|
||||
bind:this={listRoot}
|
||||
transition:slide={{ duration: 180 }}
|
||||
class="flex flex-col gap-0.5 max-h-[40vh] overflow-y-auto"
|
||||
class={twMerge(
|
||||
'flex flex-col gap-0.5 overflow-y-auto',
|
||||
// In the rail the picker is the whole list and the rail scrolls;
|
||||
// only cap height when it's one section among others (normal sidebar).
|
||||
collapsible ? 'max-h-[40vh]' : ''
|
||||
)}
|
||||
onkeydown={handleListKeydown}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#each sessionGroups as group (group.rootId)}
|
||||
{#if showGroupHeaders}
|
||||
<div
|
||||
class="px-2 pt-1.5 pb-0.5 text-[0.5rem] uppercase text-tertiary truncate"
|
||||
title={group.name}
|
||||
>
|
||||
{group.name}
|
||||
</div>
|
||||
{/if}
|
||||
{#each group.sessions as session (session.id)}
|
||||
{@const runtime = getRuntime(session.id)}
|
||||
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
|
||||
{@const isSelected = onSessionsPage && session.id === sessionState.currentSessionId}
|
||||
{@const isEditing = editingId === session.id}
|
||||
{@const unread = unreadFor(session)}
|
||||
{@const draft = hasDraft(session)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-row items-center group rounded',
|
||||
isSelected ? 'bg-surface-hover text-primary' : 'hover:bg-surface-hover',
|
||||
session.archived ? 'opacity-60' : ''
|
||||
)}
|
||||
>
|
||||
{#if isEditing}
|
||||
<span class="flex flex-row items-center gap-2 flex-1 px-2 py-1 min-w-0">
|
||||
{#snippet vline()}
|
||||
<span class="relative w-3.5 shrink-0 self-stretch">
|
||||
<span class="absolute inset-y-0 left-1/2 w-px bg-surface-tertiary"></span>
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet sessionRow(session, indented, treeDepth)}
|
||||
{@const runtime = getRuntime(session.id)}
|
||||
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
|
||||
{@const isSelected = sessionActive && session.id === sessionState.currentSessionId}
|
||||
{@const isEditing = editingId === session.id}
|
||||
{@const unread = unreadFor(session)}
|
||||
{@const draft = hasDraft(session)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-row group rounded',
|
||||
treeDepth === undefined ? 'items-center' : 'items-stretch',
|
||||
isSelected ? sidebarClasses.selectedBg : 'hover:bg-surface-hover',
|
||||
session.archived ? 'opacity-60' : '',
|
||||
// Family mode indents under the header; tree mode uses guide columns.
|
||||
treeDepth === undefined && indented ? 'pl-5' : ''
|
||||
)}
|
||||
>
|
||||
{#if treeDepth !== undefined}
|
||||
{#each Array(treeDepth) as _}{@render vline()}{/each}
|
||||
{/if}
|
||||
{#if isEditing}
|
||||
<span class="flex flex-row items-center gap-2 flex-1 px-2 py-1 min-w-0">
|
||||
{#if treeDepth === undefined}
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={isForkFor(session)}
|
||||
unavailable={isUnavailableFork(session)}
|
||||
forkDetached={forkDetachedFor(session)}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renameDraft}
|
||||
onkeydown={(e) => {
|
||||
{/if}
|
||||
<TextInput
|
||||
bind:value={renameDraft}
|
||||
size="xs"
|
||||
unifiedHeight={false}
|
||||
class="flex-1 min-w-0 !bg-transparent !border-0 !border-transparent !shadow-none focus:!ring-0 px-0 text-xs font-normal text-primary"
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Untitled session',
|
||||
autofocus: true,
|
||||
spellcheck: false,
|
||||
onkeydown: (e) => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
else if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onblur={commitRename}
|
||||
placeholder="Untitled session"
|
||||
autofocus
|
||||
spellcheck="false"
|
||||
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs font-normal text-primary"
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
data-session-button
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onclick={() => activate(session)}
|
||||
class={twMerge(
|
||||
'flex flex-row items-center gap-2 text-left text-xs font-normal focus:outline-none flex-1 min-w-0 px-2 py-1',
|
||||
unread > 0 ? 'text-primary font-semibold' : 'text-secondary'
|
||||
)}
|
||||
>
|
||||
},
|
||||
onblur: commitRename
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
data-session-button
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onclick={() => activate(session)}
|
||||
class={twMerge(
|
||||
'flex flex-row items-center gap-2 text-left text-xs font-normal focus:outline-none flex-1 min-w-0 px-2 py-1',
|
||||
isSelected ? sidebarClasses.selectedText : 'text-secondary'
|
||||
)}
|
||||
>
|
||||
{#if treeDepth === undefined}
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={isForkFor(session)}
|
||||
unavailable={isUnavailableFork(session)}
|
||||
forkDetached={forkDetachedFor(session)}
|
||||
/>
|
||||
<span class="truncate flex-1">{session.summary ?? 'Untitled session'}</span>
|
||||
{#if draft || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if draft}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity pr-0.5"
|
||||
>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-end"
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => startRename(session)
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailableFork(session)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]
|
||||
{/if}
|
||||
<span class="truncate flex-1">{session.summary ?? 'Untitled session'}</span>
|
||||
{#if draft || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if draft}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity pr-0.5"
|
||||
>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-end"
|
||||
enableFlyTransition
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => startRename(session)
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailableFork(session)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => setSessionArchived(session.id, true)
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (pendingDelete = session)
|
||||
}
|
||||
]}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => setSessionArchived(session.id, true)
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (pendingDelete = session)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if workspaceTree}
|
||||
{#each workspaceTreeItems as item, wi (item.workspace.id)}
|
||||
{#if !hiddenWorkspaceIds.has(item.workspace.id)}
|
||||
{@const wsSessions = sessionsByWorkspace.get(item.workspace.id) ?? []}
|
||||
{@const collapsed = isWorkspaceCollapsed(item.workspace.id)}
|
||||
{@const collapsible = item.hasChildren || wsSessions.length > 0}
|
||||
<!-- Workspace = folder. Stroke-colored building/fork glyph; the guide
|
||||
columns render the workspace tree (fork nesting). A chevron
|
||||
collapses the workspace (hides its sessions + fork subtree). -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-stretch w-full rounded',
|
||||
wi > 0 && item.depth === 0 ? 'mt-3' : '',
|
||||
browsedWorkspaceId === item.workspace.id
|
||||
? sidebarClasses.selectedBg
|
||||
: 'hover:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
{#each Array(item.depth) as _}{@render vline()}{/each}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onBrowseWorkspace?.(item.workspace.id)}
|
||||
title={item.workspace.name}
|
||||
class="flex items-center gap-1.5 py-1 pl-1 pr-2 min-w-0 flex-1 text-left"
|
||||
>
|
||||
{#if item.isForked}
|
||||
<GitFork size={14} class="shrink-0" style="color: {item.workspace.color}" />
|
||||
{:else}
|
||||
<Building size={14} class="shrink-0" style="color: {item.workspace.color}" />
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'text-xs truncate font-normal',
|
||||
browsedWorkspaceId === item.workspace.id
|
||||
? 'text-emphasis font-medium'
|
||||
: 'text-primary'
|
||||
)}>{item.workspace.name}</span
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
</button>
|
||||
{#if collapsible}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleWorkspaceCollapsed(item.workspace.id)}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
aria-label={collapsed ? 'Expand workspace' : 'Collapse workspace'}
|
||||
class="shrink-0 flex items-center justify-center w-6 text-tertiary hover:text-primary"
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronRight size={12} />
|
||||
{:else}
|
||||
<ChevronDown size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
{#each wsSessions as session (session.id)}
|
||||
{@render sessionRow(session, true, item.depth + 1)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sessionGroups as group, groupIdx (group.rootId)}
|
||||
{#if showGroupHeaders}
|
||||
{@const groupWs = $userWorkspaces.find((w) => w.id === group.rootId)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center gap-2 px-2 pt-2 pb-1 min-w-0',
|
||||
// Space families apart so group boundaries read clearly.
|
||||
groupIdx > 0 ? 'mt-4' : ''
|
||||
)}
|
||||
title={group.name}
|
||||
>
|
||||
<WorkspaceIcon workspaceColor={groupWs?.color} size={12} />
|
||||
<span class="text-xs font-medium text-secondary truncate">{group.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each group.sessions as session (session.id)}
|
||||
{@render sessionRow(session, showGroupHeaders, undefined)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
} from 'lucide-svelte'
|
||||
import type { SessionChatStatus } from './sessionRuntime.svelte'
|
||||
|
||||
// The fork icon is deliberately sync-state-agnostic (no ahead/behind/
|
||||
// diverged variants — that detail lives in the fork bar). Its only special
|
||||
// state is detached: the session's fork workspace no longer exists.
|
||||
let {
|
||||
status,
|
||||
isFork,
|
||||
unavailable = false
|
||||
}: { status: SessionChatStatus; isFork: boolean; unavailable?: boolean } = $props()
|
||||
forkDetached = false
|
||||
}: { status: SessionChatStatus; isFork: boolean; forkDetached?: boolean } = $props()
|
||||
|
||||
const statusTooltip: Record<SessionChatStatus, string> = {
|
||||
idle: 'No chat activity',
|
||||
@@ -32,7 +35,7 @@
|
||||
|
||||
const persistentTitle = $derived(
|
||||
isFork
|
||||
? unavailable
|
||||
? forkDetached
|
||||
? 'Fork — no longer available'
|
||||
: 'Fork session'
|
||||
: 'Root workspace session'
|
||||
@@ -53,7 +56,7 @@
|
||||
{:else if status === 'error'}
|
||||
<AlertTriangle class="w-3 h-3 text-red-500" />
|
||||
{:else if isFork}
|
||||
{#if unavailable}
|
||||
{#if forkDetached}
|
||||
<GitPullRequestClosed class="w-3 h-3 text-red-500" />
|
||||
{:else}
|
||||
<GitFork class="w-3 h-3 text-tertiary" />
|
||||
|
||||
@@ -1,53 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
getEffectiveWorkspaceId,
|
||||
setSessionPendingFork,
|
||||
setSessionPendingWorkspace,
|
||||
syncWorkspaceTo,
|
||||
type Session
|
||||
} from './sessionState.svelte'
|
||||
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { Building, ChevronDown, GitFork } from 'lucide-svelte'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
|
||||
let { session }: { session: Session } = $props()
|
||||
|
||||
function findRoot(id: string | undefined, all: UserWorkspace[]): UserWorkspace | undefined {
|
||||
if (!id) return undefined
|
||||
let current = all.find((w) => w.id === id)
|
||||
while (current?.parent_workspace_id) {
|
||||
const parent = all.find((w) => w.id === current!.parent_workspace_id)
|
||||
if (!parent) break
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Effective workspace for display: committed → pending pick → active store.
|
||||
const effectiveId = $derived(getEffectiveWorkspaceId(session) ?? $workspaceStore ?? undefined)
|
||||
const root = $derived(findRoot(effectiveId, $userWorkspaces))
|
||||
const currentWs = $derived(
|
||||
effectiveId ? $userWorkspaces.find((w) => w.id === effectiveId) : undefined
|
||||
)
|
||||
const pendingFork = $derived(session.pending_fork)
|
||||
|
||||
function pick(id: string) {
|
||||
// Pre-send only: writes the pending pick. workspace_id stays
|
||||
// undefined until the user sends their first message.
|
||||
// Pre-send only: writes the pending pick. workspace_id stays undefined until
|
||||
// the user sends their first message. The global workspaceStore is left
|
||||
// untouched — the chat targets this pending workspace via the manager's
|
||||
// workspace resolver, so picking here must not switch the active workspace.
|
||||
setSessionPendingWorkspace(session.id, id)
|
||||
syncWorkspaceTo(id)
|
||||
}
|
||||
|
||||
function stageFork(req: { parent_workspace_id: string; id: string; name: string }) {
|
||||
setSessionPendingFork(session.id, req)
|
||||
syncWorkspaceTo(req.parent_workspace_id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row items-center gap-1 py-0.5 px-1 text-2xs text-secondary">
|
||||
<span class="shrink-0">Run in</span>
|
||||
<span class="shrink-0">Acting on</span>
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={effectiveId}
|
||||
{pendingFork}
|
||||
@@ -56,25 +38,7 @@
|
||||
createForkCaption="Created when you send your first message."
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span
|
||||
class="inline-flex flex-row items-center gap-1 px-1.5 py-0.5 rounded hover:bg-surface-hover text-2xs"
|
||||
>
|
||||
{#if pendingFork || (currentWs && currentWs.id !== root?.id)}
|
||||
<GitFork class="w-3 h-3 shrink-0" />
|
||||
{:else}
|
||||
<Building class="w-3 h-3 shrink-0" />
|
||||
{/if}
|
||||
<span class="font-medium text-primary truncate max-w-[180px]">
|
||||
{pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'}
|
||||
</span>
|
||||
{#if !pendingFork && currentWs?.is_dev_workspace}
|
||||
<Badge color="indigo" small>{devBadgeText(currentWs.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
{#if pendingFork}
|
||||
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
|
||||
{/if}
|
||||
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary" />
|
||||
</span>
|
||||
<WorkspaceScopeTrigger workspaceId={effectiveId} {pendingFork} class="max-w-[16rem]" />
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Button, NameIdTooltip } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
@@ -16,12 +16,17 @@
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowUpRight,
|
||||
EllipsisVertical,
|
||||
ExternalLink,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Pencil,
|
||||
Settings,
|
||||
Trash2
|
||||
} from 'lucide-svelte'
|
||||
import { type Item } from '$lib/utils'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import WorkspaceItemDrillPicker from '$lib/components/WorkspaceItemDrillPicker.svelte'
|
||||
@@ -37,6 +42,8 @@
|
||||
getEffectiveWorkspaceId,
|
||||
moveSessionToNewFork,
|
||||
moveSessionToWorkspace,
|
||||
peekTransientDraftPrompt,
|
||||
queueTransientDraftPrompt,
|
||||
reconcileAfterWorkspaceChange,
|
||||
renameSession,
|
||||
selectSession,
|
||||
@@ -48,9 +55,24 @@
|
||||
} from './sessionState.svelte'
|
||||
import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
|
||||
|
||||
let { sessionId }: { sessionId: string } = $props()
|
||||
// hideEditor: never mount the inline editor pane. Used by the sessions page,
|
||||
// where the edited item is shown in a live page preview (iframe) beside the
|
||||
// chat instead, so the wrapper contributes only its chat column.
|
||||
// headerInset: extra left padding on the chat header so it clears a floating
|
||||
// control (the collapsed-rail launcher) sitting at the screen's top-left.
|
||||
let {
|
||||
sessionId,
|
||||
hideEditor = false,
|
||||
headerInset = false
|
||||
}: {
|
||||
sessionId: string
|
||||
hideEditor?: boolean
|
||||
headerInset?: boolean
|
||||
} = $props()
|
||||
|
||||
// LRU-warm sessions get their editor pane mounted; others render
|
||||
// chat-only. Reading from the reactive Set keeps SessionWrapper in
|
||||
@@ -70,9 +92,51 @@
|
||||
// Reactive session reference (mutations to summary/target propagate via the $state proxy)
|
||||
const session = $derived(sessionState.sessions.find((s) => s.id === sessionId))
|
||||
|
||||
// Seed the composer with the unsent prompt a reload preserved in the
|
||||
// transient draft slot (script-init: AIChatInput reads it once at mount).
|
||||
const restoredDraftPrompt = peekTransientDraftPrompt(sessionId)
|
||||
|
||||
// The workspace the session acts on, shown in the header "Acting on" strip via the shared
|
||||
// WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets.
|
||||
const acting = $derived.by(() => {
|
||||
const wsId = session ? getEffectiveWorkspaceId(session) : undefined
|
||||
if (!wsId) return undefined
|
||||
const name = $userWorkspaces.find((w) => w.id === wsId)?.name ?? wsId
|
||||
return { targetId: wsId, name }
|
||||
})
|
||||
|
||||
// Ellipsis menu on the "Acting on" chip. Both entries are real links (so
|
||||
// modifier/middle clicks open a new tab); the `workspace` query param
|
||||
// points the navigation at the acting workspace — the layout applies it on
|
||||
// both full loads and client-side query changes. The trailing external-link
|
||||
// glyphs make the leave-the-session navigation explicit.
|
||||
const actingMenu = $derived<Item[]>([
|
||||
{
|
||||
displayName: 'Workspace settings',
|
||||
icon: Settings,
|
||||
href: `${base}/workspace_settings?workspace=${acting?.targetId ?? ''}`,
|
||||
extra: externalLinkHint
|
||||
},
|
||||
{
|
||||
displayName: 'Go to this workspace',
|
||||
icon: ArrowUpRight,
|
||||
href: `${base}/?workspace=${acting?.targetId ?? ''}`,
|
||||
extra: externalLinkHint
|
||||
}
|
||||
])
|
||||
|
||||
// Load copilot config (models, providers) for the workspace the session acts
|
||||
// on, not the navigation workspace — a session deliberately leaves
|
||||
// $workspaceStore on the nav workspace, so keying off it would pick the model
|
||||
// and provider from the wrong workspace's AI config for a fork-scoped session.
|
||||
// Only the active session may write this: copilotInfo/copilotSessionModel are
|
||||
// global, and warm background wrappers (each in their own workspace) would
|
||||
// otherwise race to clobber the active chat's model config.
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
loadCopilot($workspaceStore)
|
||||
if (sessionState.currentSessionId !== sessionId) return
|
||||
const ws = acting?.targetId ?? $workspaceStore
|
||||
if (ws) {
|
||||
loadCopilot(ws)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,6 +148,9 @@
|
||||
async function resetToNewSession() {
|
||||
const fresh = createSession()
|
||||
selectSession(fresh.id)
|
||||
// The page derives the visible session from the `session_name` query, not
|
||||
// currentSessionId — navigate so the URL leaves the deleted/archived session
|
||||
// (else it renders the not-found state or stays on the archived one).
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
|
||||
}
|
||||
|
||||
@@ -259,6 +326,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkHint()}
|
||||
<ExternalLink size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#if !session || !runtime}
|
||||
<div class="p-8 text-secondary text-sm">Session not found</div>
|
||||
{:else}
|
||||
@@ -267,7 +338,7 @@
|
||||
session.target?.kind === 'script' ||
|
||||
session.target?.kind === 'raw_app' ||
|
||||
session.target?.kind === 'pipeline'}
|
||||
{@const hasEditor = mountEditor && hasTarget && editorVisible}
|
||||
{@const hasEditor = mountEditor && hasTarget && editorVisible && !hideEditor}
|
||||
|
||||
{#snippet inputPreface()}
|
||||
{#if !hasFirstUserMessage}
|
||||
@@ -320,171 +391,204 @@
|
||||
50/50. A reactive `size={hasEditor ? 50 : 100}` here instead races the
|
||||
sibling pane appearing on reload → "Could not resize panes due to constraints"
|
||||
and a wrong split. -->
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
|
||||
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
|
||||
<header class="flex flex-row items-center gap-1 pl-4 pr-4 py-2 shrink-0">
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
value={session.summary ?? ''}
|
||||
placeholder="Untitled session"
|
||||
onSave={(v) => renameSession(session.id, v)}
|
||||
class="text-sm font-semibold"
|
||||
inputClass="!text-sm !font-semibold"
|
||||
/>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => summaryInput?.edit()
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailable
|
||||
? []
|
||||
<div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture>
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
|
||||
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
|
||||
<header
|
||||
class="flex flex-row items-center gap-1 {headerInset
|
||||
? 'pl-11'
|
||||
: 'pl-4'} pr-4 py-2 shrink-0"
|
||||
>
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
value={session.summary ?? ''}
|
||||
placeholder="Untitled session"
|
||||
onSave={(v) => renameSession(session.id, v)}
|
||||
class="text-sm font-semibold"
|
||||
inputClass="!text-sm !font-semibold"
|
||||
/>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
enableFlyTransition
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => summaryInput?.edit()
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailable
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => archiveAndReset()
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => archiveAndReset()
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (deleteConfirmOpen = true)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{#if !session.target && hasFirstUserMessage}
|
||||
<!-- Drill-picker for sessions that have started but haven't
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (deleteConfirmOpen = true)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{#if acting && hasFirstUserMessage}
|
||||
<!-- "Acting on" context: workspace root (avatar + name) and, when the
|
||||
session runs in a fork, the fork name (accent pill) — with a button
|
||||
to jump into that workspace. Compact; sits right after the title.
|
||||
Hidden until the session has started — a new (un-sent) session shows
|
||||
the "Run in" picker (SessionWorkspaceBar) instead. -->
|
||||
<div class="flex items-center gap-1 min-w-0 text-2xs text-tertiary">
|
||||
<span class="shrink-0">Acting on</span>
|
||||
<!-- Hover reveals the workspace name + id + copy button (shared
|
||||
NameIdTooltip, same as the sidebar family picker), so the chip
|
||||
carries the copy affordance without an inline button. -->
|
||||
<NameIdTooltip name={acting.name} id={acting.targetId}>
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={acting.targetId}
|
||||
showChevron={false}
|
||||
interactive={false}
|
||||
disableTitle
|
||||
class="max-w-[16rem]"
|
||||
menuItems={actingMenu}
|
||||
/>
|
||||
</NameIdTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !hideEditor && !session.target && hasFirstUserMessage}
|
||||
<!-- Drill-picker for sessions that have started but haven't
|
||||
picked an editor target yet. Hidden on fresh sessions
|
||||
(no messages yet) — the workspace bar is the only
|
||||
header affordance during the empty state. -->
|
||||
<div class="ml-auto">
|
||||
<Popover
|
||||
placement="bottom-end"
|
||||
usePointerDownOutside
|
||||
disableFocusTrap
|
||||
class="inline-flex"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" unifiedSize="xs" startIcon={{ icon: PanelRightOpen }}>
|
||||
Open editor
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<WorkspaceItemDrillPicker
|
||||
onPick={(item: WorkspaceItem) => pickEditorTarget(item)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else if hasTarget && mountEditor && !editorVisible}
|
||||
<div class="ml-auto">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
onclick={() => (editorVisible = true)}
|
||||
>
|
||||
Show editor
|
||||
</Button>
|
||||
</div>
|
||||
{:else if hasEditor}
|
||||
<div class="ml-auto flex flex-row items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editorVisible = false)}
|
||||
title="Close editor"
|
||||
aria-label="Close editor"
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
|
||||
>
|
||||
<PanelRightClose size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
|
||||
<AIChat
|
||||
bind:this={aiChat}
|
||||
hideHeader
|
||||
hideModeSelector
|
||||
wideLayout
|
||||
forceDisabled={isUnavailable || !!session.archived}
|
||||
forceDisabledMessage={isUnavailable
|
||||
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
|
||||
: session.archived
|
||||
? 'This session is archived. Unarchive it from the banner above to keep working.'
|
||||
: ''}
|
||||
emptyHint={sessionEmptyHint}
|
||||
{inputPreface}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
{#if hasEditor && session.target}
|
||||
<Pane minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
|
||||
<div
|
||||
transition:slide={{ axis: 'x', duration: 200 }}
|
||||
class="flex flex-col flex-1 min-h-0 rounded-md border border-light overflow-hidden relative"
|
||||
>
|
||||
{#if session.target.kind === 'flow'}
|
||||
<FlowEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
onNavigate={pickEditorTarget}
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{:else if session.target.kind === 'script'}
|
||||
<ScriptEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
onNavigate={pickEditorTarget}
|
||||
initialTestPanelCollapsed
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{:else if session.target.kind === 'raw_app'}
|
||||
<RawAppEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
onNavigate={pickEditorTarget}
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{:else if session.target.kind === 'pipeline'}
|
||||
<PipelineEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
<div class="ml-auto">
|
||||
<Popover
|
||||
placement="bottom-end"
|
||||
usePointerDownOutside
|
||||
disableFocusTrap
|
||||
enableFlyTransition
|
||||
class="inline-flex"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" unifiedSize="xs" startIcon={{ icon: PanelRightOpen }}>
|
||||
Open editor
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<WorkspaceItemDrillPicker
|
||||
onPick={(item: WorkspaceItem) => pickEditorTarget(item)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else if !hideEditor && hasTarget && mountEditor && !editorVisible}
|
||||
<div class="ml-auto">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
onclick={() => (editorVisible = true)}
|
||||
>
|
||||
Show editor
|
||||
</Button>
|
||||
</div>
|
||||
{:else if hasEditor}
|
||||
<div class="ml-auto flex flex-row items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editorVisible = false)}
|
||||
title="Close editor"
|
||||
aria-label="Close editor"
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
|
||||
>
|
||||
<PanelRightClose size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
|
||||
<AIChat
|
||||
bind:this={aiChat}
|
||||
hideHeader
|
||||
hideModeSelector
|
||||
wideLayout
|
||||
initialInstructions={restoredDraftPrompt}
|
||||
onDraftChange={(text) => queueTransientDraftPrompt(sessionId, text)}
|
||||
forceDisabled={isUnavailable || !!session.archived}
|
||||
forceDisabledMessage={isUnavailable
|
||||
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
|
||||
: session.archived
|
||||
? 'This session is archived. Unarchive it from the banner above to keep working.'
|
||||
: ''}
|
||||
emptyHint={sessionEmptyHint}
|
||||
{inputPreface}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
{#if hasEditor && session.target}
|
||||
<Pane minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
|
||||
<div
|
||||
transition:slide={{ axis: 'x', duration: 200 }}
|
||||
class="flex flex-col flex-1 min-h-0 rounded-md border border-light overflow-hidden relative"
|
||||
>
|
||||
{#if session.target.kind === 'flow'}
|
||||
<FlowEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
onNavigate={pickEditorTarget}
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{:else if session.target.kind === 'script'}
|
||||
<ScriptEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
onNavigate={pickEditorTarget}
|
||||
initialTestPanelCollapsed
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{:else if session.target.kind === 'raw_app'}
|
||||
<RawAppEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
onNavigate={pickEditorTarget}
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{:else if session.target.kind === 'pipeline'}
|
||||
<PipelineEditorView
|
||||
{runtime}
|
||||
path={session.target.path}
|
||||
workspaceId={effectiveWorkspaceId}
|
||||
isActiveSession={sessionState.currentSessionId === sessionId}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteConfirmOpen}
|
||||
@@ -552,9 +656,13 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.splitter-hidden .splitpanes__splitter) {
|
||||
/* Invisible-but-draggable splitter: a real (layout-occupying) gutter, wide
|
||||
enough to grab. No overlap tricks — the zone can't cover the left pane's
|
||||
scrollbar or the right pane's edge. */
|
||||
:global(.splitpanes--vertical.splitter-hidden) > :global(.splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0 !important;
|
||||
width: 10px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import { actionFor, badgeOf, type DeployItem } from './sessionDeployModel'
|
||||
import { maskKey } from './modifiedItemsMask'
|
||||
import type { SessionDeployModel, DiffValues } from './sessionDeployModel.svelte'
|
||||
import { SIDE_BY_SIDE_MIN_WIDTH } from '$lib/components/diffEditorTypes'
|
||||
|
||||
// The session "Edits" surface. Reads a unified item model (the session's
|
||||
// drafts, scoped to what this session edited) and renders a folder tree
|
||||
@@ -71,8 +72,20 @@
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let searchQuery = $state('')
|
||||
// The user's explicit view choice. The *effective* view can still be inline
|
||||
// when the column is too narrow — see `narrow`/`inlineDiff` below.
|
||||
let diffStyle = $state<'sbs' | 'inline'>('sbs')
|
||||
const inlineDiff = $derived(diffStyle === 'inline')
|
||||
|
||||
// Width of the diff column (the scrolling <main>). The editor sits inside it
|
||||
// behind ~18px of chrome (main padding + card border), so subtract that to
|
||||
// compare against the editor's own SIDE_BY_SIDE_MIN_WIDTH gate. Forcing inline
|
||||
// here (rather than leaving it to each DiffEditor) keeps the toggle honest:
|
||||
// the button shown always matches what renders.
|
||||
const DIFF_COLUMN_CHROME = 18
|
||||
let mainWidth = $state(0)
|
||||
const narrow = $derived(mainWidth > 0 && mainWidth - DIFF_COLUMN_CHROME < SIDE_BY_SIDE_MIN_WIDTH)
|
||||
const inlineDiff = $derived(diffStyle === 'inline' || narrow)
|
||||
const effectiveStyle = $derived<'sbs' | 'inline'>(inlineDiff ? 'inline' : 'sbs')
|
||||
|
||||
// The sidebar's inner right padding must ADAPT to the reserved scrollbar
|
||||
// gutter (platform-dependent: 0 for overlay scrollbars, ~15px classic) so
|
||||
@@ -816,6 +829,7 @@
|
||||
kind="raw_app_file"
|
||||
rawFile={sub as RawAppFileItem}
|
||||
{inlineDiff}
|
||||
disableAutoInline
|
||||
/>
|
||||
{:else}
|
||||
{@const runnable = sub as RawAppRunnableItem}
|
||||
@@ -824,6 +838,7 @@
|
||||
originalRaw={runnable.originalRaw}
|
||||
currentRaw={runnable.currentRaw}
|
||||
{inlineDiff}
|
||||
disableAutoInline
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -835,11 +850,12 @@
|
||||
originalRaw={loaded.before}
|
||||
currentRaw={loaded.after}
|
||||
{inlineDiff}
|
||||
disableAutoInline
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Drawer bind:this={drawer} size="1200px">
|
||||
<Drawer bind:this={drawer} size="1500px">
|
||||
<DrawerContent
|
||||
{title}
|
||||
{titleExtra}
|
||||
@@ -849,13 +865,26 @@
|
||||
overflow_y={false}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
|
||||
<!-- Controlled (not bind:selected): the shown button is the *effective*
|
||||
view, which is forced to unified when the column is too narrow.
|
||||
Clicks still record the user's preference in `diffStyle`. -->
|
||||
<ToggleButtonGroup
|
||||
selected={effectiveStyle}
|
||||
onSelected={(v) => {
|
||||
// Ignore the programmatic flip to 'inline' when `narrow` forces it —
|
||||
// only a real click (when not narrow) should change the preference,
|
||||
// so widening again restores the user's choice.
|
||||
if (!narrow) diffStyle = v
|
||||
}}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="sbs"
|
||||
label="Side-by-side"
|
||||
icon={SquareSplitHorizontal}
|
||||
tooltip="Side-by-side diff"
|
||||
tooltip={narrow ? 'Side-by-side needs a wider view' : 'Side-by-side diff'}
|
||||
disabled={narrow}
|
||||
iconOnly
|
||||
{item}
|
||||
/>
|
||||
@@ -921,6 +950,7 @@
|
||||
<main
|
||||
bind:this={mainScrollEl}
|
||||
bind:clientHeight={mainHeight}
|
||||
bind:clientWidth={mainWidth}
|
||||
class="flex-1 min-w-0 overflow-y-auto bg-surface"
|
||||
>
|
||||
<div class="pl-1 pr-3 pt-3 pb-4 flex flex-col gap-3">
|
||||
@@ -956,18 +986,21 @@
|
||||
class="sticky top-0 z-30 bg-surface-tertiary rounded-t-md flex items-center gap-2 px-3 py-2 border-b border-transparent"
|
||||
>
|
||||
<RowIcon kind={d.deployKind as any} path={d.path} size={14} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- flex+items-center so the path (a block div, or ExternalEditLink's
|
||||
inline-flex <a>) is centered by its box against the icon — an inline
|
||||
<a> would otherwise sit ~2px low on the line-box baseline. -->
|
||||
<div class="min-w-0 flex-1 flex items-center">
|
||||
{#if editUrl}
|
||||
<ExternalEditLink
|
||||
href={editUrl}
|
||||
title={d.displayPath}
|
||||
class="text-xs text-primary font-mono truncate"
|
||||
class="min-w-0 text-xs text-primary font-mono truncate"
|
||||
>
|
||||
<span class="truncate">{d.displayPath}</span>
|
||||
</ExternalEditLink>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs text-primary font-mono truncate"
|
||||
class="min-w-0 text-xs text-primary font-mono truncate"
|
||||
title={d.displayPath}
|
||||
>
|
||||
{d.displayPath}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
isPremiumStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
type UserWorkspace
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
findWorkspaceDescendants,
|
||||
findCanonicalDevWorkspace
|
||||
findCanonicalDevWorkspace,
|
||||
findWorkspaceRoot,
|
||||
buildWorkspaceHierarchy
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import {
|
||||
fetchProtectionRulesForWorkspace,
|
||||
isRuleActiveInRulesets,
|
||||
@@ -24,10 +27,11 @@
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import InputError from '$lib/components/InputError.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import PrefixedInput from '$lib/components/PrefixedInput.svelte'
|
||||
import { Badge, Button, CopyButton } from '$lib/components/common'
|
||||
import { devBadgeText, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { Building, Check, GitFork, Plus } from 'lucide-svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import { Building, Check, GitFork, Plus, Settings } from 'lucide-svelte'
|
||||
|
||||
type PendingFork = { parent_workspace_id: string; id: string; name: string }
|
||||
type ForkRequest = { parent_workspace_id: string; id: string; name: string }
|
||||
@@ -42,45 +46,76 @@
|
||||
pendingFork,
|
||||
onPick,
|
||||
onCreateFork,
|
||||
// Alternative to onCreateFork's inline staging: when set, the
|
||||
// "Create new fork…" row becomes a one-shot button that delegates to
|
||||
// this callback (e.g. opening the global fork modal) instead of
|
||||
// revealing the inline name input. Used where there's no pending draft
|
||||
// to stage (the sidebar scope picker).
|
||||
onRequestCreateFork,
|
||||
allowCreateFork = true,
|
||||
// Optional caption rendered under the new-fork input. Lets the
|
||||
// consumer differentiate "staged for first send" vs. "will be
|
||||
// created immediately" semantics.
|
||||
createForkCaption = '',
|
||||
// Forwarded to the dropdown trigger wrapper — e.g. `min-w-0` so the
|
||||
// trigger can shrink and its label truncate inside a narrow container.
|
||||
class: triggerClass = undefined,
|
||||
// Optional settings link rendered at the very bottom of the menu (e.g.
|
||||
// "<workspace> settings"). The consumer decides visibility (admin gate)
|
||||
// and target href.
|
||||
settingsHref,
|
||||
settingsLabel,
|
||||
trigger
|
||||
}: {
|
||||
selectedId?: string
|
||||
pendingFork?: PendingFork
|
||||
onPick: (workspaceId: string) => void | Promise<void>
|
||||
onCreateFork?: (fork: ForkRequest) => void | Promise<void>
|
||||
onRequestCreateFork?: () => void
|
||||
allowCreateFork?: boolean
|
||||
createForkCaption?: string
|
||||
class?: string
|
||||
settingsHref?: string
|
||||
settingsLabel?: string
|
||||
trigger: Snippet<[{ open: boolean }]>
|
||||
} = $props()
|
||||
|
||||
const WM_FORK_PREFIX = 'wm-fork-'
|
||||
|
||||
function findRoot(id: string | undefined, all: UserWorkspace[]): UserWorkspace | undefined {
|
||||
if (!id) return undefined
|
||||
let current = all.find((w) => w.id === id)
|
||||
while (current?.parent_workspace_id) {
|
||||
const parent = all.find((w) => w.id === current!.parent_workspace_id)
|
||||
if (!parent) break
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
const effectiveId = $derived(selectedId ?? $workspaceStore ?? undefined)
|
||||
const root = $derived(findRoot(effectiveId, $userWorkspaces))
|
||||
const root = $derived(findWorkspaceRoot(effectiveId, $userWorkspaces))
|
||||
const forks = $derived(root ? findWorkspaceDescendants(root.id, $userWorkspaces) : [])
|
||||
|
||||
// New forks derive from the editable dev workspace when the family has one:
|
||||
// the root (prod) is typically forking-locked, so a non-admin can't fork it,
|
||||
// and dev holds the current code anyway. Falls back to the root otherwise.
|
||||
// The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be
|
||||
// forked via its dev) and as a selectable base with a "dev" badge.
|
||||
const devOfRoot = $derived(root ? findCanonicalDevWorkspace(root.id, $userWorkspaces) : undefined)
|
||||
const forkSource = $derived(devOfRoot ?? root)
|
||||
const createForkLabel = $derived(devOfRoot ? `Fork from ${devOfRoot.name}` : 'Create new fork…')
|
||||
const createForkLabel = 'Create new fork…'
|
||||
// Candidate bases ("targets") for a new fork: the root plus every fork/dev in the family, so a fork
|
||||
// can itself be the base — i.e. a fork of a fork. Root first, matching the list order below.
|
||||
const baseOptions = $derived(root ? [root, ...forks] : [])
|
||||
// Options for the base-branch <Select>. The nesting (fork of a fork) is rendered via the Select's
|
||||
// per-item `startSnippet` as a depth-based spacer, so the label text itself stays clean.
|
||||
const baseItems = $derived(
|
||||
baseOptions.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.name,
|
||||
subtitle: w.id === root?.id ? 'root' : w.is_dev_workspace ? 'dev workspace' : undefined
|
||||
}))
|
||||
)
|
||||
|
||||
// Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …), from
|
||||
// the shared hierarchy builder — the same one the sidebar workspace menu uses — so the list indents
|
||||
// forks of forks under their parent the same way. `forks` is a DFS of descendants (parent before
|
||||
// child), so indenting each row by its depth nests it under its parent.
|
||||
const familyDepths = $derived(
|
||||
new Map(buildWorkspaceHierarchy($userWorkspaces).map((h) => [h.workspace.id, h.depth]))
|
||||
)
|
||||
// Extra left padding (on top of the row's base px-3) to nest a workspace one step per depth level,
|
||||
// matching the sidebar menu's `depth * 16px`.
|
||||
function indentStyle(id: string): string | undefined {
|
||||
const depth = familyDepths.get(id) ?? 0
|
||||
return depth > 0 ? `padding-left: ${12 + depth * 16}px` : undefined
|
||||
}
|
||||
|
||||
// Judge the prod root off its OWN protection rules (fetched), not the active
|
||||
// workspace's — so it reads correctly from a dev/fork too, the same way
|
||||
@@ -104,10 +139,6 @@
|
||||
!isRuleActiveInRulesets(rootRulesets, 'DisableDirectDeployment') ||
|
||||
canUserBypassRuleKindInRulesets(rootRulesets, 'DisableDirectDeployment', rootUserInfo)
|
||||
)
|
||||
const canForkRoot = $derived(
|
||||
!isRuleActiveInRulesets(rootRulesets, 'DisableWorkspaceForking') ||
|
||||
canUserBypassRuleKindInRulesets(rootRulesets, 'DisableWorkspaceForking', rootUserInfo)
|
||||
)
|
||||
// A genuinely deploy-locked root (you can't deploy and can't bypass) is disabled regardless of
|
||||
// whether a dev workspace exists to steer to — being deploy-locked is the gate, not dev presence.
|
||||
// Roots with no rules resolve `canDeployRoot` to true, so ordinary families aren't affected. Kept
|
||||
@@ -139,44 +170,36 @@
|
||||
// The interactive create-fork row is shown unless the cap is reached;
|
||||
// otherwise (structural gate open but cap hit) we surface a disabled row
|
||||
// explaining the limit — never stage a fork the backend would reject.
|
||||
const forkAffordanceOpen = $derived(allowCreateFork && forksGateOpen && !!onCreateFork && !!root)
|
||||
const showCreateFork = $derived(forkAffordanceOpen && !ceWorkspaceCapReached)
|
||||
const showForkUpsell = $derived(forkAffordanceOpen && ceWorkspaceCapReached)
|
||||
// Whether the second "Fork from <root>" create entry is shown (admins can fork the prod root
|
||||
// directly even when the default entry forks the dev). Mirrors its render condition so keyboard
|
||||
// nav and the row index math stay in sync. Suppressed while the root's rules are still loading,
|
||||
// otherwise `canForkRoot` defaults true and a non-bypass user could stage a fork from a
|
||||
// forking-locked root before the rules resolve.
|
||||
const hasCreateFromRoot = $derived(
|
||||
showCreateFork &&
|
||||
!rootRulesetsResource.loading &&
|
||||
!rootUserInfoResource.loading &&
|
||||
canForkRoot &&
|
||||
!!devOfRoot
|
||||
const forkAffordanceOpen = $derived(
|
||||
allowCreateFork && forksGateOpen && (!!onCreateFork || !!onRequestCreateFork) && !!root
|
||||
)
|
||||
// The upsell (CE workspace cap) only applies to in-place inline creation;
|
||||
// onRequestCreateFork delegates to a flow that enforces its own limits.
|
||||
const showCreateFork = $derived(
|
||||
forkAffordanceOpen && (!ceWorkspaceCapReached || !!onRequestCreateFork)
|
||||
)
|
||||
const showForkUpsell = $derived(
|
||||
forkAffordanceOpen && ceWorkspaceCapReached && !onRequestCreateFork
|
||||
)
|
||||
|
||||
let dropdownOpen = $state(false)
|
||||
let creatingFork = $state(false)
|
||||
let newForkName = $state('')
|
||||
let forkInput: TextInput | undefined = $state(undefined)
|
||||
// Admins get a second entry to fork the prod root directly (the default forks
|
||||
// the dev). `forkFromRoot` tracks which source the create input is for.
|
||||
let forkFromRoot = $state(false)
|
||||
const effectiveForkSource = $derived(forkFromRoot ? root : forkSource)
|
||||
// Bare fork id, without the wm-fork- prefix. Forks have no separate display
|
||||
// name — the id is the name (it also becomes the git branch).
|
||||
let newForkId = $state('')
|
||||
// The base ("target") a new fork will branch from. Defaults to the root; the user can pick any fork
|
||||
// in the family (via the inline target selector) to create a fork of a fork.
|
||||
let createForkBaseId = $state<string | undefined>(undefined)
|
||||
|
||||
// Manual keyboard navigation, modelled after SelectDropdown. melt's
|
||||
// menu API couples Enter/Space to closing the menu, which we explicitly
|
||||
// don't want for the "Create new fork" row — it swaps to inline input.
|
||||
type NavRow =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'create-from-root' }
|
||||
| { kind: 'root'; id: string }
|
||||
| { kind: 'fork'; id: string }
|
||||
type NavRow = { kind: 'create' } | { kind: 'root'; id: string } | { kind: 'fork'; id: string }
|
||||
// Visual order: family first, create-fork last — keep in sync with the markup.
|
||||
const navRows = $derived<NavRow[]>([
|
||||
...(showCreateFork ? [{ kind: 'create' as const }] : []),
|
||||
...(hasCreateFromRoot ? [{ kind: 'create-from-root' as const }] : []),
|
||||
...(root ? [{ kind: 'root' as const, id: root.id }] : []),
|
||||
...forks.map((f) => ({ kind: 'fork' as const, id: f.id }))
|
||||
...forks.map((f) => ({ kind: 'fork' as const, id: f.id })),
|
||||
...(showCreateFork ? [{ kind: 'create' as const }] : [])
|
||||
])
|
||||
let keyArrowPos = $state<number | undefined>(undefined)
|
||||
$effect(() => {
|
||||
@@ -185,9 +208,7 @@
|
||||
|
||||
function activateRow(row: NavRow) {
|
||||
if (row.kind === 'create') {
|
||||
void enterCreateMode()
|
||||
} else if (row.kind === 'create-from-root') {
|
||||
void enterCreateMode(undefined, true)
|
||||
requestCreateFork()
|
||||
} else if (row.kind === 'root') {
|
||||
if (!rootDisabled) void pick(row.id)
|
||||
} else if (row.kind === 'fork') {
|
||||
@@ -195,12 +216,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function defaultForkName(): string {
|
||||
// Delegated-create mode (onRequestCreateFork) closes the dropdown and hands
|
||||
// off; inline mode reveals the name input.
|
||||
function requestCreateFork() {
|
||||
if (onRequestCreateFork) {
|
||||
dropdownOpen = false
|
||||
creatingFork = false
|
||||
onRequestCreateFork()
|
||||
} else {
|
||||
void enterCreateMode()
|
||||
}
|
||||
}
|
||||
|
||||
function defaultForkId(): string {
|
||||
const taken = new Set($userWorkspaces.map((w) => w.id))
|
||||
if (pendingFork) taken.add(pendingFork.id)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const name = `${random_adj()}-fork`
|
||||
if (!taken.has(`${WM_FORK_PREFIX}${name}`)) return name
|
||||
const candidate = `${random_adj()}-fork`
|
||||
if (!taken.has(`${WM_FORK_PREFIX}${candidate}`)) return candidate
|
||||
}
|
||||
const base = `${random_adj()}-fork`
|
||||
let n = 1
|
||||
@@ -214,53 +247,64 @@
|
||||
await onPick(id)
|
||||
}
|
||||
|
||||
async function enterCreateMode(initialName?: string, fromRoot = false) {
|
||||
forkFromRoot = fromRoot
|
||||
function enterCreateMode(initialId?: string, baseId?: string) {
|
||||
// Default the target to the root; the user can switch to any fork in the family (fork of a fork).
|
||||
createForkBaseId = baseId ?? root?.id
|
||||
creatingFork = true
|
||||
newForkName = initialName ?? defaultForkName()
|
||||
await tick()
|
||||
forkInput?.focus()
|
||||
forkInput?.select()
|
||||
newForkId = initialId ?? defaultForkId()
|
||||
// Focus + select is handled by the input's own autofocus (it mounts with
|
||||
// the create form).
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
creatingFork = false
|
||||
newForkName = ''
|
||||
forkFromRoot = false
|
||||
newForkId = ''
|
||||
serverIdError = undefined
|
||||
}
|
||||
|
||||
function slugForkBaseId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
const forkNameError = $derived.by<string | undefined>(() => {
|
||||
const trimmed = newForkName.trim()
|
||||
// Mirror the backend's fork validation so a bad id is caught before the create call rather than
|
||||
// failing mid-creation: with the `wm-fork-` prefix the id must stay within 50 chars (a hard
|
||||
// DB/git-branch limit) and only contain slug characters.
|
||||
const clientIdError = $derived.by<string | undefined>(() => {
|
||||
const trimmed = newForkId.trim()
|
||||
if (!trimmed) return undefined
|
||||
const baseId = slugForkBaseId(trimmed)
|
||||
if (!baseId) return 'Name must contain at least one letter or number'
|
||||
const prefixed = `${WM_FORK_PREFIX}${baseId}`
|
||||
if (!/^\w+(-\w+)*$/.test(trimmed))
|
||||
return 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
const prefixed = `${WM_FORK_PREFIX}${trimmed}`
|
||||
if (prefixed.length > 50) return `ID is too long (max ${50 - WM_FORK_PREFIX.length} characters)`
|
||||
const taken = new Set($userWorkspaces.map((w) => w.id))
|
||||
if (pendingFork) taken.delete(pendingFork.id)
|
||||
if (taken.has(prefixed)) return 'A workspace with this name already exists'
|
||||
if (taken.has(prefixed)) return 'A workspace with this ID already exists'
|
||||
return undefined
|
||||
})
|
||||
// Server-reported reservation (e.g. an archived fork keeps its id, invisible
|
||||
// to the client workspace list). Set at stage time, cleared on edit.
|
||||
let serverIdError = $state<string | undefined>(undefined)
|
||||
const forkIdError = $derived(clientIdError ?? serverIdError)
|
||||
|
||||
let staging = $state(false)
|
||||
async function stageNewFork() {
|
||||
const name = newForkName.trim()
|
||||
if (!effectiveForkSource || !name || forkNameError || !onCreateFork) return
|
||||
const baseId = slugForkBaseId(name)
|
||||
if (!baseId) return
|
||||
const prefixed = `${WM_FORK_PREFIX}${baseId}`
|
||||
const bareId = newForkId.trim()
|
||||
if (!createForkBaseId || !bareId || clientIdError || staging || !onCreateFork) return
|
||||
const prefixed = `${WM_FORK_PREFIX}${bareId}`
|
||||
staging = true
|
||||
try {
|
||||
if (await WorkspaceService.existsWorkspace({ requestBody: { id: prefixed } })) {
|
||||
serverIdError = `'${prefixed}' already exists — it may be an archived fork (archiving keeps the id reserved)`
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Availability check failed (network/API): fall through — the create
|
||||
// call re-checks server-side anyway.
|
||||
} finally {
|
||||
staging = false
|
||||
}
|
||||
// Close optimistically; consumer can re-open + toast on error.
|
||||
creatingFork = false
|
||||
newForkName = ''
|
||||
forkFromRoot = false
|
||||
newForkId = ''
|
||||
dropdownOpen = false
|
||||
await onCreateFork({ parent_workspace_id: effectiveForkSource.id, id: prefixed, name })
|
||||
// The bare id doubles as the display name — forks have no separate name.
|
||||
await onCreateFork({ parent_workspace_id: createForkBaseId, id: prefixed, name: bareId })
|
||||
}
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
@@ -269,16 +313,21 @@
|
||||
}
|
||||
|
||||
// Reopening the dropdown while a pending fork is staged drops the user
|
||||
// directly into edit mode so they can refine the name. Avoids re-
|
||||
// directly into edit mode so they can refine the id. Avoids re-
|
||||
// entering edit mode after an explicit cancel.
|
||||
let lastDropdownOpen = $state(false)
|
||||
$effect(() => {
|
||||
const wasOpen = lastDropdownOpen
|
||||
lastDropdownOpen = dropdownOpen
|
||||
if (dropdownOpen && !wasOpen && pendingFork && !creatingFork && showCreateFork) {
|
||||
// Preserve which source the fork was staged from (root vs dev); without this the re-entry
|
||||
// defaults to the dev and silently re-parents a "Fork from <root>" request.
|
||||
void enterCreateMode(pendingFork.name, pendingFork.parent_workspace_id === root?.id)
|
||||
// Preserve the base the fork was staged from; without this the re-entry defaults to the root
|
||||
// and silently re-parents a fork that was staged off another fork.
|
||||
void enterCreateMode(
|
||||
pendingFork.id.startsWith(WM_FORK_PREFIX)
|
||||
? pendingFork.id.slice(WM_FORK_PREFIX.length)
|
||||
: pendingFork.name,
|
||||
pendingFork.parent_workspace_id
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -310,91 +359,186 @@
|
||||
placement="bottom-start"
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
class={triggerClass}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
{@render trigger({ open: dropdownOpen })}
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
{@const rowBase =
|
||||
'px-3 py-1.5 text-xs text-primary flex flex-row gap-2 items-center text-left rounded-sm w-full'}
|
||||
'px-3 py-1.5 text-xs font-normal text-primary flex flex-row gap-2 items-center text-left rounded-sm w-full'}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-left rounded-lg shadow-lg focus:outline-none py-1 flex flex-col max-h-80 overflow-y-auto"
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-left rounded-lg shadow-lg focus:outline-none py-1 flex flex-col max-h-[min(50vh,26rem)]"
|
||||
>
|
||||
<!-- Scrollable rows; the create-fork section and settings footer below stay pinned. -->
|
||||
<div class="flex flex-col overflow-y-auto min-h-0">
|
||||
{#if root}
|
||||
{@const rootIdx = 0}
|
||||
<!-- Rows are <button>s, so the copy affordance lives in a hover-revealed
|
||||
absolute sibling (nested buttons are invalid HTML). pr-8 reserves its
|
||||
spot so nothing shifts or gets covered on hover. -->
|
||||
<div class="relative group shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={rootDisabled}
|
||||
title={rootDisabled
|
||||
? devOfRoot
|
||||
? `${root.name} is locked. Run in its ${devLabelNoun(devOfRoot.dev_workspace_label)} instead.`
|
||||
: `${root.name} is locked for direct deploys.`
|
||||
: undefined}
|
||||
class={`${rowBase} pr-8 ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`}
|
||||
onmouseenter={() => !rootDisabled && (keyArrowPos = rootIdx)}
|
||||
onclick={() => !rootDisabled && void pick(root.id)}
|
||||
>
|
||||
<Building size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{root.name}</span>
|
||||
<span class="text-2xs text-tertiary shrink-0 ml-auto"
|
||||
>{rootDisabled ? 'locked' : 'root'}</span
|
||||
>
|
||||
{#if isSelected(root.id) && !pendingFork}
|
||||
<Check size={14} class="shrink-0 text-accent" />
|
||||
{/if}
|
||||
</button>
|
||||
<CopyButton
|
||||
value={root.id}
|
||||
title={`Copy id: ${root.id}`}
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#each forks as f, fi (f.id)}
|
||||
{@const forkIdx = (root ? 1 : 0) + fi}
|
||||
{@const accentStyle = forkAccentStyle(f.color)}
|
||||
<div class="relative group shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
style={[indentStyle(f.id), accentStyle].filter(Boolean).join('; ')}
|
||||
class={`${rowBase} pr-8 ${isSelected(f.id) ? 'bg-surface-selected' : ''} ${keyArrowPos === forkIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
onmouseenter={() => (keyArrowPos = forkIdx)}
|
||||
onclick={() => void pick(f.id)}
|
||||
>
|
||||
<GitFork
|
||||
size={14}
|
||||
class={`shrink-0 ${accentStyle ? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]' : 'text-tertiary'}`}
|
||||
/>
|
||||
<span
|
||||
class={`truncate ${accentStyle ? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]' : ''}`}
|
||||
>{f.name}</span
|
||||
>
|
||||
{#if f.is_dev_workspace}
|
||||
<Badge
|
||||
color="dark-blue"
|
||||
small
|
||||
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
|
||||
>{devBadgeText(f.dev_workspace_label)}</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if isSelected(f.id)}
|
||||
<Check size={14} class="shrink-0 ml-auto text-accent" />
|
||||
{/if}
|
||||
</button>
|
||||
<CopyButton
|
||||
value={f.id}
|
||||
title={`Copy id: ${f.id}`}
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if pendingFork && !creatingFork}
|
||||
<div
|
||||
class="px-3 py-1.5 text-xs text-primary flex flex-row gap-2 items-center text-left rounded-sm bg-surface-selected cursor-default"
|
||||
>
|
||||
<GitFork size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{pendingFork.name}</span>
|
||||
<span class="text-2xs text-tertiary italic shrink-0 ml-auto">New</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showCreateFork}
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
{#if creatingFork}
|
||||
<div class="flex flex-col gap-1 px-2 py-1.5">
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput
|
||||
bind:this={forkInput}
|
||||
bind:value={newForkName}
|
||||
size="xs"
|
||||
error={forkNameError}
|
||||
class="flex-1 min-w-0"
|
||||
inputProps={{
|
||||
placeholder: 'Fork name',
|
||||
autofocus: true,
|
||||
'aria-invalid': forkNameError ? 'true' : undefined,
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void stageNewFork()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cancelCreate()
|
||||
}
|
||||
<!-- Small inline form with labels: fork id + base ("target") workspace. The base
|
||||
defaults to the root; picking a fork there creates a fork of a fork. -->
|
||||
<div class="flex flex-col gap-2 px-2.5 py-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xs font-normal text-hint">Fork ID</span>
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
bind:value={newForkId}
|
||||
placeholder="my-fork"
|
||||
autofocus
|
||||
error={!!forkIdError}
|
||||
class="min-h-7"
|
||||
aria-invalid={forkIdError ? 'true' : undefined}
|
||||
oninput={() => (serverIdError = undefined)}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void stageNewFork()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cancelCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm"
|
||||
title="Stage fork"
|
||||
onclick={() => void stageNewFork()}
|
||||
disabled={!newForkName.trim() || !!forkNameError}
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-accent hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{#if forkNameError || createForkCaption}
|
||||
<div class="pl-6">
|
||||
<InputError error={forkNameError} />
|
||||
{#if !forkNameError && createForkCaption}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xs font-normal text-hint">Base workspace</span>
|
||||
<Select
|
||||
size="sm"
|
||||
class="w-full"
|
||||
disablePortal
|
||||
placeholder="Base workspace"
|
||||
items={baseItems}
|
||||
bind:value={createForkBaseId}
|
||||
>
|
||||
{#snippet startSnippet({ item })}
|
||||
{@const d = familyDepths.get(item.value) ?? 0}
|
||||
{#if d > 0}
|
||||
<span class="inline-block shrink-0" style="width: {d * 14}px"></span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Select>
|
||||
</div>
|
||||
{#if forkIdError || createForkCaption}
|
||||
<div>
|
||||
<InputError error={forkIdError} />
|
||||
{#if !forkIdError && createForkCaption}
|
||||
<span class="text-2xs text-tertiary">{createForkCaption}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row justify-end items-center gap-1.5 pt-0.5">
|
||||
<Button variant="subtle" unifiedSize="xs" on:click={() => cancelCreate()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: Check }}
|
||||
disabled={!newForkId.trim() || !!forkIdError || staging}
|
||||
on:click={() => void stageNewFork()}
|
||||
>
|
||||
Set as target
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const createIdx = 0}
|
||||
{@const createIdx = (root ? 1 : 0) + forks.length}
|
||||
<button
|
||||
type="button"
|
||||
class={`${rowBase} ${keyArrowPos === createIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
onmouseenter={() => (keyArrowPos = createIdx)}
|
||||
onclick={() => enterCreateMode()}
|
||||
onclick={() => requestCreateFork()}
|
||||
>
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<span>{createForkLabel}</span>
|
||||
</button>
|
||||
{#if hasCreateFromRoot}
|
||||
{@const createFromRootIdx = 1}
|
||||
<button
|
||||
type="button"
|
||||
class={`${rowBase} ${keyArrowPos === createFromRootIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
onmouseenter={() => (keyArrowPos = createFromRootIdx)}
|
||||
onclick={() => enterCreateMode(undefined, true)}
|
||||
>
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<span>Fork from {root?.name}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
{:else if showForkUpsell}
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
<div
|
||||
class={`${rowBase} opacity-60 cursor-not-allowed`}
|
||||
aria-disabled="true"
|
||||
@@ -405,54 +549,18 @@
|
||||
<span>{createForkLabel}</span>
|
||||
<span class="ml-auto shrink-0 text-2xs text-tertiary"> Workspace limit reached </span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if settingsHref}
|
||||
<!-- Pinned footer: stays visible while the fork list above scrolls. -->
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
{#if root}
|
||||
{@const rootIdx = (showCreateFork ? 1 : 0) + (hasCreateFromRoot ? 1 : 0)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={rootDisabled}
|
||||
title={rootDisabled
|
||||
? devOfRoot
|
||||
? `${root.name} is locked. Run in its ${devLabelNoun(devOfRoot.dev_workspace_label)} instead.`
|
||||
: `${root.name} is locked for direct deploys.`
|
||||
: undefined}
|
||||
class={`${rowBase} ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`}
|
||||
onmouseenter={() => !rootDisabled && (keyArrowPos = rootIdx)}
|
||||
onclick={() => !rootDisabled && void pick(root.id)}
|
||||
<a
|
||||
href={settingsHref}
|
||||
onclick={() => (dropdownOpen = false)}
|
||||
class={`${rowBase} shrink-0 hover:bg-surface-hover`}
|
||||
>
|
||||
<Building size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{root.name}</span>
|
||||
<span class="text-2xs text-tertiary shrink-0 ml-auto"
|
||||
>{rootDisabled ? 'locked' : 'root'}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{#each forks as f, fi (f.id)}
|
||||
{@const forkIdx =
|
||||
(showCreateFork ? 1 : 0) + (hasCreateFromRoot ? 1 : 0) + (root ? 1 : 0) + fi}
|
||||
<button
|
||||
type="button"
|
||||
class={`${rowBase} ${isSelected(f.id) ? 'bg-surface-selected' : ''} ${keyArrowPos === forkIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
onmouseenter={() => (keyArrowPos = forkIdx)}
|
||||
onclick={() => void pick(f.id)}
|
||||
>
|
||||
<GitFork size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{f.name}</span>
|
||||
{#if f.is_dev_workspace}
|
||||
<Badge color="indigo" small>{devBadgeText(f.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if pendingFork && !creatingFork}
|
||||
<div
|
||||
class="px-3 py-1.5 text-xs text-primary flex flex-row gap-2 items-center text-left rounded-sm bg-surface-selected cursor-default"
|
||||
>
|
||||
<GitFork size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{pendingFork.name}</span>
|
||||
<span class="text-2xs text-tertiary italic shrink-0 ml-auto">New</span>
|
||||
</div>
|
||||
<Settings size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="truncate">{settingsLabel ?? 'Workspace settings'}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parsePreviewItemRoute, resolvePreviewTab } from './previewRouter'
|
||||
import type { SessionTarget } from './sessionState.svelte'
|
||||
|
||||
describe('parsePreviewItemRoute', () => {
|
||||
it('maps edit/get routes to item kinds', () => {
|
||||
expect(parsePreviewItemRoute('/scripts/edit/f/foo/bar')).toEqual({
|
||||
kind: 'script',
|
||||
raw_app: false,
|
||||
itemPath: 'f/foo/bar'
|
||||
})
|
||||
expect(parsePreviewItemRoute('/flows/get/u/admin/baz')).toEqual({
|
||||
kind: 'flow',
|
||||
raw_app: false,
|
||||
itemPath: 'u/admin/baz'
|
||||
})
|
||||
expect(parsePreviewItemRoute('/apps_raw/edit/f/a/b')).toEqual({
|
||||
kind: 'app',
|
||||
raw_app: true,
|
||||
itemPath: 'f/a/b'
|
||||
})
|
||||
expect(parsePreviewItemRoute('/apps/edit/f/a/b')).toEqual({
|
||||
kind: 'app',
|
||||
raw_app: false,
|
||||
itemPath: 'f/a/b'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-item pages', () => {
|
||||
expect(parsePreviewItemRoute('/')).toBeNull()
|
||||
expect(parsePreviewItemRoute('/runs')).toBeNull()
|
||||
expect(parsePreviewItemRoute('/workspace_settings')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePreviewTab', () => {
|
||||
const scriptTarget: SessionTarget = { kind: 'script', path: 'f/foo/bar' }
|
||||
|
||||
it('routes a static page to the iframe fallback', () => {
|
||||
expect(resolvePreviewTab('/runs', scriptTarget)).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('routes the matching target item to a live editor', () => {
|
||||
expect(resolvePreviewTab('/scripts/edit/f/foo/bar', scriptTarget)).toEqual({
|
||||
kind: 'editor',
|
||||
editorKind: 'script',
|
||||
path: 'f/foo/bar'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a same-kind but different item to the iframe (one editor per session)', () => {
|
||||
expect(resolvePreviewTab('/scripts/edit/f/other/script', scriptTarget)).toEqual({
|
||||
kind: 'iframe'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a different-kind item to the iframe even when it matches no target', () => {
|
||||
expect(resolvePreviewTab('/flows/edit/f/foo/bar', scriptTarget)).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('maps a raw-app target to the raw_app editor kind', () => {
|
||||
const target: SessionTarget = { kind: 'raw_app', path: 'f/a/b' }
|
||||
expect(resolvePreviewTab('/apps_raw/edit/f/a/b', target)).toEqual({
|
||||
kind: 'editor',
|
||||
editorKind: 'raw_app',
|
||||
path: 'f/a/b'
|
||||
})
|
||||
})
|
||||
|
||||
it('never routes a regular drag-and-drop app to an editor (no wrapper exists)', () => {
|
||||
const target = { kind: 'raw_app', path: 'f/a/b' } as SessionTarget
|
||||
expect(resolvePreviewTab('/apps/edit/f/a/b', target)).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('falls back to the iframe when the session has no target', () => {
|
||||
expect(resolvePreviewTab('/scripts/edit/f/foo/bar', undefined)).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
Home,
|
||||
Play,
|
||||
DollarSign,
|
||||
Boxes,
|
||||
Calendar,
|
||||
Database,
|
||||
FolderOpen,
|
||||
Users,
|
||||
Settings,
|
||||
ScrollText
|
||||
} from 'lucide-svelte'
|
||||
import type { DrillIcon } from '$lib/components/drillPicker'
|
||||
import type { WorkspaceItem, WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
import type { SessionTarget } from './sessionState.svelte'
|
||||
import type { SessionTargetKind } from './sessionRuntime.svelte'
|
||||
|
||||
/** What the preview breadcrumb picker can route to: a static workspace page
|
||||
* or a workspace item (script/flow/app). The sessions page turns either into
|
||||
* an iframe URL. */
|
||||
export type PreviewTarget =
|
||||
| { type: 'page'; href: string; label: string }
|
||||
| { type: 'item'; item: WorkspaceItem }
|
||||
|
||||
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
|
||||
|
||||
// Core workspace-level destinations the preview can route to. Intentionally
|
||||
// curated — the main pages, not every trigger sub-page (those live behind
|
||||
// EE/feature gating in SidebarContent and aren't worth duplicating here).
|
||||
export const PREVIEW_PAGES: PreviewPage[] = [
|
||||
{ label: 'Home', path: '/', icon: Home },
|
||||
{ label: 'Runs', path: '/runs', icon: Play },
|
||||
{ label: 'Variables', path: '/variables', icon: DollarSign },
|
||||
{ label: 'Resources', path: '/resources', icon: Boxes },
|
||||
{ label: 'Schedules', path: '/schedules', icon: Calendar },
|
||||
{ label: 'Assets', path: '/assets', icon: Database },
|
||||
{ label: 'Folders', path: '/folders', icon: FolderOpen },
|
||||
{ label: 'Groups', path: '/groups', icon: Users },
|
||||
{ label: 'Workspace settings', path: '/workspace_settings', icon: Settings },
|
||||
{ label: 'Audit logs', path: '/audit_logs', icon: ScrollText }
|
||||
]
|
||||
|
||||
export const pageKey = (path: string) => `page:${path}`
|
||||
export const pageHref = (path: string) => `${base}${path}`
|
||||
|
||||
/** Strip the deployment base prefix (and any query/hash) from a preview path
|
||||
* so it can be matched against `PREVIEW_PAGES` / parsed as an item route. */
|
||||
export function stripBase(path: string): string {
|
||||
let p = path.split('?')[0].split('#')[0]
|
||||
if (base && p.startsWith(base)) p = p.slice(base.length)
|
||||
return p || '/'
|
||||
}
|
||||
|
||||
// Match a base-stripped preview pathname to a known page, for breadcrumb
|
||||
// labelling + picker highlight. Exact match; '/' only matches home.
|
||||
export function matchPreviewPage(path: string): PreviewPage | undefined {
|
||||
const clean = stripBase(path)
|
||||
return PREVIEW_PAGES.find((p) => p.path === clean)
|
||||
}
|
||||
|
||||
export type PreviewItemRoute = { kind: WorkspaceItemKind; raw_app: boolean; itemPath: string }
|
||||
|
||||
// Parse a preview URL/pathname into the workspace item it edits, or null for a
|
||||
// non-item page (home, runs, …). Shared by the breadcrumb (drill segments) and
|
||||
// the tab resolver below so both agree on what counts as an item route.
|
||||
export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null {
|
||||
const p = stripBase(fullPath)
|
||||
const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(?:edit|get)\/(.+)$/)
|
||||
if (!m) return null
|
||||
const itemPath = decodeURIComponent(m[2])
|
||||
if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath }
|
||||
if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath }
|
||||
if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath }
|
||||
return { kind: 'app', raw_app: false, itemPath }
|
||||
}
|
||||
|
||||
// How a preview tab should render: as an in-process live editor (sharing the
|
||||
// session runtime's store) or as an iframe fallback. Only the three kinds with
|
||||
// existing editor wrappers — and only the tab matching the session's target —
|
||||
// resolve to 'editor'; everything else (static pages, regular drag-and-drop
|
||||
// apps, any other item) stays an iframe.
|
||||
export type PreviewSlot =
|
||||
| { kind: 'editor'; editorKind: SessionTargetKind; path: string }
|
||||
| { kind: 'iframe' }
|
||||
|
||||
export function resolvePreviewTab(url: string, target: SessionTarget | undefined): PreviewSlot {
|
||||
const route = parsePreviewItemRoute(url)
|
||||
if (!route) return { kind: 'iframe' }
|
||||
const editorKind: SessionTargetKind | undefined =
|
||||
route.kind === 'script'
|
||||
? 'script'
|
||||
: route.kind === 'flow'
|
||||
? 'flow'
|
||||
: route.kind === 'app' && route.raw_app
|
||||
? 'raw_app'
|
||||
: undefined
|
||||
if (!editorKind) return { kind: 'iframe' }
|
||||
// SessionRuntime holds one load slot per kind, so only the tab pointing at the
|
||||
// session's own target claims it as a live editor; any other item previews as
|
||||
// an iframe (the "one live editor per session" rule).
|
||||
if (!target || target.kind !== editorKind || target.path !== route.itemPath) {
|
||||
return { kind: 'iframe' }
|
||||
}
|
||||
return { kind: 'editor', editorKind, path: route.itemPath }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { base } from '$app/paths'
|
||||
import type { SessionTarget } from './sessionState.svelte'
|
||||
|
||||
// Maps a session's editor target to the canonical full-page Windmill route.
|
||||
export function sessionTargetHref(target: SessionTarget | undefined): string | undefined {
|
||||
if (!target) return undefined
|
||||
const seg =
|
||||
target.kind === 'script'
|
||||
? 'scripts/edit'
|
||||
: target.kind === 'flow'
|
||||
? 'flows/edit'
|
||||
: target.kind === 'raw_app'
|
||||
? 'apps_raw/edit'
|
||||
: undefined
|
||||
if (!seg) return undefined
|
||||
return `${base}/${seg}/${target.path}`
|
||||
}
|
||||
|
||||
// Force the global sidebar off in the previewed page (the sessions page already
|
||||
// has its own navigation rail) by setting Windmill's `nomenubar` query flag.
|
||||
// A session deliberately never switches the global workspaceStore, so the iframe
|
||||
// must carry the session's effective workspace as `?workspace=` — the logged
|
||||
// layout reads that param to scope the page, otherwise it renders against the
|
||||
// navigation workspace (wrong data for a fork-scoped session). Returns a
|
||||
// relative URL so the iframe stays same-origin.
|
||||
export function withMenuHidden(url: string, workspaceId?: string): string {
|
||||
try {
|
||||
const u = new URL(url, 'http://_')
|
||||
u.searchParams.set('nomenubar', 'true')
|
||||
if (workspaceId) u.searchParams.set('workspace', workspaceId)
|
||||
return u.pathname + u.search + u.hash
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// Append `?workspace=` to a canonical route so a full-page navigation (e.g.
|
||||
// "Open in workspace") lands on the session's effective workspace instead of
|
||||
// the navigation workspace. Unlike withMenuHidden, the menu is kept visible —
|
||||
// this leaves the session for the real workspace UI. No-op without a workspace.
|
||||
export function withWorkspaceParam(url: string, workspaceId?: string): string {
|
||||
if (!workspaceId) return url
|
||||
try {
|
||||
const u = new URL(url, 'http://_')
|
||||
u.searchParams.set('workspace', workspaceId)
|
||||
return u.pathname + u.search + u.hash
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sessionTargetHref, withMenuHidden, withWorkspaceParam } from './sessionMode.svelte'
|
||||
|
||||
describe('sessionTargetHref', () => {
|
||||
it('maps each editor kind to its full-page route', () => {
|
||||
expect(sessionTargetHref({ kind: 'script', path: 'u/me/foo' })).toBe('/scripts/edit/u/me/foo')
|
||||
expect(sessionTargetHref({ kind: 'flow', path: 'u/me/bar' })).toBe('/flows/edit/u/me/bar')
|
||||
expect(sessionTargetHref({ kind: 'raw_app', path: 'u/me/app' })).toBe('/apps_raw/edit/u/me/app')
|
||||
})
|
||||
|
||||
it('returns undefined for no target or a kind without a full-page route', () => {
|
||||
expect(sessionTargetHref(undefined)).toBeUndefined()
|
||||
expect(sessionTargetHref({ kind: 'pipeline', path: 'u/me/pipe' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withMenuHidden', () => {
|
||||
it('appends the nomenubar flag', () => {
|
||||
expect(withMenuHidden('/runs')).toBe('/runs?nomenubar=true')
|
||||
})
|
||||
|
||||
it('preserves existing query params and the hash', () => {
|
||||
expect(withMenuHidden('/runs?tag=x#frag')).toBe('/runs?tag=x&nomenubar=true#frag')
|
||||
})
|
||||
|
||||
it('overwrites an existing nomenubar value instead of duplicating it', () => {
|
||||
expect(withMenuHidden('/runs?nomenubar=false')).toBe('/runs?nomenubar=true')
|
||||
})
|
||||
|
||||
it('scopes the page to a workspace when one is given', () => {
|
||||
expect(withMenuHidden('/runs', 'fork_ws')).toBe('/runs?nomenubar=true&workspace=fork_ws')
|
||||
})
|
||||
|
||||
it('omits the workspace param when none is given', () => {
|
||||
expect(withMenuHidden('/runs', undefined)).toBe('/runs?nomenubar=true')
|
||||
})
|
||||
|
||||
it('returns unparseable input unchanged', () => {
|
||||
expect(withMenuHidden('http://[bad')).toBe('http://[bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withWorkspaceParam', () => {
|
||||
it('appends the workspace param without touching the menu', () => {
|
||||
expect(withWorkspaceParam('/scripts/get/abc', 'fork_ws')).toBe(
|
||||
'/scripts/get/abc?workspace=fork_ws'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves existing query params and the hash', () => {
|
||||
expect(withWorkspaceParam('/runs?tag=x#frag', 'fork_ws')).toBe(
|
||||
'/runs?tag=x&workspace=fork_ws#frag'
|
||||
)
|
||||
})
|
||||
|
||||
it('is a no-op without a workspace', () => {
|
||||
expect(withWorkspaceParam('/runs')).toBe('/runs')
|
||||
})
|
||||
|
||||
it('returns unparseable input unchanged when a workspace is given', () => {
|
||||
expect(withWorkspaceParam('http://[bad', 'fork_ws')).toBe('http://[bad')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { base } from '$lib/base'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { editPathFor, type WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import {
|
||||
matchPreviewPage,
|
||||
parsePreviewItemRoute,
|
||||
resolvePreviewTab,
|
||||
stripBase,
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
import { sessionTargetHref } from './sessionMode.svelte'
|
||||
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
|
||||
|
||||
// The single live owner of a session's preview tabs. Runs behind a small
|
||||
// interface both the sessions page (renderer) and the `open_preview` tool cross,
|
||||
// so there is exactly one live copy of the tab model instead of three drifting
|
||||
// ones synced by effects. Persistence and the session-record `target` write are
|
||||
// injected as an adapter, so the class is pure runes with no sessionState / IDB
|
||||
// coupling (mirrors PipelineEditorState). Held on SessionRuntime.previewTabs.
|
||||
|
||||
export type PreviewTabsSnapshot = {
|
||||
tabs: SessionPreviewTab[]
|
||||
activeId: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
export type PreviewTabsAdapter = {
|
||||
// Write-behind the full tab model onto the durable backing (debounced by the
|
||||
// owner). Fire-and-forget.
|
||||
persist: (snapshot: PreviewTabsSnapshot) => void
|
||||
// Point the session's live editor at `target`. Called atomically with the tab
|
||||
// open/navigate that shows the item, so tab and target can never drift apart.
|
||||
setTarget: (target: SessionTarget) => void
|
||||
}
|
||||
|
||||
// URL a tab should load for a destination: a page's href, or an item's edit route.
|
||||
function targetUrl(target: PreviewTarget): string {
|
||||
return target.type === 'page' ? target.href : `${base}${editPathFor(target.item)}`
|
||||
}
|
||||
|
||||
// Strip the query params the sessions preview injects into iframe URLs
|
||||
// (`nomenubar` to hide the nav, `workspace` to scope the page): they aren't part
|
||||
// of the canonical page URL. The observed `loc` must drop them to stay symmetric
|
||||
// with `url` (targetUrl, which never carries them), else reopening the same page
|
||||
// spawns a duplicate tab instead of focusing the existing one.
|
||||
export function canonicalizeObservedLoc(loc: string): string {
|
||||
try {
|
||||
const u = new URL(loc, 'http://_')
|
||||
u.searchParams.delete('nomenubar')
|
||||
u.searchParams.delete('workspace')
|
||||
return u.pathname + u.search + u.hash
|
||||
} catch {
|
||||
return loc
|
||||
}
|
||||
}
|
||||
|
||||
// The editor target a destination maps to, or undefined when it isn't an item we
|
||||
// host live (static pages, legacy drag-and-drop apps). Drives the "set the
|
||||
// session target iff the destination is an editable item" rule.
|
||||
function editorTargetFor(target: PreviewTarget): SessionTarget | undefined {
|
||||
if (target.type !== 'item') return undefined
|
||||
const item = target.item
|
||||
if (item.kind === 'script') return { kind: 'script', path: item.path }
|
||||
if (item.kind === 'flow') return { kind: 'flow', path: item.path }
|
||||
if (item.kind === 'app' && item.raw_app) return { kind: 'raw_app', path: item.path }
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Adapt a session editor target (`open_preview` tool arg) to a preview
|
||||
// destination. Pipeline targets have no full-page route, so they can't be
|
||||
// previewed as a tab (returns undefined).
|
||||
export function previewTargetForSessionTarget(
|
||||
kind: SessionTarget['kind'],
|
||||
path: string
|
||||
): PreviewTarget | undefined {
|
||||
if (kind === 'pipeline') return undefined
|
||||
const item: WorkspaceItem =
|
||||
kind === 'raw_app'
|
||||
? { kind: 'app', raw_app: true, path, summary: '' }
|
||||
: { kind, path, summary: '' }
|
||||
return { type: 'item', item }
|
||||
}
|
||||
|
||||
// Build the initial tab model for a session: its saved tabs, else a single tab
|
||||
// on its editor target, else empty. Default collapse: collapsed only for a
|
||||
// session with nothing to preview.
|
||||
export function hydratePreviewTabs(session: {
|
||||
previewTabs?: SessionPreviewTab[]
|
||||
activePreviewTabId?: string
|
||||
previewCollapsed?: boolean
|
||||
target?: SessionTarget
|
||||
}): PreviewTabsSnapshot {
|
||||
// Saved tabs come straight from IndexedDB — drop malformed records (missing
|
||||
// id/url) and duplicate ids, which would break the page's keyed {#each}.
|
||||
const seen = new Set<string>()
|
||||
const tabs: SessionPreviewTab[] = []
|
||||
for (const t of session.previewTabs ?? []) {
|
||||
if (!t?.id || !t?.url || seen.has(t.id)) continue
|
||||
seen.add(t.id)
|
||||
// Rebuilt field-by-field so stray properties on old saved records (e.g. the
|
||||
// retired `pinned` flag) don't survive hydration and get persisted back.
|
||||
tabs.push({ id: t.id, url: t.url, loc: t.loc ?? t.url })
|
||||
}
|
||||
if (tabs.length > 0) {
|
||||
const wantActive = session.activePreviewTabId
|
||||
const activeId = wantActive && tabs.some((t) => t.id === wantActive) ? wantActive : tabs[0].id
|
||||
return { tabs, activeId, collapsed: session.previewCollapsed ?? false }
|
||||
}
|
||||
const seedUrl = sessionTargetHref(session.target)
|
||||
if (seedUrl) {
|
||||
return {
|
||||
tabs: [{ id: 'session', url: seedUrl, loc: seedUrl }],
|
||||
activeId: 'session',
|
||||
collapsed: session.previewCollapsed ?? false
|
||||
}
|
||||
}
|
||||
return { tabs: [], activeId: '', collapsed: session.previewCollapsed ?? true }
|
||||
}
|
||||
|
||||
const FLUSH_DELAY_MS = 400
|
||||
|
||||
export class SessionPreviewTabs {
|
||||
// Each tab tracks two URLs: `url` is what we command the iframe to load
|
||||
// (changes only on an explicit open/navigate), `loc` the last observed
|
||||
// location. Keeping them separate lets a tab stay mounted — in-iframe
|
||||
// navigation updates `loc` only, so `url` (bound to `src`) never reloads.
|
||||
#tabs = $state<SessionPreviewTab[]>([])
|
||||
#activeId = $state('')
|
||||
#collapsed = $state(false)
|
||||
readonly #adapter: PreviewTabsAdapter
|
||||
readonly #flushDelay: number
|
||||
#flushHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(
|
||||
initial: PreviewTabsSnapshot,
|
||||
adapter: PreviewTabsAdapter,
|
||||
flushDelay = FLUSH_DELAY_MS
|
||||
) {
|
||||
this.#tabs = initial.tabs.map((t) => ({ ...t }))
|
||||
this.#activeId = initial.activeId
|
||||
this.#collapsed = initial.collapsed
|
||||
this.#adapter = adapter
|
||||
this.#flushDelay = flushDelay
|
||||
}
|
||||
|
||||
get tabs(): SessionPreviewTab[] {
|
||||
return this.#tabs
|
||||
}
|
||||
get activeId(): string {
|
||||
return this.#activeId
|
||||
}
|
||||
get activeTab(): SessionPreviewTab | undefined {
|
||||
return this.#tabs.find((t) => t.id === this.#activeId) ?? this.#tabs[0]
|
||||
}
|
||||
get collapsed(): boolean {
|
||||
return this.#collapsed
|
||||
}
|
||||
|
||||
// Open — or focus, if already shown — a tab for a destination, and reveal the
|
||||
// panel. An editable item is made the session's live editor (setTarget) and
|
||||
// deduped against the tab already hosting it; anything else dedupes on the
|
||||
// tab's observed location.
|
||||
open(target: PreviewTarget): { status: 'opened' | 'focused' } {
|
||||
const editorTarget = editorTargetFor(target)
|
||||
if (editorTarget) this.#adapter.setTarget(editorTarget)
|
||||
// A fresh session starts collapsed, so without this the tab opens behind a
|
||||
// collapsed panel and the user sees nothing change.
|
||||
this.#collapsed = false
|
||||
if (editorTarget) {
|
||||
// resolvePreviewTab(url, target) is 'editor' exactly for the tab showing
|
||||
// `target`'s item, so it doubles as the dedupe test.
|
||||
const existing = this.#tabs.find(
|
||||
(t) => resolvePreviewTab(t.url, editorTarget).kind === 'editor'
|
||||
)
|
||||
if (existing) {
|
||||
this.#activeId = existing.id
|
||||
this.#flush()
|
||||
return { status: 'focused' }
|
||||
}
|
||||
}
|
||||
const url = targetUrl(target)
|
||||
// Focus the tab currently *showing* this destination instead of opening a
|
||||
// duplicate. Matched on the observed `loc`, not `url`: a tab that was
|
||||
// opened here but navigated away no longer counts as showing it.
|
||||
const shown = this.#tabs.find((t) => t.loc === url)
|
||||
if (shown) {
|
||||
this.#activeId = shown.id
|
||||
this.#flush()
|
||||
return { status: 'focused' }
|
||||
}
|
||||
const tab: SessionPreviewTab = { id: randomUUID(), url, loc: url }
|
||||
this.#tabs.push(tab)
|
||||
this.#activeId = tab.id
|
||||
this.#flush()
|
||||
return { status: 'opened' }
|
||||
}
|
||||
|
||||
// Re-point the active tab at a destination (breadcrumb pick / in-editor link /
|
||||
// iframe-posted editor navigation). Same target rule as open: an editable item
|
||||
// becomes the session's live editor.
|
||||
navigate(target: PreviewTarget): void {
|
||||
const t = this.#tabs.find((x) => x.id === this.#activeId)
|
||||
if (!t) return
|
||||
const editorTarget = editorTargetFor(target)
|
||||
if (editorTarget) {
|
||||
this.#adapter.setTarget(editorTarget)
|
||||
// Same dedupe as open(): if another tab already hosts `target` as the
|
||||
// live editor, focus it instead of re-pointing this one — two tabs
|
||||
// resolving 'editor' for one target would mount two editor instances
|
||||
// on the same runtime slot.
|
||||
const existing = this.#tabs.find(
|
||||
(x) => resolvePreviewTab(x.url, editorTarget).kind === 'editor'
|
||||
)
|
||||
if (existing && existing.id !== t.id) {
|
||||
this.#activeId = existing.id
|
||||
this.#flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
const url = targetUrl(target)
|
||||
t.url = url
|
||||
t.loc = url
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
// Replace the whole tab model and reveal the panel. For re-pointing an
|
||||
// existing draft session at a new destination, where the current tabs
|
||||
// (persisted and/or live) still show the previous one.
|
||||
reset(tabs: SessionPreviewTab[], activeId: string): void {
|
||||
this.#tabs = tabs.map((t) => ({ ...t }))
|
||||
this.#activeId = activeId
|
||||
this.#collapsed = false
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
select(id: string): void {
|
||||
if (this.#activeId === id) return
|
||||
this.#activeId = id
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const idx = this.#tabs.findIndex((t) => t.id === id)
|
||||
if (idx < 0) return
|
||||
this.#tabs.splice(idx, 1)
|
||||
if (this.#activeId === id) {
|
||||
this.#activeId = (this.#tabs[idx] ?? this.#tabs[idx - 1] ?? this.#tabs[0])?.id ?? ''
|
||||
}
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
setCollapsed(collapsed: boolean): void {
|
||||
if (this.#collapsed === collapsed) return
|
||||
this.#collapsed = collapsed
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
// Feed back the location an iframe reported on load (only the page can read
|
||||
// contentWindow.location). Updates the observed `loc`, leaving `url` alone so
|
||||
// the tab doesn't reload.
|
||||
observeLocation(id: string, loc: string): void {
|
||||
const t = this.#tabs.find((x) => x.id === id)
|
||||
if (!t) return
|
||||
const canonical = canonicalizeObservedLoc(loc)
|
||||
if (t.loc === canonical) return
|
||||
t.loc = canonical
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
// Persist a pending write immediately, cancelling the debounce. Called on
|
||||
// page hide — a mutation inside the debounce window would otherwise be lost
|
||||
// to a reload/navigation. No-op when nothing is pending.
|
||||
flushNow(): void {
|
||||
if (this.#flushHandle === undefined) return
|
||||
clearTimeout(this.#flushHandle)
|
||||
this.#flushHandle = undefined
|
||||
this.#persistNow()
|
||||
}
|
||||
|
||||
#flush(): void {
|
||||
clearTimeout(this.#flushHandle)
|
||||
this.#flushHandle = setTimeout(() => {
|
||||
this.#flushHandle = undefined
|
||||
this.#persistNow()
|
||||
}, this.#flushDelay)
|
||||
}
|
||||
|
||||
#persistNow(): void {
|
||||
this.#adapter.persist({
|
||||
tabs: this.#tabs.map((t) => ({ ...t })),
|
||||
activeId: this.#activeId,
|
||||
collapsed: this.#collapsed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable summary of a session's open preview tabs, for the
|
||||
// `get_preview_status` AI tool. Pure over the owner's model + the session target
|
||||
// so the owner needs no target-read dependency. The "no session" case is the
|
||||
// caller's (the tool handler has the session context).
|
||||
export function describePreview(
|
||||
tabs: SessionPreviewTab[],
|
||||
activeId: string,
|
||||
target: SessionTarget | undefined
|
||||
): string {
|
||||
if (tabs.length === 0) return 'No preview tabs are open in the side panel.'
|
||||
const lines = tabs.map((t) => {
|
||||
const where = t.loc || t.url
|
||||
const page = matchPreviewPage(where)
|
||||
const route = parsePreviewItemRoute(where)
|
||||
const label = page
|
||||
? `page "${page.label}"`
|
||||
: route
|
||||
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
|
||||
: stripBase(where)
|
||||
const live = resolvePreviewTab(t.url, target).kind === 'editor' ? ', live editor' : ''
|
||||
const active = t.id === activeId ? ', active' : ''
|
||||
return `- ${label}${live}${active}`
|
||||
})
|
||||
return `${tabs.length} preview tab${tabs.length === 1 ? '' : 's'} open in the side panel:\n${lines.join('\n')}`
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
SessionPreviewTabs,
|
||||
describePreview,
|
||||
hydratePreviewTabs,
|
||||
previewTargetForSessionTarget,
|
||||
type PreviewTabsAdapter,
|
||||
type PreviewTabsSnapshot
|
||||
} from './sessionPreviewTabs.svelte'
|
||||
import type { PreviewTarget } from './previewRouter'
|
||||
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
|
||||
|
||||
// In-memory adapter spy: records persisted snapshots + target writes, no IDB.
|
||||
function makeAdapter() {
|
||||
const persisted: PreviewTabsSnapshot[] = []
|
||||
const targets: SessionTarget[] = []
|
||||
const adapter: PreviewTabsAdapter = {
|
||||
persist: (snap) => persisted.push(snap),
|
||||
setTarget: (t) => targets.push(t)
|
||||
}
|
||||
return { adapter, persisted, targets }
|
||||
}
|
||||
|
||||
function owner(initial: Partial<PreviewTabsSnapshot> = {}, adapter?: PreviewTabsAdapter) {
|
||||
return new SessionPreviewTabs(
|
||||
{ tabs: [], activeId: '', collapsed: false, ...initial },
|
||||
adapter ?? makeAdapter().adapter,
|
||||
// Deterministic debounce for the tests.
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const scriptTarget: PreviewTarget = {
|
||||
type: 'item',
|
||||
item: { kind: 'script', path: 'u/me/foo', summary: '' }
|
||||
}
|
||||
const flowTarget: PreviewTarget = {
|
||||
type: 'item',
|
||||
item: { kind: 'flow', path: 'u/me/bar', summary: '' }
|
||||
}
|
||||
const rawAppTarget: PreviewTarget = {
|
||||
type: 'item',
|
||||
item: { kind: 'app', raw_app: true, path: 'u/me/app', summary: '' }
|
||||
}
|
||||
const dndAppTarget: PreviewTarget = {
|
||||
type: 'item',
|
||||
item: { kind: 'app', path: 'u/me/legacy', summary: '' }
|
||||
}
|
||||
const pageTarget: PreviewTarget = { type: 'page', href: '/runs', label: 'Runs' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('hydratePreviewTabs', () => {
|
||||
it('uses saved tabs and a valid active id, panel open', () => {
|
||||
const tabs: SessionPreviewTab[] = [
|
||||
{ id: 'a', url: '/x', loc: '/x' },
|
||||
{ id: 'b', url: '/y', loc: '/y' }
|
||||
]
|
||||
const snap = hydratePreviewTabs({ previewTabs: tabs, activePreviewTabId: 'b' })
|
||||
expect(snap.tabs).toHaveLength(2)
|
||||
expect(snap.activeId).toBe('b')
|
||||
expect(snap.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the first tab when the saved active id is stale', () => {
|
||||
const snap = hydratePreviewTabs({
|
||||
previewTabs: [{ id: 'a', url: '/x', loc: '/x' }],
|
||||
activePreviewTabId: 'gone'
|
||||
})
|
||||
expect(snap.activeId).toBe('a')
|
||||
})
|
||||
|
||||
it('seeds a single tab on the editor target when there are no saved tabs', () => {
|
||||
const snap = hydratePreviewTabs({ target: { kind: 'script', path: 'u/me/foo' } })
|
||||
expect(snap.tabs).toEqual([
|
||||
{ id: 'session', url: '/scripts/edit/u/me/foo', loc: '/scripts/edit/u/me/foo' }
|
||||
])
|
||||
expect(snap.activeId).toBe('session')
|
||||
expect(snap.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('is empty and collapsed for a session with nothing to preview', () => {
|
||||
const snap = hydratePreviewTabs({})
|
||||
expect(snap.tabs).toEqual([])
|
||||
expect(snap.activeId).toBe('')
|
||||
expect(snap.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('honours an explicit previewCollapsed override', () => {
|
||||
expect(
|
||||
hydratePreviewTabs({ previewCollapsed: true, target: { kind: 'script', path: 'p' } })
|
||||
.collapsed
|
||||
).toBe(true)
|
||||
expect(hydratePreviewTabs({ previewCollapsed: false }).collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('drops malformed saved tabs, duplicate ids and stray fields, defaulting loc to url', () => {
|
||||
const snap = hydratePreviewTabs({
|
||||
previewTabs: [
|
||||
// `pinned: true` mimics a record saved before the flag was retired.
|
||||
{ id: 'a', url: '/x', pinned: true } as unknown as SessionPreviewTab,
|
||||
{ id: '', url: '/no-id', loc: '/no-id' },
|
||||
{ id: 'b', url: '', loc: '' },
|
||||
{ id: 'a', url: '/dupe', loc: '/dupe' }
|
||||
],
|
||||
activePreviewTabId: 'a'
|
||||
})
|
||||
expect(snap.tabs).toEqual([{ id: 'a', url: '/x', loc: '/x' }])
|
||||
expect(snap.activeId).toBe('a')
|
||||
})
|
||||
|
||||
it('falls back to the target seed when every saved tab is malformed', () => {
|
||||
const snap = hydratePreviewTabs({
|
||||
previewTabs: [{ id: '', url: '', loc: '' }],
|
||||
target: { kind: 'script', path: 'u/me/foo' }
|
||||
})
|
||||
expect(snap.tabs).toHaveLength(1)
|
||||
expect(snap.activeId).toBe('session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewTargetForSessionTarget', () => {
|
||||
it('maps raw_app to a raw app item', () => {
|
||||
expect(previewTargetForSessionTarget('raw_app', 'u/me/app')).toEqual({
|
||||
type: 'item',
|
||||
item: { kind: 'app', raw_app: true, path: 'u/me/app', summary: '' }
|
||||
})
|
||||
})
|
||||
it('maps script/flow straight through', () => {
|
||||
expect(previewTargetForSessionTarget('script', 'p')).toEqual({
|
||||
type: 'item',
|
||||
item: { kind: 'script', path: 'p', summary: '' }
|
||||
})
|
||||
expect(previewTargetForSessionTarget('flow', 'p')).toEqual({
|
||||
type: 'item',
|
||||
item: { kind: 'flow', path: 'p', summary: '' }
|
||||
})
|
||||
})
|
||||
it('returns undefined for pipeline (no full-page route)', () => {
|
||||
expect(previewTargetForSessionTarget('pipeline', 'p')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreviewTabs.open', () => {
|
||||
it('opens an editor item, points the target at it, activates it, and reveals the panel', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({ collapsed: true }, adapter)
|
||||
const res = o.open(scriptTarget)
|
||||
expect(res.status).toBe('opened')
|
||||
expect(o.tabs).toHaveLength(1)
|
||||
expect(o.tabs[0].url).toBe('/scripts/edit/u/me/foo')
|
||||
expect(o.activeId).toBe(o.tabs[0].id)
|
||||
expect(o.collapsed).toBe(false)
|
||||
expect(targets).toEqual([{ kind: 'script', path: 'u/me/foo' }])
|
||||
})
|
||||
|
||||
it('focuses the existing tab instead of duplicating when the item is already shown', () => {
|
||||
const o = owner()
|
||||
o.open(scriptTarget)
|
||||
const firstId = o.tabs[0].id
|
||||
o.select('nonexistent-noop') // no-op: not present
|
||||
const res = o.open(scriptTarget)
|
||||
expect(res.status).toBe('focused')
|
||||
expect(o.tabs).toHaveLength(1)
|
||||
expect(o.activeId).toBe(firstId)
|
||||
})
|
||||
|
||||
it('opens a second tab and repoints the live editor for a different item', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(scriptTarget)
|
||||
const res = o.open(flowTarget)
|
||||
expect(res.status).toBe('opened')
|
||||
expect(o.tabs).toHaveLength(2)
|
||||
expect(targets.at(-1)).toEqual({ kind: 'flow', path: 'u/me/bar' })
|
||||
})
|
||||
|
||||
it('opens a raw app via its apps_raw route', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(rawAppTarget)
|
||||
expect(o.tabs[0].url).toBe('/apps_raw/edit/u/me/app')
|
||||
expect(targets).toEqual([{ kind: 'raw_app', path: 'u/me/app' }])
|
||||
})
|
||||
|
||||
it('focuses the tab already showing a page instead of duplicating, and never sets a target', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(pageTarget)
|
||||
const firstId = o.activeId
|
||||
o.open(scriptTarget)
|
||||
const res = o.open(pageTarget)
|
||||
expect(res.status).toBe('focused')
|
||||
expect(o.tabs).toHaveLength(2)
|
||||
expect(o.activeId).toBe(firstId)
|
||||
expect(targets).toEqual([{ kind: 'script', path: 'u/me/foo' }])
|
||||
})
|
||||
|
||||
it('opens a fresh page tab when the original navigated away', () => {
|
||||
const o = owner()
|
||||
o.open(pageTarget)
|
||||
o.observeLocation(o.activeId, '/variables')
|
||||
const res = o.open(pageTarget)
|
||||
expect(res.status).toBe('opened')
|
||||
expect(o.tabs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('focuses (not duplicates) a page tab whose iframe reported the injected workspace/nomenubar params', () => {
|
||||
const o = owner()
|
||||
o.open(pageTarget) // /runs
|
||||
// The iframe loads with the params the preview injects on the src.
|
||||
o.observeLocation(o.activeId, '/runs?nomenubar=true&workspace=wm-fork-x')
|
||||
const res = o.open(pageTarget)
|
||||
expect(res.status).toBe('focused')
|
||||
expect(o.tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not set a target for a legacy drag-and-drop app', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(dndAppTarget)
|
||||
expect(targets).toEqual([])
|
||||
expect(o.tabs[0].url).toBe('/apps/edit/u/me/legacy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreviewTabs.navigate', () => {
|
||||
it('retargets the active tab and sets the target for an editor item', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(pageTarget)
|
||||
const tabId = o.activeId
|
||||
o.navigate(flowTarget)
|
||||
expect(o.tabs).toHaveLength(1)
|
||||
expect(o.activeId).toBe(tabId)
|
||||
expect(o.tabs[0].url).toBe('/flows/edit/u/me/bar')
|
||||
expect(o.tabs[0].loc).toBe('/flows/edit/u/me/bar')
|
||||
expect(targets).toEqual([{ kind: 'flow', path: 'u/me/bar' }])
|
||||
})
|
||||
|
||||
it('no-ops with no active tab', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.navigate(flowTarget)
|
||||
expect(o.tabs).toHaveLength(0)
|
||||
expect(targets).toEqual([])
|
||||
})
|
||||
|
||||
it('retargets to a page without touching the target', () => {
|
||||
const { adapter, targets } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(scriptTarget)
|
||||
targets.length = 0
|
||||
o.navigate(pageTarget)
|
||||
expect(o.tabs[0].url).toBe('/runs')
|
||||
expect(targets).toEqual([])
|
||||
})
|
||||
|
||||
it('focuses the tab already hosting the item instead of duplicating the editor', () => {
|
||||
const { adapter } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(scriptTarget)
|
||||
const editorTabId = o.activeId
|
||||
o.open(pageTarget)
|
||||
const pageTabId = o.activeId
|
||||
o.navigate(scriptTarget)
|
||||
expect(o.activeId).toBe(editorTabId)
|
||||
expect(o.tabs).toHaveLength(2)
|
||||
// The page tab must keep its own url — only focus moved.
|
||||
expect(o.tabs.find((t) => t.id === pageTabId)?.url).toBe('/runs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreviewTabs.select / close / setCollapsed', () => {
|
||||
it('selects a tab', () => {
|
||||
const o = owner({
|
||||
tabs: [
|
||||
{ id: 'a', url: '/x', loc: '/x' },
|
||||
{ id: 'b', url: '/y', loc: '/y' }
|
||||
],
|
||||
activeId: 'a'
|
||||
})
|
||||
o.select('b')
|
||||
expect(o.activeId).toBe('b')
|
||||
expect(o.activeTab?.id).toBe('b')
|
||||
})
|
||||
|
||||
it('closes a tab and picks a neighbour when the active one goes', () => {
|
||||
const o = owner({
|
||||
tabs: [
|
||||
{ id: 'a', url: '/x', loc: '/x' },
|
||||
{ id: 'b', url: '/y', loc: '/y' },
|
||||
{ id: 'c', url: '/z', loc: '/z' }
|
||||
],
|
||||
activeId: 'b'
|
||||
})
|
||||
o.close('b')
|
||||
expect(o.tabs.map((t) => t.id)).toEqual(['a', 'c'])
|
||||
expect(o.activeId).toBe('c')
|
||||
})
|
||||
|
||||
it('closing the last tab empties the model', () => {
|
||||
const o = owner({
|
||||
tabs: [{ id: 'session', url: '/x', loc: '/x' }],
|
||||
activeId: 'session'
|
||||
})
|
||||
o.close('session')
|
||||
expect(o.tabs).toHaveLength(0)
|
||||
expect(o.activeId).toBe('')
|
||||
})
|
||||
|
||||
it('toggles collapsed', () => {
|
||||
const o = owner({ collapsed: false })
|
||||
o.setCollapsed(true)
|
||||
expect(o.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('reset replaces the whole model and reveals the panel', () => {
|
||||
const { adapter, persisted } = makeAdapter()
|
||||
const o = owner(
|
||||
{
|
||||
tabs: [
|
||||
{ id: 'a', url: '/x', loc: '/x' },
|
||||
{ id: 'b', url: '/y', loc: '/y' }
|
||||
],
|
||||
activeId: 'b',
|
||||
collapsed: true
|
||||
},
|
||||
adapter
|
||||
)
|
||||
o.reset([{ id: 'session', url: '/z', loc: '/z' }], 'session')
|
||||
expect(o.tabs.map((t) => t.id)).toEqual(['session'])
|
||||
expect(o.activeId).toBe('session')
|
||||
expect(o.collapsed).toBe(false)
|
||||
vi.runAllTimers()
|
||||
expect(persisted.at(-1)?.tabs.map((t) => t.url)).toEqual(['/z'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreviewTabs.observeLocation', () => {
|
||||
it('updates loc without touching url', () => {
|
||||
const o = owner({
|
||||
tabs: [{ id: 'a', url: '/scripts/edit/u/me/foo', loc: '/scripts/edit/u/me/foo' }],
|
||||
activeId: 'a'
|
||||
})
|
||||
o.observeLocation('a', '/scripts/edit/u/me/foo?tab=logs')
|
||||
expect(o.tabs[0].url).toBe('/scripts/edit/u/me/foo')
|
||||
expect(o.tabs[0].loc).toBe('/scripts/edit/u/me/foo?tab=logs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreviewTabs persistence', () => {
|
||||
it('debounces a write-behind of the full model after mutations', () => {
|
||||
const { adapter, persisted } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(scriptTarget)
|
||||
o.open(flowTarget)
|
||||
expect(persisted).toHaveLength(0) // nothing flushed synchronously
|
||||
vi.runAllTimers()
|
||||
expect(persisted).toHaveLength(1) // coalesced to one write
|
||||
expect(persisted[0].tabs).toHaveLength(2)
|
||||
expect(persisted[0].activeId).toBe(o.activeId)
|
||||
expect(persisted[0].collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('flushNow persists a pending write immediately and cancels the debounce', () => {
|
||||
const { adapter, persisted } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.open(scriptTarget)
|
||||
o.flushNow()
|
||||
expect(persisted).toHaveLength(1)
|
||||
vi.runAllTimers()
|
||||
expect(persisted).toHaveLength(1) // debounce cancelled, no second write
|
||||
})
|
||||
|
||||
it('flushNow is a no-op when nothing is pending', () => {
|
||||
const { adapter, persisted } = makeAdapter()
|
||||
const o = owner({}, adapter)
|
||||
o.flushNow()
|
||||
expect(persisted).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describePreview', () => {
|
||||
it('reports no tabs when there are none', () => {
|
||||
expect(describePreview([], '', undefined)).toContain('No preview tabs')
|
||||
})
|
||||
|
||||
it('lists tabs, marks the active one, and flags the live editor', () => {
|
||||
const tabs: SessionPreviewTab[] = [
|
||||
{ id: 'a', url: '/scripts/edit/u/me/foo', loc: '/scripts/edit/u/me/foo' }
|
||||
]
|
||||
const out = describePreview(tabs, 'a', { kind: 'script', path: 'u/me/foo' })
|
||||
expect(out).toContain('1 preview tab')
|
||||
expect(out).toContain('script "u/me/foo"')
|
||||
expect(out).toContain('live editor')
|
||||
expect(out).toContain('active')
|
||||
})
|
||||
|
||||
it('labels a known page and omits the live-editor flag when the target differs', () => {
|
||||
const tabs: SessionPreviewTab[] = [{ id: 'a', url: '/runs', loc: '/runs' }]
|
||||
const out = describePreview(tabs, 'a', { kind: 'script', path: 'u/me/foo' })
|
||||
expect(out).toContain('page "Runs"')
|
||||
expect(out).not.toContain('live editor')
|
||||
})
|
||||
})
|
||||
@@ -25,19 +25,28 @@ type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
|
||||
import type { HiddenRunnable } from '$lib/components/apps/types'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { loadCopilot, copilotWorkspace } from '$lib/aiStore'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import {
|
||||
commitSessionWorkspace,
|
||||
deleteSession as deleteSessionState,
|
||||
ensureChatIdsSeeded,
|
||||
getEffectiveWorkspaceId,
|
||||
materializeTransient,
|
||||
sessionState,
|
||||
setGeneratedSessionSummary,
|
||||
setSessionChatId,
|
||||
setSessionPreviewCollapsed,
|
||||
setSessionTabs,
|
||||
setSessionTarget,
|
||||
type Session,
|
||||
type SessionTarget
|
||||
type Session
|
||||
} from './sessionState.svelte'
|
||||
import {
|
||||
SessionPreviewTabs,
|
||||
describePreview,
|
||||
hydratePreviewTabs,
|
||||
previewTargetForSessionTarget
|
||||
} from './sessionPreviewTabs.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { armRestartOnFirstInteraction } from '$lib/userDraftToast'
|
||||
@@ -69,6 +78,10 @@ import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
|
||||
// (synchronous) target swap.
|
||||
export interface LoadSlot {
|
||||
loadedPath: string | undefined
|
||||
// The workspace `loadedPath`'s content was fetched from. A session can retarget
|
||||
// the same item path to a different fork, so the cache must key on both — else
|
||||
// the editor keeps the old workspace's content while save/deploy write to the new.
|
||||
loadedWorkspace: string | undefined
|
||||
loading: boolean
|
||||
notFound: boolean
|
||||
}
|
||||
@@ -78,6 +91,10 @@ export type SessionTargetKind = 'flow' | 'script' | 'raw_app'
|
||||
export interface SessionRuntime {
|
||||
readonly sessionId: string
|
||||
readonly manager: AIChatManager
|
||||
// Single live owner of this session's preview tabs. Both the sessions page
|
||||
// (renderer) and the open_preview/get_preview_status tools cross it, so the
|
||||
// tab model has exactly one live copy.
|
||||
readonly previewTabs: SessionPreviewTabs
|
||||
// Pipeline target state — persists across editor hide/show (the pane unmounts
|
||||
// on hide, so this can't be component-local) and across session switches.
|
||||
readonly pipelineEditorState: PipelineEditorState
|
||||
@@ -246,6 +263,13 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// Carried into the tool helpers so this session's preview/deploy tool calls
|
||||
// dispatch to THIS session even when another session is the UI-active one.
|
||||
manager.sessionId = session.id
|
||||
// The chat targets the session's OWN (possibly forked) workspace without
|
||||
// switching the global workspaceStore. Resolved live from the session record
|
||||
// so it tracks the pending → committed (and staged-fork) transitions.
|
||||
manager.workspaceResolver = () => {
|
||||
const s = sessionState.sessions.find((x) => x.id === session.id)
|
||||
return s ? getEffectiveWorkspaceId(s) : undefined
|
||||
}
|
||||
// Pre-flight: materialise the (still-transient) session, then commit
|
||||
// the workspace (creating a staged fork if needed) before any send.
|
||||
// AIChatManager awaits this so the first message hits a persisted
|
||||
@@ -267,6 +291,16 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
'the session workspace could not be created or committed (fork creation may have failed)'
|
||||
)
|
||||
}
|
||||
// The composer may have been enabled by a previous workspace's copilot
|
||||
// config (copilotInfo is global). getCurrentModel() reads it when the
|
||||
// request builds just after this hook, so load the committed workspace's
|
||||
// config first — otherwise a send right after switching workspaces could
|
||||
// pick the old provider/model while the proxy + tools target the new one.
|
||||
// SessionWrapper's active-session load usually already did this; skip the
|
||||
// fetch when it matches (a staged fork commits to a fresh id its load missed).
|
||||
if (get(copilotWorkspace) !== committed) {
|
||||
await loadCopilot(committed)
|
||||
}
|
||||
}
|
||||
manager.afterFirstTurnSaved = () => generateAndApplySessionSummary(session.id, manager)
|
||||
|
||||
@@ -276,15 +310,43 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
val: undefined
|
||||
})
|
||||
|
||||
const flowSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false })
|
||||
const flowSlot: LoadSlot = $state({
|
||||
loadedPath: undefined,
|
||||
loadedWorkspace: undefined,
|
||||
loading: false,
|
||||
notFound: false
|
||||
})
|
||||
|
||||
const scriptStore: { val: NewScript | undefined } = $state({ val: undefined })
|
||||
const savedScript: { val: SavedScript | undefined } = $state({ val: undefined })
|
||||
const scriptSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false })
|
||||
const scriptSlot: LoadSlot = $state({
|
||||
loadedPath: undefined,
|
||||
loadedWorkspace: undefined,
|
||||
loading: false,
|
||||
notFound: false
|
||||
})
|
||||
|
||||
const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined })
|
||||
const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined })
|
||||
const rawAppSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false })
|
||||
const rawAppSlot: LoadSlot = $state({
|
||||
loadedPath: undefined,
|
||||
loadedWorkspace: undefined,
|
||||
loading: false,
|
||||
notFound: false
|
||||
})
|
||||
|
||||
// Hydrate the preview-tab owner from the session record (the durable backing);
|
||||
// from here on the owner is the single live copy and writes back through the
|
||||
// adapter. setSessionTabs / setSessionPreviewCollapsed / setSessionTarget stay
|
||||
// the low-level record writers (a transient session's writes land in the
|
||||
// localStorage draft slot until it materialises).
|
||||
const previewTabs = new SessionPreviewTabs(hydratePreviewTabs(session), {
|
||||
persist: (snap) => {
|
||||
setSessionTabs(session.id, snap.tabs, snap.activeId)
|
||||
setSessionPreviewCollapsed(session.id, snap.collapsed)
|
||||
},
|
||||
setTarget: (target) => setSessionTarget(session.id, target)
|
||||
})
|
||||
|
||||
// Pipeline target state lives on the runtime (not the PipelineEditorView
|
||||
// component) so the in-session drafts survive hide/show of the editor pane —
|
||||
@@ -297,6 +359,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
manager,
|
||||
previewTabs,
|
||||
slot(kind: SessionTargetKind): LoadSlot {
|
||||
return kind === 'flow' ? flowSlot : kind === 'script' ? scriptSlot : rawAppSlot
|
||||
},
|
||||
@@ -306,9 +369,12 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
savedFlow,
|
||||
|
||||
async loadFlow(workspace: string, path: string, force = false) {
|
||||
if (flowSlot.loadedPath === path && !force) return
|
||||
// See loadScript: forced reload remounts via the render gate.
|
||||
if (force) flowSlot.loadedPath = undefined
|
||||
if (flowSlot.loadedPath === path && flowSlot.loadedWorkspace === workspace && !force) return
|
||||
// See loadScript: forced reload remounts via the render gate. A workspace
|
||||
// retarget (same path, new fork) drops the stale content the same way so
|
||||
// the editor gate shows loading and outbound sync can't write the old
|
||||
// workspace's content into the new one before the fetch lands.
|
||||
if (force || flowSlot.loadedWorkspace !== workspace) flowSlot.loadedPath = undefined
|
||||
flowSlot.loading = true
|
||||
flowSlot.notFound = false
|
||||
try {
|
||||
@@ -342,6 +408,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
if (deployedVersionId != null && flowStore.val)
|
||||
flowStore.val.version_id = deployedVersionId
|
||||
flowSlot.loadedPath = path
|
||||
flowSlot.loadedWorkspace = workspace
|
||||
return
|
||||
}
|
||||
|
||||
@@ -363,6 +430,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId
|
||||
flowSlot.loadedPath = path
|
||||
flowSlot.loadedWorkspace = workspace
|
||||
} catch (err) {
|
||||
console.error('Failed to load flow', err)
|
||||
flowSlot.notFound = true
|
||||
@@ -375,12 +443,13 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
savedScript,
|
||||
|
||||
async loadScript(workspace: string, path: string, force = false) {
|
||||
if (scriptSlot.loadedPath === path && !force) return
|
||||
if (scriptSlot.loadedPath === path && scriptSlot.loadedWorkspace === workspace && !force)
|
||||
return
|
||||
// Forced reload: clearing the slot's loadedPath drops us into
|
||||
// SessionEditorTarget's `{:else if slot.loadedPath === undefined}` gate,
|
||||
// which unmounts then remounts the editor — avoids the Monaco init race a
|
||||
// synchronous {#key} would hit.
|
||||
if (force) scriptSlot.loadedPath = undefined
|
||||
if (force || scriptSlot.loadedWorkspace !== workspace) scriptSlot.loadedPath = undefined
|
||||
scriptSlot.loading = true
|
||||
scriptSlot.notFound = false
|
||||
try {
|
||||
@@ -433,6 +502,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary
|
||||
scriptStore.val = baseline
|
||||
scriptSlot.loadedPath = path
|
||||
scriptSlot.loadedWorkspace = workspace
|
||||
return
|
||||
}
|
||||
|
||||
@@ -458,6 +528,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
UserDraft.save<NewScript>('script', path, baseline, { workspace })
|
||||
scriptStore.val = baseline
|
||||
scriptSlot.loadedPath = path
|
||||
scriptSlot.loadedWorkspace = workspace
|
||||
} catch (err) {
|
||||
console.error('Failed to load script', err)
|
||||
scriptSlot.notFound = true
|
||||
@@ -470,9 +541,10 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
savedRawApp,
|
||||
|
||||
async loadRawApp(workspace: string, path: string, force = false, deployedOnly = false) {
|
||||
if (rawAppSlot.loadedPath === path && !force) return
|
||||
if (rawAppSlot.loadedPath === path && rawAppSlot.loadedWorkspace === workspace && !force)
|
||||
return
|
||||
// See loadScript: forced reload remounts via the render gate.
|
||||
if (force) rawAppSlot.loadedPath = undefined
|
||||
if (force || rawAppSlot.loadedWorkspace !== workspace) rawAppSlot.loadedPath = undefined
|
||||
rawAppSlot.loading = true
|
||||
rawAppSlot.notFound = false
|
||||
try {
|
||||
@@ -521,6 +593,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
aiDraft
|
||||
)
|
||||
rawAppSlot.loadedPath = path
|
||||
rawAppSlot.loadedWorkspace = workspace
|
||||
return
|
||||
}
|
||||
|
||||
@@ -582,6 +655,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace })
|
||||
rawApp.val = runtimeValue
|
||||
rawAppSlot.loadedPath = path
|
||||
rawAppSlot.loadedWorkspace = workspace
|
||||
} catch (err) {
|
||||
console.error('Failed to load raw app', err)
|
||||
rawAppSlot.notFound = true
|
||||
@@ -674,6 +748,23 @@ export function getRuntime(sessionId: string): SessionRuntime | undefined {
|
||||
return runtimes.get(sessionId)
|
||||
}
|
||||
|
||||
// Point a session's preview at a single seed tab. For re-pointing an existing
|
||||
// draft session at a new destination ("Open in AI session" / new-session-from-
|
||||
// page on a reused transient): its previous tabs — persisted with the draft
|
||||
// and/or held by a live owner — would otherwise keep showing the old target.
|
||||
// Must write through the live owner when one exists; a bare record write would
|
||||
// be clobbered by the owner's next flush.
|
||||
export function resetSessionPreviewTabs(sessionId: string, url: string): void {
|
||||
const tabs = [{ id: 'session', url, loc: url }]
|
||||
const rt = runtimes.get(sessionId)
|
||||
if (rt) {
|
||||
rt.previewTabs.reset(tabs, 'session')
|
||||
} else {
|
||||
setSessionTabs(sessionId, tabs, 'session')
|
||||
setSessionPreviewCollapsed(sessionId, false)
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionChatStatus =
|
||||
| 'idle'
|
||||
| 'streaming'
|
||||
@@ -720,25 +811,31 @@ setOpenPreviewHandler(({ sessionId: callerSessionId, kind, path }) => {
|
||||
if (!sessionId) {
|
||||
return 'Error: no active session to open the preview in.'
|
||||
}
|
||||
const current = sessionState.sessions.find((s) => s.id === sessionId)?.target
|
||||
if (current && current.kind === kind && current.path === path) {
|
||||
return `Preview is already open showing ${kind} "${path}".`
|
||||
const session = sessionState.sessions.find((s) => s.id === sessionId)
|
||||
if (!session) {
|
||||
return 'Error: no active session to open the preview in.'
|
||||
}
|
||||
const target: SessionTarget = { kind, path }
|
||||
setSessionTarget(sessionId, target)
|
||||
const target = previewTargetForSessionTarget(kind, path)
|
||||
if (!target) {
|
||||
return `Error: ${kind} targets cannot be shown in the preview panel.`
|
||||
}
|
||||
const result = getOrCreateRuntime(session).previewTabs.open(target)
|
||||
promoteEditorWarm(sessionId)
|
||||
return `Opened ${kind} preview for ${path} in the side panel.`
|
||||
return result.status === 'focused'
|
||||
? `A preview tab is already showing ${kind} "${path}" — focused it.`
|
||||
: `Opened ${kind} preview for ${path} in a new tab in the side panel.`
|
||||
})
|
||||
|
||||
// Companion to the open_preview handler: report whether the calling session's
|
||||
// preview is open and which item it shows, so the assistant can avoid
|
||||
// re-opening a preview already showing the item it just edited.
|
||||
// Companion to the open_preview handler: report the calling session's open
|
||||
// preview tabs (the panel is multi-tab), so the assistant can avoid re-opening
|
||||
// a tab already showing the item it just edited.
|
||||
setGetPreviewStatusHandler((callerSessionId) => {
|
||||
const sessionId = callerSessionId ?? sessionState.currentSessionId
|
||||
if (!sessionId) return 'No active session; the preview panel is unavailable.'
|
||||
const target = sessionState.sessions.find((s) => s.id === sessionId)?.target
|
||||
if (!target) return 'No preview is currently open in the side panel.'
|
||||
return `The preview is currently open showing ${target.kind} "${target.path}".`
|
||||
const session = sessionState.sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return 'No active session; the preview panel is unavailable.'
|
||||
const owner = getOrCreateRuntime(session).previewTabs
|
||||
return describePreview(owner.tabs, owner.activeId, session.target)
|
||||
})
|
||||
|
||||
// After a chat deploy, reload the calling session's preview — only if it's open
|
||||
|
||||
@@ -35,7 +35,7 @@ export function syncWorkspaceTo(workspaceId: string | undefined): void {
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte'
|
||||
import { onUserChange } from '$lib/userScopedStorage'
|
||||
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
|
||||
|
||||
// Kinds the in-session editor pane can host. Legacy drag-and-drop apps are
|
||||
// intentionally not previewable — only code-based 'raw_app' apps are. A
|
||||
@@ -119,8 +119,20 @@ export type Session = {
|
||||
// current message count to derive the unread badge (see sessionUnread).
|
||||
// Travels on the record so it is scoped, migrated, and deleted with it.
|
||||
lastSeenCount?: number
|
||||
// Preview tabs the user opened in the sessions page, restored when the
|
||||
// session is reopened.
|
||||
previewTabs?: SessionPreviewTab[]
|
||||
// Which preview tab was active. Falls back to the first tab on restore.
|
||||
activePreviewTabId?: string
|
||||
// Whether the user collapsed the preview panel for this session (to give the
|
||||
// chat full width). Per-session so each session restores its own layout.
|
||||
previewCollapsed?: boolean
|
||||
}
|
||||
|
||||
// One preview tab: `url` is the URL we command the iframe to load, `loc` the
|
||||
// last observed location (see the sessions page for the url/loc split).
|
||||
export type SessionPreviewTab = { id: string; url: string; loc: string }
|
||||
|
||||
// Sessions live in one per-user IndexedDB, one record per session in the
|
||||
// `sessions` store keyed by `id`. IndexedDB is the sole store — no localStorage
|
||||
// fallback. The bare localStorage keys below are the oldest (pre-namespacing)
|
||||
@@ -128,6 +140,11 @@ export type Session = {
|
||||
const SESSIONS_DB = 'windmill-sessions'
|
||||
const LEGACY_SESSIONS_KEY = 'windmill_sessions'
|
||||
const LEGACY_LAST_SEEN_KEY = 'windmill_sessions_last_seen_counts'
|
||||
// The single unsent (transient) draft, kept in localStorage (user-scoped) so a
|
||||
// reload doesn't lose what the user set up before their first message: name,
|
||||
// workspace/fork choice, editor target, preview tabs and the typed-but-unsent
|
||||
// prompt.
|
||||
const TRANSIENT_DRAFT_KEY = 'wm_session_transient_draft'
|
||||
|
||||
interface SessionSchema extends DBSchema {
|
||||
sessions: { key: string; value: Session }
|
||||
@@ -207,6 +224,19 @@ function sessionRootId(s: Session): string | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
// Whether a session belongs to the active workspace's family. Only in-memory
|
||||
// drafts (transient) may lack a root and still count as in-family — they follow
|
||||
// the user until a workspace is picked. A persisted session with no workspace
|
||||
// binding must fail closed: counting it as in-family would surface it in every
|
||||
// family, including freshly created workspaces.
|
||||
export function sessionInCurrentFamily(s: Session): boolean {
|
||||
const currentRoot = workspaceRootId(get(workspaceStore) ?? undefined, get(userWorkspaces))
|
||||
if (!currentRoot) return true
|
||||
const root = sessionRootId(s)
|
||||
if (root === undefined) return !!s.transient
|
||||
return root === currentRoot
|
||||
}
|
||||
|
||||
function ensureSessionRootId(s: Session): boolean {
|
||||
if (s.workspace_root_id || s.transient) return false
|
||||
const workspaceId = s.workspace_id ?? s.pending_workspace_id
|
||||
@@ -256,18 +286,83 @@ const sessionsDb = userScopedDb<SessionSchema>(SESSIONS_DB, {
|
||||
export const sessionState = $state<{
|
||||
sessions: Session[]
|
||||
currentSessionId: string | undefined
|
||||
// False until the first IndexedDB hydration completes. Consumers gate their
|
||||
// "not found" / empty states on it — before that, an empty list only means
|
||||
// "still loading", not "the user has no sessions".
|
||||
hydrated: boolean
|
||||
}>({
|
||||
sessions: [],
|
||||
currentSessionId: undefined
|
||||
currentSessionId: undefined,
|
||||
hydrated: false
|
||||
})
|
||||
|
||||
// Write-behind a single session record. Transient (unsent) sessions stay in
|
||||
// memory only — they materialise via materializeTransient() at first send.
|
||||
type TransientDraft = Session & {
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
// The unsent prompt for the current transient session, held here so every
|
||||
// draft write (which snapshots only the Session record) can carry it along.
|
||||
let transientPrompt: { sessionId: string; text: string } | undefined
|
||||
|
||||
function writeTransientDraft(s: Session): void {
|
||||
const key = scopedKey(TRANSIENT_DRAFT_KEY)
|
||||
if (!key) return
|
||||
const draft: TransientDraft = {
|
||||
...($state.snapshot(s) as Session),
|
||||
prompt: transientPrompt?.sessionId === s.id ? transientPrompt.text : undefined
|
||||
}
|
||||
storeLocalSetting(key, JSON.stringify(draft))
|
||||
}
|
||||
|
||||
function readTransientDraft(): TransientDraft | undefined {
|
||||
const key = scopedKey(TRANSIENT_DRAFT_KEY)
|
||||
if (!key) return undefined
|
||||
const raw = getLocalSetting(key)
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
const d = JSON.parse(raw)
|
||||
if (!d || typeof d.id !== 'string' || typeof d.name !== 'string') return undefined
|
||||
return d as TransientDraft
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function clearTransientDraft(): void {
|
||||
const key = scopedKey(TRANSIENT_DRAFT_KEY)
|
||||
if (key) storeLocalSetting(key, undefined)
|
||||
transientPrompt = undefined
|
||||
}
|
||||
|
||||
// Debounced write-behind of the chat input for a transient session, so the
|
||||
// typed-but-unsent prompt survives a reload with the rest of the draft.
|
||||
let transientPromptFlushHandle: ReturnType<typeof setTimeout> | undefined
|
||||
export function queueTransientDraftPrompt(sessionId: string, text: string): void {
|
||||
const s = sessionState.sessions.find((x) => x.id === sessionId)
|
||||
if (!s?.transient) return
|
||||
transientPrompt = { sessionId, text }
|
||||
clearTimeout(transientPromptFlushHandle)
|
||||
transientPromptFlushHandle = setTimeout(() => writeTransientDraft(s), 400)
|
||||
}
|
||||
|
||||
// Read back the restored draft prompt when the session's runtime (and its chat
|
||||
// manager) is created. Peek, not take: later draft writes keep carrying it.
|
||||
export function peekTransientDraftPrompt(sessionId: string): string | undefined {
|
||||
return transientPrompt?.sessionId === sessionId ? transientPrompt.text : undefined
|
||||
}
|
||||
|
||||
// Write-behind a single session record. Transient (unsent) sessions are not
|
||||
// written to IndexedDB — they live in memory plus a single localStorage draft
|
||||
// slot until materializeTransient() promotes them at first send.
|
||||
// Awaits DB-open so a write racing hydration still lands; no-ops (degrades to
|
||||
// in-memory) when the DB can't be opened. In-memory $state is the read surface,
|
||||
// so callers fire-and-forget.
|
||||
export async function putSession(s: Session): Promise<void> {
|
||||
if (!BROWSER || s.transient) return
|
||||
if (!BROWSER) return
|
||||
if (s.transient) {
|
||||
writeTransientDraft(s)
|
||||
return
|
||||
}
|
||||
// Never resurrect a session whose committed workspace is gone. A live runtime
|
||||
// can still write through here after reconciliation deletes its record (chatId
|
||||
// seed, unread watermark), so guard once the workspace list is loaded.
|
||||
@@ -317,6 +412,19 @@ async function hydrateSessions({ dropTransients = false } = {}): Promise<void> {
|
||||
const changed = all.filter((s) => ensureSessionRootId(s))
|
||||
for (const s of changed) await db.put('sessions', s)
|
||||
all.sort((a, b) => b.createdAt - a.createdAt)
|
||||
// Restore the (user-scoped) unsent draft, unless it already materialised
|
||||
// (present in the DB — e.g. sent from another browser tab) or the same
|
||||
// draft is still live in memory.
|
||||
const draft = readTransientDraft()
|
||||
if (draft) {
|
||||
if (all.some((s) => s.id === draft.id)) {
|
||||
clearTransientDraft()
|
||||
} else if (!transients.some((s) => s.id === draft.id)) {
|
||||
const { prompt, ...rec } = draft
|
||||
transients.push({ ...rec, transient: true })
|
||||
if (prompt) transientPrompt = { sessionId: rec.id, text: prompt }
|
||||
}
|
||||
}
|
||||
sessionState.sessions = [...transients, ...all]
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions from IndexedDB', e)
|
||||
@@ -390,23 +498,30 @@ export async function reconcileSessionsLifecycle(): Promise<void> {
|
||||
}
|
||||
|
||||
const deletedIds = new Set<string>()
|
||||
for (const s of sessions) {
|
||||
if (!s.workspace_id) continue
|
||||
const { action, patch } = decideSessionLifecycle(s, status[s.workspace_id])
|
||||
if (action === 'delete') {
|
||||
await db.delete('sessions', s.id)
|
||||
// GC linked files too, matching deleteSession — a record-only delete
|
||||
// here would orphan the session's attached-file blobs/handles.
|
||||
void deleteItemsForSession(s.id)
|
||||
deletedIds.add(s.id)
|
||||
continue
|
||||
try {
|
||||
for (const s of sessions) {
|
||||
if (!s.workspace_id) continue
|
||||
const { action, patch } = decideSessionLifecycle(s, status[s.workspace_id])
|
||||
if (action === 'delete') {
|
||||
await db.delete('sessions', s.id)
|
||||
// GC linked files too, matching deleteSession — a record-only delete
|
||||
// here would orphan the session's attached-file blobs/handles.
|
||||
void deleteItemsForSession(s.id)
|
||||
deletedIds.add(s.id)
|
||||
continue
|
||||
}
|
||||
let changed = action === 'archive' || action === 'unarchive'
|
||||
if (changed && patch) applyLifecyclePatch(s, patch)
|
||||
// Re-root surviving sessions whose family topmost member shifted (an
|
||||
// ancestor was deleted); fall back to backfilling a missing root.
|
||||
if (refreshSessionRootId(s) || ensureSessionRootId(s)) changed = true
|
||||
if (changed) await db.put('sessions', s)
|
||||
}
|
||||
let changed = action === 'archive' || action === 'unarchive'
|
||||
if (changed && patch) applyLifecyclePatch(s, patch)
|
||||
// Re-root surviving sessions whose family topmost member shifted (an
|
||||
// ancestor was deleted); fall back to backfilling a missing root.
|
||||
if (refreshSessionRootId(s) || ensureSessionRootId(s)) changed = true
|
||||
if (changed) await db.put('sessions', s)
|
||||
} catch (e) {
|
||||
// The connection can go stale mid-loop (user switch reopens the per-user
|
||||
// DB); this pass is best-effort — the next reconcile trigger retries.
|
||||
console.error('Failed to reconcile session lifecycle', e)
|
||||
return
|
||||
}
|
||||
await hydrateSessions()
|
||||
// If the session the user was on lived in a now-deleted workspace, it was just
|
||||
@@ -495,6 +610,9 @@ export async function deleteSessionsForWorkspace(workspaceId: string): Promise<v
|
||||
onUserChange(async (email, prevEmail) => {
|
||||
if (!BROWSER) return
|
||||
await hydrateSessions({ dropTransients: prevEmail !== email })
|
||||
// onUserChange also fires at registration time, before the email resolves —
|
||||
// that hydration is an empty no-op and must not clear the loading state.
|
||||
if (email !== undefined) sessionState.hydrated = true
|
||||
if (prevEmail !== undefined && prevEmail !== email) {
|
||||
sessionState.currentSessionId = undefined
|
||||
}
|
||||
@@ -525,11 +643,19 @@ export function findSessionByName(name: string): Session | undefined {
|
||||
export function createSession(): Session {
|
||||
// Reuse the existing transient session (if any) so the user can hit
|
||||
// the "+" button repeatedly without piling drafts. The transient
|
||||
// becomes a real session at first-message-send time.
|
||||
// becomes a real session at first-message-send time. Only a transient
|
||||
// from the active workspace family qualifies — reusing one left over
|
||||
// from another family would hand the user a session still acting on
|
||||
// that family. A cross-family leftover is dropped instead (it was
|
||||
// never sent, so only the draft slot holds it).
|
||||
const existingTransient = sessionState.sessions.find((s) => s.transient)
|
||||
if (existingTransient) {
|
||||
sessionState.currentSessionId = existingTransient.id
|
||||
return existingTransient
|
||||
if (sessionInCurrentFamily(existingTransient)) {
|
||||
sessionState.currentSessionId = existingTransient.id
|
||||
return existingTransient
|
||||
}
|
||||
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== existingTransient.id)
|
||||
clearTransientDraft()
|
||||
}
|
||||
const existingNumbers = sessionState.sessions
|
||||
.map((s) => /^session-(\d+)$/.exec(s.name)?.[1])
|
||||
@@ -569,8 +695,9 @@ export function createSession(): Session {
|
||||
}
|
||||
sessionState.sessions = [session, ...sessionState.sessions]
|
||||
sessionState.currentSessionId = session.id
|
||||
// The new session is transient (in-memory only) until first send, so there
|
||||
// is nothing to write to the DB yet.
|
||||
// Transient until first send: no DB record yet, but the draft slot keeps it
|
||||
// (name, workspace/fork choice, prompt) across reloads.
|
||||
writeTransientDraft(session)
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -583,6 +710,8 @@ export function materializeTransient(id: string): void {
|
||||
if (!s || !s.transient) return
|
||||
delete s.transient
|
||||
void putSession(s)
|
||||
// Promoted to IndexedDB — the localStorage draft slot is now stale.
|
||||
clearTransientDraft()
|
||||
}
|
||||
|
||||
export function setSessionPendingWorkspace(id: string, workspace_id: string) {
|
||||
@@ -650,7 +779,9 @@ export async function commitSessionWorkspace(
|
||||
s.pending_workspace_id = undefined
|
||||
s.workspace_root_id = workspaceRootId(newId, get(userWorkspaces)) ?? newId
|
||||
await putSession(s)
|
||||
if (get(workspaceStore) !== newId) switchWorkspace(newId)
|
||||
// The global workspaceStore is intentionally left untouched: the session
|
||||
// chat targets its own workspace via AIChatManager.operatingWorkspace, so
|
||||
// committing must not yank the user's active (navigation-mode) workspace.
|
||||
return newId
|
||||
}
|
||||
|
||||
@@ -660,13 +791,9 @@ export async function commitSessionWorkspace(
|
||||
s.pending_workspace_id = undefined
|
||||
s.workspace_root_id = workspaceRootId(ws, get(userWorkspaces)) ?? ws
|
||||
await putSession(s)
|
||||
// `pending_workspace_id` defaults to the root workspace when created from
|
||||
// inside a fork, so the committed workspace can differ from the active
|
||||
// workspaceStore. Without this sync, the very first AI request's
|
||||
// `logAiChat` and tool calls read the stale fork from workspaceStore
|
||||
// while the session metadata says root. Mirrors the `switchWorkspace`
|
||||
// in the pending_fork branch above.
|
||||
if (get(workspaceStore) !== ws) syncWorkspaceTo(ws)
|
||||
// The global workspaceStore is intentionally left untouched (see the fork
|
||||
// branch above): the session chat reads its committed workspace through the
|
||||
// manager's workspace resolver, not the active workspaceStore.
|
||||
return ws
|
||||
}
|
||||
|
||||
@@ -691,6 +818,25 @@ export function setSessionTarget(id: string, target: SessionTarget, summary?: st
|
||||
void putSession(s)
|
||||
}
|
||||
|
||||
// Persist the session's preview tabs. Fire-and-forget write-behind (transient
|
||||
// sessions land in the localStorage draft slot).
|
||||
export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabId: string): void {
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (!s) return
|
||||
s.previewTabs = tabs.map((t) => ({ ...t }))
|
||||
s.activePreviewTabId = activeTabId
|
||||
void putSession(s)
|
||||
}
|
||||
|
||||
// Persist whether the preview panel is collapsed for this session. Fire-and-forget
|
||||
// write-behind (transient sessions land in the localStorage draft slot).
|
||||
export function setSessionPreviewCollapsed(id: string, collapsed: boolean): void {
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (!s || !!s.previewCollapsed === collapsed) return
|
||||
s.previewCollapsed = collapsed
|
||||
void putSession(s)
|
||||
}
|
||||
|
||||
export function selectSession(id: string) {
|
||||
sessionState.currentSessionId = id
|
||||
}
|
||||
@@ -813,6 +959,7 @@ export function setSessionArchived(id: string, archived: boolean) {
|
||||
export function deleteSession(id: string) {
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (!s) return
|
||||
if (s.transient) clearTransientDraft()
|
||||
sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id)
|
||||
if (sessionState.currentSessionId === id) {
|
||||
sessionState.currentSessionId = sessionState.sessions[0]?.id
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { get } from 'svelte/store'
|
||||
import {
|
||||
commitSessionWorkspace,
|
||||
createSession,
|
||||
decideSessionLifecycle,
|
||||
isForkSession,
|
||||
renameSession,
|
||||
sessionInCurrentFamily,
|
||||
setGeneratedSessionSummary,
|
||||
sessionState,
|
||||
type Session
|
||||
@@ -168,14 +170,12 @@ describe('commitSessionWorkspace — CE workspace-cap fork guard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitSessionWorkspace — workspaceStore sync (non-fork branch)', () => {
|
||||
it('syncs workspaceStore to the committed workspace when they differ', async () => {
|
||||
// Repro: user is sitting in a fork workspace (wm-fork-x) and creates a
|
||||
// new session whose pending_workspace_id defaults to the root workspace.
|
||||
// Without the syncWorkspaceTo call in commitSessionWorkspace's non-fork
|
||||
// branch, the session metadata says root while the active workspace
|
||||
// stays on the fork — so AIChatManager.chatRequest's logAiChat and tool
|
||||
// calls would target the wrong workspace.
|
||||
describe('commitSessionWorkspace — leaves workspaceStore untouched', () => {
|
||||
it('commits the session workspace without switching the active workspaceStore', async () => {
|
||||
// A session chat targets its committed workspace through the manager's
|
||||
// workspace resolver (AIChatManager.operatingWorkspace), so committing must
|
||||
// NOT yank the user's active (navigation-mode) workspace — even when the
|
||||
// committed workspace and the active one differ.
|
||||
const id = 'test-commit-ws-sync'
|
||||
const prev = get(workspaceStore)
|
||||
workspaceStore.set('wm-fork-x')
|
||||
@@ -192,30 +192,8 @@ describe('commitSessionWorkspace — workspaceStore sync (non-fork branch)', ()
|
||||
expect(s?.workspace_id).toBe('root_ws')
|
||||
expect(s?.workspace_root_id).toBe('root_ws')
|
||||
expect(s?.pending_workspace_id).toBeUndefined()
|
||||
expect(get(workspaceStore)).toBe('root_ws')
|
||||
} finally {
|
||||
const i = sessionState.sessions.findIndex((x) => x.id === id)
|
||||
if (i >= 0) sessionState.sessions.splice(i, 1)
|
||||
workspaceStore.set(prev)
|
||||
}
|
||||
})
|
||||
|
||||
it('is a no-op on workspaceStore when it already matches the committed workspace', async () => {
|
||||
const id = 'test-commit-ws-match'
|
||||
const prev = get(workspaceStore)
|
||||
workspaceStore.set('root_ws')
|
||||
sessionState.sessions.push({
|
||||
id,
|
||||
name: 'ws-match',
|
||||
createdAt: 0,
|
||||
pending_workspace_id: 'root_ws'
|
||||
} as Session)
|
||||
try {
|
||||
const committed = await commitSessionWorkspace(id, undefined)
|
||||
expect(committed).toBe('root_ws')
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
expect(s?.workspace_root_id).toBe('root_ws')
|
||||
expect(get(workspaceStore)).toBe('root_ws')
|
||||
// The active workspace is intentionally left where the user put it.
|
||||
expect(get(workspaceStore)).toBe('wm-fork-x')
|
||||
} finally {
|
||||
const i = sessionState.sessions.findIndex((x) => x.id === id)
|
||||
if (i >= 0) sessionState.sessions.splice(i, 1)
|
||||
@@ -308,3 +286,103 @@ describe('decideSessionLifecycle — the never-orphaned rule (pure)', () => {
|
||||
expect(decideSessionLifecycle(mk(), undefined)).toEqual({ action: 'noop' })
|
||||
})
|
||||
})
|
||||
|
||||
// Two-family fixture: rootA (with forkA) and rootB. Returns a restore fn.
|
||||
function withTwoFamilies(activeWorkspace: string): () => void {
|
||||
const prevUsers = get(usersWorkspaceStore)
|
||||
const prevWs = get(workspaceStore)
|
||||
usersWorkspaceStore.set({
|
||||
email: 't@t',
|
||||
workspaces: [ws('rootA'), ws('forkA', 'rootA'), ws('rootB')]
|
||||
} as never)
|
||||
workspaceStore.set(activeWorkspace)
|
||||
return () => {
|
||||
usersWorkspaceStore.set(prevUsers)
|
||||
workspaceStore.set(prevWs)
|
||||
}
|
||||
}
|
||||
|
||||
describe('sessionInCurrentFamily', () => {
|
||||
it('matches sessions bound anywhere in the active family, rejects other families', () => {
|
||||
const restore = withTwoFamilies('forkA')
|
||||
try {
|
||||
expect(sessionInCurrentFamily(session({ workspace_id: 'rootA' }))).toBe(true)
|
||||
expect(sessionInCurrentFamily(session({ pending_workspace_id: 'forkA' }))).toBe(true)
|
||||
expect(sessionInCurrentFamily(session({ workspace_id: 'rootB' }))).toBe(false)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails open only for unbound transient drafts, closed for unbound persisted sessions', () => {
|
||||
const restore = withTwoFamilies('rootA')
|
||||
try {
|
||||
// In-memory draft with no workspace picked yet — follows the user.
|
||||
expect(sessionInCurrentFamily(session({ transient: true }))).toBe(true)
|
||||
// Persisted session with no workspace binding (legacy data) must not
|
||||
// surface in every family.
|
||||
expect(sessionInCurrentFamily(session())).toBe(false)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails open when there is no active root', () => {
|
||||
const restore = withTwoFamilies('rootA')
|
||||
try {
|
||||
workspaceStore.set(undefined)
|
||||
expect(sessionInCurrentFamily(session({ workspace_id: 'rootB' }))).toBe(true)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSession — transient reuse is family-scoped', () => {
|
||||
it('reuses a transient from the active family', () => {
|
||||
const restore = withTwoFamilies('rootA')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
const transient = session({
|
||||
id: 'transient-same-family',
|
||||
name: 'session-901',
|
||||
pending_workspace_id: 'forkA',
|
||||
transient: true
|
||||
})
|
||||
sessionState.sessions.push(transient)
|
||||
try {
|
||||
const created = createSession()
|
||||
expect(created.id).toBe('transient-same-family')
|
||||
expect(sessionState.currentSessionId).toBe('transient-same-family')
|
||||
} finally {
|
||||
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'transient-same-family')
|
||||
sessionState.currentSessionId = prevCurrent
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a transient left over from another family and starts in the active workspace', () => {
|
||||
const restore = withTwoFamilies('rootB')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
const stale = session({
|
||||
id: 'transient-other-family',
|
||||
name: 'session-902',
|
||||
pending_workspace_id: 'forkA',
|
||||
transient: true
|
||||
})
|
||||
sessionState.sessions.push(stale)
|
||||
let createdId: string | undefined
|
||||
try {
|
||||
const created = createSession()
|
||||
createdId = created.id
|
||||
expect(created.id).not.toBe('transient-other-family')
|
||||
expect(created.pending_workspace_id).toBe('rootB')
|
||||
expect(sessionState.sessions.some((s) => s.id === 'transient-other-family')).toBe(false)
|
||||
} finally {
|
||||
sessionState.sessions = sessionState.sessions.filter(
|
||||
(s) => s.id !== 'transient-other-family' && s.id !== createdId
|
||||
)
|
||||
sessionState.currentSessionId = prevCurrent
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,9 +33,13 @@ import { WorkspaceService } from '$lib/gen'
|
||||
import {
|
||||
sessionState,
|
||||
putSession,
|
||||
deleteSession,
|
||||
deleteSessionRecord,
|
||||
archiveSessionsForWorkspace,
|
||||
deleteSessionsForWorkspace,
|
||||
materializeTransient,
|
||||
peekTransientDraftPrompt,
|
||||
queueTransientDraftPrompt,
|
||||
reconcileSessionsLifecycle,
|
||||
setSessionArchived,
|
||||
type Session
|
||||
@@ -89,12 +93,98 @@ describe('sessionState IndexedDB persistence', () => {
|
||||
await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s2', 's1']))
|
||||
})
|
||||
|
||||
it('does not persist transient sessions', async () => {
|
||||
it('keeps a transient session as a user-scoped localStorage draft, not in IndexedDB', async () => {
|
||||
const user = freshUser()
|
||||
userStore.set(user)
|
||||
await flush()
|
||||
|
||||
await putSession(session({ id: 't1', transient: true }))
|
||||
// Same user reload: the draft is restored, still transient (i.e. it came
|
||||
// from the localStorage slot — an IndexedDB record would have the flag
|
||||
// stripped by materialisation).
|
||||
await rehydrate(user)
|
||||
await flush()
|
||||
expect(sessionState.sessions.map((s) => ({ id: s.id, transient: s.transient }))).toEqual([
|
||||
{ id: 't1', transient: true }
|
||||
])
|
||||
|
||||
// The slot is user-scoped: another user sees nothing.
|
||||
await rehydrate(freshUser())
|
||||
await flush()
|
||||
expect(sessionState.sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trips a transient session preview state through the draft slot', async () => {
|
||||
const user = freshUser()
|
||||
userStore.set(user)
|
||||
await flush()
|
||||
|
||||
await putSession(
|
||||
session({
|
||||
id: 't1b',
|
||||
transient: true,
|
||||
target: { kind: 'script', path: 'u/me/foo' },
|
||||
previewTabs: [{ id: 'session', url: '/x', loc: '/x' }],
|
||||
activePreviewTabId: 'session',
|
||||
previewCollapsed: false
|
||||
})
|
||||
)
|
||||
await rehydrate(user)
|
||||
await flush()
|
||||
const restored = sessionState.sessions.find((s) => s.id === 't1b')
|
||||
expect(restored?.target).toEqual({ kind: 'script', path: 'u/me/foo' })
|
||||
expect(restored?.previewTabs).toEqual([{ id: 'session', url: '/x', loc: '/x' }])
|
||||
expect(restored?.activePreviewTabId).toBe('session')
|
||||
expect(restored?.previewCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('materializeTransient promotes the draft to IndexedDB and clears the slot', async () => {
|
||||
const user = freshUser()
|
||||
userStore.set(user)
|
||||
await flush()
|
||||
|
||||
const s = session({ id: 't2', transient: true })
|
||||
sessionState.sessions = [s]
|
||||
await putSession(s)
|
||||
expect(localStorage.getItem(`wm_session_transient_draft::${user.email}`)).not.toBeNull()
|
||||
|
||||
materializeTransient('t2')
|
||||
await flush()
|
||||
expect(localStorage.getItem(`wm_session_transient_draft::${user.email}`)).toBeNull()
|
||||
|
||||
await rehydrate(user)
|
||||
await vi.waitFor(() => expect(sessionState.sessions.map((x) => x.id)).toEqual(['t2']))
|
||||
expect(sessionState.sessions[0].transient).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips the unsent prompt through the draft slot', async () => {
|
||||
const user = freshUser()
|
||||
userStore.set(user)
|
||||
await flush()
|
||||
|
||||
const s = session({ id: 't3', transient: true })
|
||||
sessionState.sessions = [s]
|
||||
await putSession(s)
|
||||
queueTransientDraftPrompt('t3', 'draft prompt')
|
||||
// The prompt write-behind debounces 400ms.
|
||||
await new Promise((r) => setTimeout(r, 450))
|
||||
|
||||
await rehydrate(user)
|
||||
await flush()
|
||||
expect(sessionState.sessions.map((x) => x.id)).toEqual(['t3'])
|
||||
expect(peekTransientDraftPrompt('t3')).toBe('draft prompt')
|
||||
})
|
||||
|
||||
it('deleteSession discards the transient draft', async () => {
|
||||
const user = freshUser()
|
||||
userStore.set(user)
|
||||
await flush()
|
||||
|
||||
const s = session({ id: 't4', transient: true })
|
||||
sessionState.sessions = [s]
|
||||
await putSession(s)
|
||||
deleteSession('t4')
|
||||
|
||||
await rehydrate(user)
|
||||
await flush()
|
||||
expect(sessionState.sessions).toEqual([])
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
createSession,
|
||||
selectSession,
|
||||
sessionInCurrentFamily,
|
||||
sessionState,
|
||||
setSessionPendingWorkspace,
|
||||
setSessionTarget,
|
||||
type SessionTarget
|
||||
} from './sessionState.svelte'
|
||||
import { sessionTargetHref } from './sessionMode.svelte'
|
||||
|
||||
// The session/navigation switch turns the global rail into either the workspace
|
||||
// navigation (navigation mode) or the sessions sidebar (session mode). Session
|
||||
// mode is route-derived — it is exactly "on the /sessions page" — so the switch
|
||||
// is just navigation in and out of that route.
|
||||
|
||||
// The last non-session route the user was on, so exiting session mode returns
|
||||
// them where they were instead of dumping them on the home page. Stored as a
|
||||
// base-prefixed pathname (+ search); `goto` is base-idempotent so re-prefixing
|
||||
// is safe. Plain module state: it is read only inside click handlers, never
|
||||
// rendered, so it needs no reactivity.
|
||||
let lastNavRoute = '/'
|
||||
|
||||
export function rememberNavRoute(pathnameWithSearch: string): void {
|
||||
lastNavRoute = pathnameWithSearch
|
||||
}
|
||||
|
||||
// Enter session mode: open the active session if one is selected, else the most
|
||||
// recent non-archived session, else spin up a fresh one — then route to it.
|
||||
// Restore candidates are scoped to the active workspace family: reviving a
|
||||
// session from another family would pull that family's scope (sidebar list,
|
||||
// "Acting on" workspace) into the one the user is actually in.
|
||||
// `replace` swaps the current history entry instead of pushing — for the
|
||||
// sessions page's family reconcile, where Back must not return to the
|
||||
// redirected-away URL just to bounce here again.
|
||||
export async function enterSessionMode(opts?: { replace?: boolean }): Promise<void> {
|
||||
const current = sessionState.currentSessionId
|
||||
? sessionState.sessions.find((s) => s.id === sessionState.currentSessionId)
|
||||
: undefined
|
||||
const target =
|
||||
(current && sessionInCurrentFamily(current) ? current : undefined) ??
|
||||
sessionState.sessions.find((s) => !s.archived && sessionInCurrentFamily(s)) ??
|
||||
createSession()
|
||||
selectSession(target.id)
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(target.name)}`, {
|
||||
replaceState: opts?.replace ?? false
|
||||
})
|
||||
}
|
||||
|
||||
// Exit session mode: back to the last navigation route (home as a fallback).
|
||||
export async function exitSessionMode(): Promise<void> {
|
||||
let target = lastNavRoute || '/'
|
||||
// The remembered route can carry a stale `?workspace=` — the layout's
|
||||
// onQueryChange re-applies that param, so restoring the route would
|
||||
// silently check the app back out to a workspace the user has since
|
||||
// left. The rest of the route is scoped to that stale workspace too,
|
||||
// so fall back to home rather than rewriting the param.
|
||||
const wsParam = /[?&]workspace=([^&]*)/.exec(target)?.[1]
|
||||
const ws = get(workspaceStore)
|
||||
if (ws && wsParam && decodeURIComponent(wsParam) !== ws) {
|
||||
target = '/'
|
||||
}
|
||||
await goto(target)
|
||||
}
|
||||
|
||||
// Open a fresh AI session pre-targeted at an editor (flow/script/raw_app), then
|
||||
// route into session mode. The session preview loads that editor via its target,
|
||||
// so the caller MUST persist any unsaved edits first (e.g. save a draft) for the
|
||||
// preview to reflect the live state. `workspaceId` scopes the session to the
|
||||
// editor's workspace (instead of createSession's root default) so it opens the
|
||||
// same flow/script the user was editing.
|
||||
export async function openEditorInSession(
|
||||
target: SessionTarget,
|
||||
workspaceId?: string
|
||||
): Promise<void> {
|
||||
// createSession() reuses an existing transient draft, which may still be
|
||||
// pointed at a *different* item — and its preview tabs (persisted with the
|
||||
// draft and/or held by a live runtime) keep showing that old target unless
|
||||
// the tab model is reset along with the target field.
|
||||
const session = createSession()
|
||||
const retargeted = session.target?.kind !== target.kind || session.target?.path !== target.path
|
||||
if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId)
|
||||
setSessionTarget(session.id, target)
|
||||
const url = sessionTargetHref(target)
|
||||
if (url && retargeted) {
|
||||
// Dynamic import: a static one would drag the runtime's heavy graph
|
||||
// (chat manager → monaco) into this thin navigation seam, breaking its
|
||||
// node-run unit tests.
|
||||
const { resetSessionPreviewTabs } = await import('./sessionRuntime.svelte')
|
||||
resetSessionPreviewTabs(session.id, url)
|
||||
}
|
||||
selectSession(session.id)
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { get } from 'svelte/store'
|
||||
import { enterSessionMode } from './sessionSwitch.svelte'
|
||||
import { sessionState, type Session } from './sessionState.svelte'
|
||||
import { usersWorkspaceStore, workspaceStore, type UserWorkspace } from '$lib/stores'
|
||||
|
||||
vi.mock('$lib/navigation', () => ({ goto: vi.fn().mockResolvedValue(undefined) }))
|
||||
import { goto } from '$lib/navigation'
|
||||
|
||||
function session(over: Partial<Session> = {}): Session {
|
||||
return { id: 's1', name: 'sess', createdAt: 0, ...over }
|
||||
}
|
||||
function ws(id: string, parent?: string): UserWorkspace {
|
||||
return { id, name: id, parent_workspace_id: parent } as unknown as UserWorkspace
|
||||
}
|
||||
|
||||
// Two-family fixture: rootA (with forkA) and rootB. Returns a restore fn.
|
||||
function withTwoFamilies(activeWorkspace: string): () => void {
|
||||
const prevUsers = get(usersWorkspaceStore)
|
||||
const prevWs = get(workspaceStore)
|
||||
usersWorkspaceStore.set({
|
||||
email: 't@t',
|
||||
workspaces: [ws('rootA'), ws('forkA', 'rootA'), ws('rootB')]
|
||||
} as never)
|
||||
workspaceStore.set(activeWorkspace)
|
||||
return () => {
|
||||
usersWorkspaceStore.set(prevUsers)
|
||||
workspaceStore.set(prevWs)
|
||||
}
|
||||
}
|
||||
|
||||
describe('enterSessionMode — restore is scoped to the active family', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(goto).mockClear()
|
||||
})
|
||||
|
||||
it('keeps the current session when it belongs to the active family', async () => {
|
||||
const restore = withTwoFamilies('forkA')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
const inFamily = session({ id: 'sw-in-family', name: 'session-911', workspace_id: 'rootA' })
|
||||
sessionState.sessions.push(inFamily)
|
||||
sessionState.currentSessionId = 'sw-in-family'
|
||||
try {
|
||||
await enterSessionMode()
|
||||
expect(sessionState.currentSessionId).toBe('sw-in-family')
|
||||
expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-911', { replaceState: false })
|
||||
} finally {
|
||||
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'sw-in-family')
|
||||
sessionState.currentSessionId = prevCurrent
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips a cross-family current session in favor of the most recent in-family one', async () => {
|
||||
const restore = withTwoFamilies('rootB')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
const foreign = session({ id: 'sw-foreign', name: 'session-912', workspace_id: 'rootA' })
|
||||
const local = session({ id: 'sw-local', name: 'session-913', workspace_id: 'rootB' })
|
||||
sessionState.sessions.push(foreign, local)
|
||||
sessionState.currentSessionId = 'sw-foreign'
|
||||
try {
|
||||
await enterSessionMode()
|
||||
expect(sessionState.currentSessionId).toBe('sw-local')
|
||||
expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-913', { replaceState: false })
|
||||
} finally {
|
||||
sessionState.sessions = sessionState.sessions.filter(
|
||||
(s) => s.id !== 'sw-foreign' && s.id !== 'sw-local'
|
||||
)
|
||||
sessionState.currentSessionId = prevCurrent
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a fresh session in the active workspace when no in-family session exists', async () => {
|
||||
const restore = withTwoFamilies('rootB')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
const foreign = session({ id: 'sw-only-foreign', name: 'session-914', workspace_id: 'rootA' })
|
||||
sessionState.sessions.push(foreign)
|
||||
sessionState.currentSessionId = 'sw-only-foreign'
|
||||
let createdId: string | undefined
|
||||
try {
|
||||
await enterSessionMode()
|
||||
createdId = sessionState.currentSessionId
|
||||
expect(createdId).not.toBe('sw-only-foreign')
|
||||
const created = sessionState.sessions.find((s) => s.id === createdId)
|
||||
expect(created?.pending_workspace_id).toBe('rootB')
|
||||
expect(created?.transient).toBe(true)
|
||||
} finally {
|
||||
sessionState.sessions = sessionState.sessions.filter(
|
||||
(s) => s.id !== 'sw-only-foreign' && s.id !== createdId
|
||||
)
|
||||
sessionState.currentSessionId = prevCurrent
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, usersWorkspaceStore, workspaceColor } from '$lib/stores'
|
||||
import { workspaceStore, usersWorkspaceStore, userWorkspaces, workspaceColor } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import Modal from '../common/modal/Modal.svelte'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import WorkspaceScopeTrigger from '../WorkspaceScopeTrigger.svelte'
|
||||
import DarkModeToggle from '../sidebar/DarkModeToggle.svelte'
|
||||
|
||||
let { open = false }: { open?: boolean } = $props()
|
||||
|
||||
// A fork's color themes the fork pickers, so the section takes the
|
||||
// create-fork form's "Fork color" shape — live picker preview included.
|
||||
const isFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces))
|
||||
const colorLabel = $derived(isFork ? 'Fork color' : 'Workspace color')
|
||||
|
||||
let colorEnabled = $state(false)
|
||||
let editingColor = $state<string | undefined>(undefined)
|
||||
let lastWorkspace = $state<string | undefined>(undefined)
|
||||
@@ -52,9 +60,11 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold text-xs text-emphasis">Workspace color</p>
|
||||
<p class="font-semibold text-xs text-emphasis">{colorLabel}</p>
|
||||
<p class="text-xs text-secondary font-normal">
|
||||
Color to identify the current workspace in the list of workspaces
|
||||
{isFork
|
||||
? 'Color to identify this fork in the pickers'
|
||||
: 'Color to identify the current workspace in the list of workspaces'}
|
||||
</p>
|
||||
<div class="flex flex-row gap-0.5 items-center">
|
||||
{#if $workspaceColor}
|
||||
@@ -79,10 +89,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open title="Change workspace color">
|
||||
<Modal bind:open title="Change {colorLabel.toLowerCase()}">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="block">
|
||||
<span class="text-secondary text-sm">Workspace color</span>
|
||||
<span class="text-secondary text-sm">{colorLabel}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
@@ -97,6 +107,23 @@
|
||||
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
|
||||
</div>
|
||||
</label>
|
||||
{#if isFork && colorEnabled}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span class="text-2xs text-secondary">Fork picker preview</span>
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="w-64">
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={$workspaceStore}
|
||||
color={editingColor}
|
||||
showChevron={false}
|
||||
wrap
|
||||
class="w-full pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet actions()}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
// Forks have no customizable display name — the unique id is the name
|
||||
// (same convention as the create-fork form), so the editor is hidden.
|
||||
const isFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces))
|
||||
|
||||
let currentName = $state('')
|
||||
let newName = $state('')
|
||||
|
||||
@@ -36,33 +41,35 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold text-xs text-emphasis">Workspace name</p>
|
||||
<p class="text-xs text-secondary font-normal">Displayable name</p>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<TextInput
|
||||
bind:value={newName}
|
||||
size="sm"
|
||||
class="max-w-xs"
|
||||
inputProps={{
|
||||
placeholder: 'Workspace name',
|
||||
'aria-label': 'Workspace name',
|
||||
onkeydown: (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
renameWorkspace()
|
||||
{#if !isFork}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold text-xs text-emphasis">Workspace name</p>
|
||||
<p class="text-xs text-secondary font-normal">Displayable name</p>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<TextInput
|
||||
bind:value={newName}
|
||||
size="sm"
|
||||
class="max-w-xs"
|
||||
inputProps={{
|
||||
placeholder: 'Workspace name',
|
||||
'aria-label': 'Workspace name',
|
||||
onkeydown: (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
renameWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={!newName || newName === currentName}
|
||||
on:click={() => {
|
||||
renameWorkspace()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={!newName || newName === currentName}
|
||||
on:click={() => {
|
||||
renameWorkspace()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearStores, switchWorkspace } from '$lib/storeUtils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { findWorkspaceDescendants, workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import {
|
||||
deleteSessionsForWorkspace,
|
||||
reconcileAfterWorkspaceChange
|
||||
} from '$lib/components/sessions/sessionState.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
|
||||
type ForkedDatatable = {
|
||||
name: string
|
||||
resourceType: string
|
||||
resourcePath: string
|
||||
dropOnDelete: boolean
|
||||
}
|
||||
|
||||
let open = $state(false)
|
||||
let forkedDatatables: ForkedDatatable[] = $state([])
|
||||
let deleteForkedChildren = $state(false)
|
||||
|
||||
const forkedDescendants = $derived(
|
||||
$workspaceStore ? findWorkspaceDescendants($workspaceStore, $userWorkspaces ?? []) : []
|
||||
)
|
||||
// Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
|
||||
const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
|
||||
|
||||
async function loadForkedDatatables() {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
|
||||
const datatables = settings.datatable?.datatables ?? {}
|
||||
forkedDatatables = Object.entries(datatables)
|
||||
.filter(([_, dt]) => dt.forked_from != null)
|
||||
.map(([name, dt]) => ({
|
||||
name,
|
||||
resourceType: dt.database.resource_type ?? 'instance',
|
||||
resourcePath: dt.database.resource_path ?? '',
|
||||
dropOnDelete: true
|
||||
}))
|
||||
} catch {
|
||||
forkedDatatables = []
|
||||
}
|
||||
}
|
||||
|
||||
// Load the fork's datatables, then open the confirm modal. The caller only
|
||||
// surfaces the affordance for forked workspaces; the modal also self-guards
|
||||
// its markup on `currentWsIsFork` below.
|
||||
export async function openModal() {
|
||||
await loadForkedDatatables()
|
||||
deleteForkedChildren = false
|
||||
open = true
|
||||
}
|
||||
|
||||
async function deleteFork() {
|
||||
const workspace = $workspaceStore ?? ''
|
||||
// Capture the parent before delete so we can land the user there
|
||||
// instead of dropping them back on the workspace-picker menu.
|
||||
// Only valid if the parent is still in the user's workspace list.
|
||||
const parentId = $userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id
|
||||
const parentStillAccessible = !!(parentId && $userWorkspaces.find((w) => w.id === parentId))
|
||||
const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name)
|
||||
|
||||
if (dbsToDrop.length > 0) {
|
||||
const errors = await WorkspaceService.dropForkedDatatableDatabases({
|
||||
workspace,
|
||||
requestBody: { datatable_names: dbsToDrop }
|
||||
})
|
||||
for (const err of errors) {
|
||||
sendUserToast(err, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fork-scoped ducklake namespaces (metadata schemas + data files) — driven by the
|
||||
// backend registry, so no per-lake selection is needed. Best-effort: a failure is
|
||||
// surfaced but doesn't block the workspace delete (the registry rows survive a
|
||||
// failed cleanup and re-creating the same fork id retries it — the toast tells the
|
||||
// user something was left behind).
|
||||
try {
|
||||
const dlErrors = await WorkspaceService.dropForkedDucklakeNamespaces({ workspace })
|
||||
for (const err of dlErrors) {
|
||||
sendUserToast(err, true)
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to drop fork ducklake namespaces: ${err}`, true)
|
||||
}
|
||||
|
||||
if (deleteForkedChildren && forkedDescendants.length > 0) {
|
||||
for (const child of forkedDescendants) {
|
||||
try {
|
||||
await WorkspaceService.dropForkedDucklakeNamespaces({ workspace: child.id }).then(
|
||||
(errs) => errs.forEach((e) => sendUserToast(e, true)),
|
||||
(err) =>
|
||||
sendUserToast(`Failed to drop fork ducklake namespaces of ${child.id}: ${err}`, true)
|
||||
)
|
||||
await WorkspaceService.deleteWorkspace({ workspace: child.id })
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to delete forked child ${child.id}: ${err}`, true)
|
||||
return
|
||||
}
|
||||
// Backend delete is authoritative; session cleanup is best-effort so a
|
||||
// local failure can't abort the remaining (parent) deletes.
|
||||
await deleteSessionsForWorkspace(child.id).catch((e) =>
|
||||
console.error(`Session cleanup for ${child.id} failed`, e)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await WorkspaceService.deleteWorkspace({ workspace })
|
||||
await deleteSessionsForWorkspace(workspace).catch((e) =>
|
||||
console.error('Session cleanup after workspace delete failed', e)
|
||||
)
|
||||
sendUserToast('You deleted the workspace')
|
||||
if (parentStillAccessible && parentId) {
|
||||
// Refresh the workspace list AND reconcile session lifecycle before
|
||||
// landing on the parent. The refresh keeps the sidebar's
|
||||
// `visibleSessions` filter from rendering every committed session as
|
||||
// "Fork — no longer available" (it reads `$userWorkspaces`, which
|
||||
// `clearStores()` would null). The reconcile re-roots surviving child
|
||||
// forks: deleting this fork without "delete children" re-parents them
|
||||
// via the backend's ON DELETE SET NULL, so their sessions' stored
|
||||
// `workspace_root_id` must be recomputed off the now-deleted ancestor.
|
||||
// A reconcile failure must NOT strand the user on the just-deleted
|
||||
// workspace — always fall through to the parent switch + navigation.
|
||||
try {
|
||||
await reconcileAfterWorkspaceChange()
|
||||
} catch (e) {
|
||||
console.error('Failed to reconcile sessions after workspace delete', e)
|
||||
}
|
||||
switchWorkspace(parentId)
|
||||
await goto('/')
|
||||
} else {
|
||||
clearStores()
|
||||
await goto('/user/workspaces')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if currentWsIsFork}
|
||||
<ConfirmationModal
|
||||
{open}
|
||||
title="Delete forked workspace"
|
||||
confirmationText="Remove"
|
||||
on:canceled={() => {
|
||||
open = false
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
open = false
|
||||
deleteFork()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
|
||||
{#if forkedDescendants.length > 0}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="px-4 py-2 flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-xs font-semibold text-secondary">Forked children</span>
|
||||
<span class="text-3xs text-hint">
|
||||
This fork has {forkedDescendants.length} forked
|
||||
{forkedDescendants.length === 1 ? 'child' : 'children'} (transitively).
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
class="shrink-0"
|
||||
size="xs"
|
||||
bind:checked={deleteForkedChildren}
|
||||
options={{ right: 'Also delete children' }}
|
||||
/>
|
||||
</div>
|
||||
<ul class="px-4 py-2 text-3xs text-hint max-h-32 overflow-y-auto">
|
||||
{#each forkedDescendants as child}
|
||||
<li class="font-mono truncate" title={child.id}>{child.id}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if forkedDatatables.length > 0}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-secondary"> Forked databases </div>
|
||||
{#each forkedDatatables as dt}
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium text-secondary">{dt.name}</span>
|
||||
<span class="text-3xs text-hint">
|
||||
{dt.resourceType === 'instance'
|
||||
? dt.resourcePath
|
||||
: `${$workspaceStore?.replace(/-/g, '_')}__${dt.name}`}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
class="shrink-0"
|
||||
size="xs"
|
||||
bind:checked={dt.dropOnDelete}
|
||||
options={{ right: 'Drop database' }}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
@@ -97,7 +97,7 @@
|
||||
let { lightMode = false, isCollapsed = false, favoriteLinks = [], createMenu }: Props = $props()
|
||||
</script>
|
||||
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
{#snippet triggr({ trigger })}
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
@@ -105,6 +105,7 @@
|
||||
label={'Favorites'}
|
||||
{isCollapsed}
|
||||
{lightMode}
|
||||
showChevron
|
||||
{trigger}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -132,15 +133,16 @@
|
||||
{item}
|
||||
class="w-full inline-flex flex-row px-4 py-2 pr-8 data-[highlighted]:bg-surface-hover"
|
||||
>
|
||||
<!-- Kind colors mirror the home-page row icons (RowIcon.svelte). -->
|
||||
<span class="center-center">
|
||||
{#if favorite.kind == 'script'}
|
||||
<CodeXml size={16} />
|
||||
<CodeXml size={16} class="text-blue-500" />
|
||||
{:else if favorite.kind == 'flow'}
|
||||
<BarsStaggered size={16} />
|
||||
<BarsStaggered size={16} class="text-teal-500" />
|
||||
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
|
||||
<LayoutDashboard size={16} />
|
||||
<LayoutDashboard size={16} class="text-orange-500" />
|
||||
{:else if favorite.kind == 'asset'}
|
||||
<Table2 size={16} />
|
||||
<Table2 size={16} class="text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
|
||||
@@ -153,7 +155,7 @@
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: 'Delete',
|
||||
displayName: 'Remove from favorites',
|
||||
icon: Trash2,
|
||||
action: () => favoriteManager.unstar(favorite.path, favorite.kind)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { conditionalMelt } from '$lib/utils'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
aiId?: string | undefined
|
||||
@@ -36,6 +37,13 @@
|
||||
trigger?: MenubarMenuElements['trigger'] | undefined
|
||||
href?: string | undefined
|
||||
class?: string | undefined
|
||||
// Show a trailing chevron to signal the button opens a dropdown.
|
||||
showChevron?: boolean
|
||||
// Render the label with stronger weight/size (e.g. the workspace name).
|
||||
emphasizeLabel?: boolean
|
||||
// Drop the native `title` attributes — for callers that wrap the button
|
||||
// in their own hover tooltip.
|
||||
disableTitle?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -55,7 +63,10 @@
|
||||
color = null,
|
||||
trigger = undefined,
|
||||
href = undefined,
|
||||
class: classNames = undefined
|
||||
class: classNames = undefined,
|
||||
showChevron = false,
|
||||
emphasizeLabel = false,
|
||||
disableTitle = false
|
||||
}: Props = $props()
|
||||
|
||||
let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
|
||||
@@ -103,7 +114,7 @@
|
||||
)}
|
||||
use:conditionalMelt={trigger}
|
||||
aria-label={label}
|
||||
title={isCollapsed ? undefined : label}
|
||||
title={isCollapsed || disableTitle ? undefined : label}
|
||||
{...$trigger}
|
||||
>
|
||||
{#if icon}
|
||||
@@ -138,11 +149,11 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'whitespace-pre truncate w-full',
|
||||
sidebarClasses.text,
|
||||
emphasizeLabel ? 'text-primary text-sm font-semibold' : sidebarClasses.text,
|
||||
'transition-all',
|
||||
classNames
|
||||
)}
|
||||
title={label}
|
||||
title={disableTitle ? undefined : label}
|
||||
>
|
||||
{label}
|
||||
<span class="pl-2 text-xs text-secondary font-semibold">
|
||||
@@ -164,6 +175,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showChevron && !isCollapsed}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
class="flex-shrink-0 text-tertiary transition-colors group-hover:text-secondary"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isCollapsed && notificationsCount > 0}
|
||||
<div class="absolute top-1 right-1 flex h-fit w-fit">
|
||||
<SideBarNotification notificationCount={notificationsCount} small={true} />
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
ServerCog,
|
||||
Logs,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Building,
|
||||
Moon,
|
||||
Sun,
|
||||
GraduationCap,
|
||||
BookOpen,
|
||||
Github,
|
||||
Newspaper,
|
||||
Crown,
|
||||
Gauge,
|
||||
Trash2
|
||||
} from 'lucide-svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { type Item } from '$lib/utils'
|
||||
import { logout } from '$lib/logoutKit'
|
||||
import { leaveCurrentWorkspace } from './leaveWorkspace'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import DeleteForkedWorkspaceModal from './DeleteForkedWorkspaceModal.svelte'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import DarkModeObserver from '../DarkModeObserver.svelte'
|
||||
import DiscordIcon from '../icons/brands/Discord.svelte'
|
||||
import MenuLink from './MenuLink.svelte'
|
||||
import SideBarNotification from './SideBarNotification.svelte'
|
||||
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
|
||||
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
|
||||
import {
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
userStore,
|
||||
superadmin,
|
||||
devopsRole,
|
||||
enterpriseLicense,
|
||||
isCriticalAlertsUIOpen,
|
||||
usageStore,
|
||||
isPremiumStore
|
||||
} from '$lib/stores'
|
||||
|
||||
let {
|
||||
isCollapsed = false,
|
||||
// Session mode: workspace the workspace-settings entry should target —
|
||||
// the session's own (possibly forked) workspace, which can differ from
|
||||
// the rail's active workspace, so the entry's href carries it as the
|
||||
// `workspace` query param. Also hides "Leave workspace" (leaving a
|
||||
// workspace from the session rail makes no sense). Unset = navigation
|
||||
// mode, entry targets the active workspace.
|
||||
workspaceSettingsTarget = undefined,
|
||||
numUnacknowledgedCriticalAlerts = 0,
|
||||
// Overridable so the kitchen-sink page can preview the cloud variant
|
||||
// (isCloudHosted reads window.location, which a dev page can't change).
|
||||
cloudHosted = isCloudHosted()
|
||||
}: {
|
||||
isCollapsed?: boolean
|
||||
workspaceSettingsTarget?: string
|
||||
numUnacknowledgedCriticalAlerts?: number
|
||||
cloudHosted?: boolean
|
||||
} = $props()
|
||||
|
||||
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
|
||||
const canManageWorkspace = $derived($userStore?.is_admin || $superadmin)
|
||||
// Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
|
||||
const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
|
||||
|
||||
const settingsTargetWs = $derived(
|
||||
workspaceSettingsTarget
|
||||
? $userWorkspaces?.find((w) => w.id === workspaceSettingsTarget)
|
||||
: undefined
|
||||
)
|
||||
|
||||
let leaveWorkspaceModal = $state(false)
|
||||
let deleteForkModal = $state<DeleteForkedWorkspaceModal>()
|
||||
|
||||
const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
|
||||
let hasNewChangelogs = $state(hasNew)
|
||||
function onChangelogsOpened() {
|
||||
markChangelogsOpened()
|
||||
hasNewChangelogs = false
|
||||
}
|
||||
|
||||
let darkMode = $state(false)
|
||||
function toggleDarkMode() {
|
||||
if (!document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.add('dark')
|
||||
window.localStorage.setItem('dark-mode', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
window.localStorage.setItem('dark-mode', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
const helpItems: Item[] = [
|
||||
{ displayName: 'Tutorials', icon: GraduationCap, href: `${base}/tutorials` },
|
||||
{
|
||||
displayName: 'Docs',
|
||||
icon: BookOpen,
|
||||
href: 'https://www.windmill.dev/docs/intro/',
|
||||
hrefTarget: '_blank'
|
||||
},
|
||||
{
|
||||
displayName: 'Feedbacks',
|
||||
icon: DiscordIcon,
|
||||
href: 'https://discord.gg/V7PM2YHsPB',
|
||||
hrefTarget: '_blank'
|
||||
},
|
||||
{
|
||||
displayName: 'Issues',
|
||||
icon: Github,
|
||||
href: 'https://github.com/windmill-labs/windmill/issues/new',
|
||||
hrefTarget: '_blank'
|
||||
},
|
||||
{
|
||||
displayName: 'Changelog',
|
||||
icon: Newspaper,
|
||||
href: 'https://www.windmill.dev/changelog/',
|
||||
hrefTarget: '_blank'
|
||||
},
|
||||
...recentChangelogs.map((changelog, i) => ({
|
||||
displayName: changelog.label,
|
||||
href: changelog.href,
|
||||
hrefTarget: '_blank' as const,
|
||||
separatorTop: i === 0
|
||||
}))
|
||||
]
|
||||
|
||||
// Account / instance actions gathered under one "Settings" dropdown, shared by
|
||||
// the session rail and the global sidebar so both expose the same entry point.
|
||||
// User sits just above Logout so the account-scoped entries read as one block.
|
||||
const items = $derived<Item[]>([
|
||||
...(canManageWorkspace
|
||||
? [
|
||||
workspaceSettingsTarget
|
||||
? {
|
||||
displayName: `${settingsTargetWs?.name ?? workspaceSettingsTarget} settings`,
|
||||
icon: Building,
|
||||
// The `workspace` query param switches the app to the target
|
||||
// workspace on arrival (full loads and client-side navs alike).
|
||||
href: `${base}/workspace_settings?workspace=${workspaceSettingsTarget}`
|
||||
}
|
||||
: {
|
||||
displayName: `${currentWs?.name ?? $workspaceStore ?? 'Workspace'} settings`,
|
||||
icon: Building,
|
||||
href: `${base}/workspace_settings`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...($superadmin
|
||||
? [
|
||||
{
|
||||
displayName: 'Instance settings',
|
||||
icon: Settings,
|
||||
action: () => goto(SUPERADMIN_SETTINGS_HASH)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(!canManageWorkspace && !workspaceSettingsTarget
|
||||
? [
|
||||
{
|
||||
displayName: 'Leave workspace',
|
||||
icon: LogOut,
|
||||
type: 'delete' as const,
|
||||
action: () => (leaveWorkspaceModal = true)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// Fork deletion is a global-sidebar action on the active workspace, so keep it
|
||||
// out of the session rail's per-target settings entry (`workspaceSettingsTarget`).
|
||||
...(currentWsIsFork && !workspaceSettingsTarget
|
||||
? [
|
||||
{
|
||||
displayName: 'Delete forked workspace',
|
||||
icon: Trash2,
|
||||
type: 'delete' as const,
|
||||
action: () => deleteForkModal?.openModal()
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Help',
|
||||
icon: HelpCircle,
|
||||
submenuItems: helpItems,
|
||||
extra: helpPing,
|
||||
separatorTop: true
|
||||
},
|
||||
{
|
||||
displayName: 'Switch theme',
|
||||
icon: darkMode ? Sun : Moon,
|
||||
action: () => toggleDarkMode()
|
||||
},
|
||||
{
|
||||
// The email is the label itself; the crown icon carries the admin role.
|
||||
displayName: $userStore?.non_member
|
||||
? `${$userStore?.email} (superadmin, not a member)`
|
||||
: ($userStore?.email ?? 'User'),
|
||||
icon: $userStore?.is_admin || $userStore?.non_member ? Crown : User,
|
||||
submenuItems: [
|
||||
{
|
||||
displayName: 'Account settings',
|
||||
icon: Settings,
|
||||
action: () => goto(USER_SETTINGS_HASH)
|
||||
},
|
||||
...(cloudHosted && !$isPremiumStore
|
||||
? [
|
||||
{
|
||||
displayName: `${$usageStore}/1000 user execs`,
|
||||
icon: Gauge,
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{ displayName: 'Logout', icon: LogOut, action: () => logout() }
|
||||
])
|
||||
|
||||
const logsItems = $derived<Item[]>([
|
||||
{ displayName: 'Audit logs', icon: Eye, href: `${base}/audit_logs` },
|
||||
...($devopsRole
|
||||
? [{ displayName: 'Service logs', icon: Logs, href: `${base}/service_logs` }]
|
||||
: []),
|
||||
...($enterpriseLicense
|
||||
? [
|
||||
{
|
||||
displayName: 'Critical alerts',
|
||||
icon: AlertCircle,
|
||||
action: () => isCriticalAlertsUIOpen.set(true),
|
||||
extra: criticalAlertsBadge
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
</script>
|
||||
|
||||
{#snippet criticalAlertsBadge()}
|
||||
{#if numUnacknowledgedCriticalAlerts > 0}
|
||||
<SideBarNotification notificationCount={numUnacknowledgedCriticalAlerts} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet helpPing()}
|
||||
{#if hasNewChangelogs}
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Workers and Logs are full pages (not account/instance actions), so they sit
|
||||
as plain links above the Settings dropdown rather than inside it. -->
|
||||
<div class="flex flex-col gap-1 pb-1">
|
||||
<MenuLink
|
||||
class="!text-xs"
|
||||
label="Workers"
|
||||
href="{base}/workers"
|
||||
icon={ServerCog}
|
||||
{isCollapsed}
|
||||
aiId="sidebar-menu-link-workers"
|
||||
aiDescription="Button to navigate to workers"
|
||||
/>
|
||||
{#if $devopsRole || $userStore?.is_admin}
|
||||
<DropdownV2
|
||||
items={logsItems}
|
||||
placement="top-start"
|
||||
class="w-full"
|
||||
enableFlyTransition
|
||||
aiId="sidebar-menu-link-logs"
|
||||
aiDescription="Button to open the logs menu (audit logs, service logs, critical alerts)"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-secondary text-xs hover:bg-surface-hover cursor-pointer {isCollapsed
|
||||
? 'justify-center'
|
||||
: ''}"
|
||||
>
|
||||
<Logs size={16} />
|
||||
{#if !isCollapsed}
|
||||
Logs
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
{#if numUnacknowledgedCriticalAlerts > 0}
|
||||
<SideBarNotification notificationCount={numUnacknowledgedCriticalAlerts} />
|
||||
{/if}
|
||||
<ChevronDown size={14} class="flex-shrink-0 text-tertiary" />
|
||||
</span>
|
||||
{:else if numUnacknowledgedCriticalAlerts > 0}
|
||||
<span class="absolute top-0.5 right-0.5">
|
||||
<SideBarNotification
|
||||
notificationCount={numUnacknowledgedCriticalAlerts}
|
||||
small={true}
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{:else}
|
||||
<MenuLink
|
||||
class="!text-xs"
|
||||
label="Audit logs"
|
||||
href="{base}/audit_logs"
|
||||
icon={Eye}
|
||||
disabled={$userStore?.operator}
|
||||
{isCollapsed}
|
||||
aiId="sidebar-menu-link-audit-logs"
|
||||
aiDescription="Button to navigate to audit logs"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Changelogs count as seen once the menu closes (not on open), so the Help-row
|
||||
dot stays visible while the user is looking at the open menu. -->
|
||||
<DropdownV2
|
||||
{items}
|
||||
placement="top-start"
|
||||
class="w-full"
|
||||
enableFlyTransition
|
||||
on:close={onChangelogsOpened}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-secondary text-xs hover:bg-surface-hover cursor-pointer {isCollapsed
|
||||
? 'justify-center'
|
||||
: ''}"
|
||||
>
|
||||
<Settings size={16} />
|
||||
{#if !isCollapsed}
|
||||
Settings
|
||||
<ChevronDown size={14} class="ml-auto flex-shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
{#if hasNewChangelogs}
|
||||
<span
|
||||
class="flex h-2 w-2 absolute {isCollapsed
|
||||
? 'top-0.5 right-0.5'
|
||||
: 'right-7 top-1/2 -translate-y-1/2'}"
|
||||
>
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"></span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<ConfirmationModal
|
||||
open={leaveWorkspaceModal}
|
||||
title="Leave workspace"
|
||||
confirmationText="Leave workspace"
|
||||
on:canceled={() => {
|
||||
leaveWorkspaceModal = false
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
leaveWorkspaceModal = false
|
||||
void leaveCurrentWorkspace()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to leave this workspace?</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<DeleteForkedWorkspaceModal bind:this={deleteForkModal} />
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
@@ -4,7 +4,6 @@
|
||||
superadmin,
|
||||
usedTriggerKinds,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
isCriticalAlertsUIOpen,
|
||||
enterpriseLicense,
|
||||
@@ -12,7 +11,6 @@
|
||||
tutorialsToDo,
|
||||
skippedAll
|
||||
} from '$lib/stores'
|
||||
import { findWorkspaceDescendants, workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { syncTutorialsTodos } from '$lib/tutorialUtils'
|
||||
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
|
||||
import {
|
||||
@@ -35,12 +33,12 @@
|
||||
Route,
|
||||
Settings,
|
||||
UserCog,
|
||||
Users,
|
||||
Plus,
|
||||
Unplug,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Pyramid,
|
||||
Trash2,
|
||||
MailIcon,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
@@ -49,16 +47,10 @@
|
||||
import { slide } from 'svelte/transition'
|
||||
import UserMenu from './UserMenu.svelte'
|
||||
import DiscordIcon from '../icons/brands/Discord.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearStores, switchWorkspace } from '$lib/storeUtils'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { onMount } from 'svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { type Changelog, changelogs } from './changelogs'
|
||||
import { page } from '$app/state'
|
||||
import SideBarNotification from './SideBarNotification.svelte'
|
||||
import KafkaIcon from '../icons/KafkaIcon.svelte'
|
||||
@@ -81,137 +73,11 @@
|
||||
import MenuButton from './MenuButton.svelte'
|
||||
import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte'
|
||||
import AzureIcon from '../icons/AzureIcon.svelte'
|
||||
import {
|
||||
deleteSessionsForWorkspace,
|
||||
reconcileAfterWorkspaceChange
|
||||
} from '$lib/components/sessions/sessionState.svelte'
|
||||
import { leaveCurrentWorkspace } from './leaveWorkspace'
|
||||
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
|
||||
|
||||
async function leaveWorkspace() {
|
||||
await WorkspaceService.leaveWorkspace({ workspace: $workspaceStore ?? '' })
|
||||
sendUserToast('You left the workspace')
|
||||
clearStores()
|
||||
goto('/user/workspaces')
|
||||
}
|
||||
|
||||
type ForkedDatatable = {
|
||||
name: string
|
||||
resourceType: string
|
||||
resourcePath: string
|
||||
dropOnDelete: boolean
|
||||
}
|
||||
let forkedDatatables: ForkedDatatable[] = $state([])
|
||||
|
||||
async function loadForkedDatatables() {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
|
||||
const datatables = settings.datatable?.datatables ?? {}
|
||||
forkedDatatables = Object.entries(datatables)
|
||||
.filter(([_, dt]) => dt.forked_from != null)
|
||||
.map(([name, dt]) => ({
|
||||
name,
|
||||
resourceType: dt.database.resource_type ?? 'instance',
|
||||
resourcePath: dt.database.resource_path ?? '',
|
||||
dropOnDelete: true
|
||||
}))
|
||||
} catch {
|
||||
forkedDatatables = []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFork() {
|
||||
const workspace = $workspaceStore ?? ''
|
||||
// Capture the parent before delete so we can land the user there
|
||||
// instead of dropping them back on the workspace-picker menu.
|
||||
// Only valid if the parent is still in the user's workspace list.
|
||||
const parentId = $userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id
|
||||
const parentStillAccessible = !!(parentId && $userWorkspaces.find((w) => w.id === parentId))
|
||||
const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name)
|
||||
|
||||
if (dbsToDrop.length > 0) {
|
||||
const errors = await WorkspaceService.dropForkedDatatableDatabases({
|
||||
workspace,
|
||||
requestBody: { datatable_names: dbsToDrop }
|
||||
})
|
||||
for (const err of errors) {
|
||||
sendUserToast(err, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fork-scoped ducklake namespaces (metadata schemas + data files) — driven by the
|
||||
// backend registry, so no per-lake selection is needed. Best-effort: a failure is
|
||||
// surfaced but doesn't block the workspace delete (the registry rows survive a
|
||||
// failed cleanup and re-creating the same fork id retries it — the toast tells the
|
||||
// user something was left behind).
|
||||
try {
|
||||
const dlErrors = await WorkspaceService.dropForkedDucklakeNamespaces({ workspace })
|
||||
for (const err of dlErrors) {
|
||||
sendUserToast(err, true)
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to drop fork ducklake namespaces: ${err}`, true)
|
||||
}
|
||||
|
||||
if (deleteForkedChildren && forkedDescendants.length > 0) {
|
||||
for (const child of forkedDescendants) {
|
||||
try {
|
||||
await WorkspaceService.dropForkedDucklakeNamespaces({ workspace: child.id }).then(
|
||||
(errs) => errs.forEach((e) => sendUserToast(e, true)),
|
||||
(err) =>
|
||||
sendUserToast(`Failed to drop fork ducklake namespaces of ${child.id}: ${err}`, true)
|
||||
)
|
||||
await WorkspaceService.deleteWorkspace({ workspace: child.id })
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to delete forked child ${child.id}: ${err}`, true)
|
||||
return
|
||||
}
|
||||
// Backend delete is authoritative; session cleanup is best-effort so a
|
||||
// local failure can't abort the remaining (parent) deletes.
|
||||
await deleteSessionsForWorkspace(child.id).catch((e) =>
|
||||
console.error(`Session cleanup for ${child.id} failed`, e)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await WorkspaceService.deleteWorkspace({ workspace })
|
||||
await deleteSessionsForWorkspace(workspace).catch((e) =>
|
||||
console.error('Session cleanup after workspace delete failed', e)
|
||||
)
|
||||
sendUserToast('You deleted the workspace')
|
||||
if (parentStillAccessible && parentId) {
|
||||
// Refresh the workspace list AND reconcile session lifecycle before
|
||||
// landing on the parent. The refresh keeps the sidebar's
|
||||
// `visibleSessions` filter from rendering every committed session as
|
||||
// "Fork — no longer available" (it reads `$userWorkspaces`, which
|
||||
// `clearStores()` would null). The reconcile re-roots surviving child
|
||||
// forks: deleting this fork without "delete children" re-parents them
|
||||
// via the backend's ON DELETE SET NULL, so their sessions' stored
|
||||
// `workspace_root_id` must be recomputed off the now-deleted ancestor.
|
||||
// A reconcile failure must NOT strand the user on the just-deleted
|
||||
// workspace — always fall through to the parent switch + navigation.
|
||||
try {
|
||||
await reconcileAfterWorkspaceChange()
|
||||
} catch (e) {
|
||||
console.error('Failed to reconcile sessions after workspace delete', e)
|
||||
}
|
||||
switchWorkspace(parentId)
|
||||
await goto('/')
|
||||
} else {
|
||||
clearStores()
|
||||
await goto('/user/workspaces')
|
||||
}
|
||||
}
|
||||
|
||||
let deleteForkedChildren = $state(false)
|
||||
const forkedDescendants = $derived(
|
||||
$workspaceStore ? findWorkspaceDescendants($workspaceStore, $userWorkspaces ?? []) : []
|
||||
)
|
||||
// Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
|
||||
const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
|
||||
|
||||
let hasNewChangelogs = $state(false)
|
||||
let recentChangelogs: Changelog[] = $state([])
|
||||
let lastOpened = localStorage.getItem('changelogsLastOpened')
|
||||
const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
|
||||
let hasNewChangelogs = $state(hasNew)
|
||||
let availableNativeServices = $state<
|
||||
Array<{ service: NativeServiceName; icon: any; config: any }>
|
||||
>([])
|
||||
@@ -242,21 +108,12 @@
|
||||
)
|
||||
|
||||
onMount(async () => {
|
||||
if (lastOpened) {
|
||||
// @ts-ignore
|
||||
recentChangelogs = changelogs.filter((changelog) => changelog.date > lastOpened)
|
||||
hasNewChangelogs =
|
||||
recentChangelogs.length > 0 && lastOpened !== new Date().toISOString().split('T')[0]
|
||||
} else {
|
||||
recentChangelogs = changelogs.slice(0, 3)
|
||||
}
|
||||
// Sync tutorial progress on mount
|
||||
await syncTutorialsTodos()
|
||||
})
|
||||
|
||||
function openChangelogs() {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
localStorage.setItem('changelogsLastOpened', today)
|
||||
markChangelogsOpened()
|
||||
hasNewChangelogs = false
|
||||
}
|
||||
|
||||
@@ -312,12 +169,25 @@
|
||||
interface Props {
|
||||
numUnacknowledgedCriticalAlerts?: number
|
||||
isCollapsed?: boolean
|
||||
// Render the workspace-content nav (Home/Runs/Variables/… + triggers).
|
||||
showMain?: boolean
|
||||
// Render the bottom account group (User, Settings, Workers, Folders, Logs, Help).
|
||||
// Splitting these lets a host show content nav and account nav in separate rails.
|
||||
showSecondary?: boolean
|
||||
// Main-menu labels to omit here because the host renders them elsewhere
|
||||
// (e.g. the session-mode rail lifts Home/Runs up next to Favorites/Search).
|
||||
excludeMainLabels?: string[]
|
||||
}
|
||||
|
||||
let { numUnacknowledgedCriticalAlerts = 0, isCollapsed = false }: Props = $props()
|
||||
let {
|
||||
numUnacknowledgedCriticalAlerts = 0,
|
||||
isCollapsed = false,
|
||||
showMain = true,
|
||||
showSecondary = true,
|
||||
excludeMainLabels = []
|
||||
}: Props = $props()
|
||||
|
||||
let leaveWorkspaceModal = $state(false)
|
||||
let deleteWorkspaceForkModal = $state(false)
|
||||
|
||||
function computeAllNotificationsCount(menuItems: any[]) {
|
||||
let count = 0
|
||||
@@ -330,63 +200,81 @@
|
||||
const itemClass = twMerge(
|
||||
'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)
|
||||
let mainMenuLinks = $derived([
|
||||
{
|
||||
label: 'Home',
|
||||
href: `${base}/`,
|
||||
icon: Home,
|
||||
aiId: 'sidebar-menu-link-home',
|
||||
aiDescription:
|
||||
"Button to navigate to home which contains all the user's scripts, flows and apps"
|
||||
},
|
||||
{
|
||||
label: 'Runs',
|
||||
href: `${base}/runs`,
|
||||
icon: Play,
|
||||
aiId: 'sidebar-menu-link-runs',
|
||||
aiDescription: 'Button to navigate to runs',
|
||||
onclick: () => {
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('popstate'))
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Variables',
|
||||
href: `${base}/variables`,
|
||||
icon: DollarSign,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-variables',
|
||||
aiDescription: 'Button to navigate to variables'
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
href: `${base}/resources`,
|
||||
icon: Boxes,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-resources',
|
||||
aiDescription: 'Button to navigate to resources'
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: `${base}/assets`,
|
||||
icon: Pyramid,
|
||||
aiId: 'sidebar-menu-link-assets',
|
||||
aiDescription: 'Button to navigate to assets'
|
||||
},
|
||||
// Add Tutorials to main menu only if not all completed and not skipped
|
||||
...($tutorialsToDo.length > 0 && !$skippedAll
|
||||
? [
|
||||
{
|
||||
label: 'Tutorials',
|
||||
href: `${base}/tutorials`,
|
||||
icon: GraduationCap,
|
||||
aiId: 'sidebar-menu-link-tutorials-main',
|
||||
aiDescription: 'Button to navigate to tutorials'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
let mainMenuLinks = $derived(
|
||||
[
|
||||
{
|
||||
label: 'Home',
|
||||
href: `${base}/`,
|
||||
icon: Home,
|
||||
aiId: 'sidebar-menu-link-home',
|
||||
aiDescription:
|
||||
"Button to navigate to home which contains all the user's scripts, flows and apps"
|
||||
},
|
||||
{
|
||||
label: 'Runs',
|
||||
href: `${base}/runs`,
|
||||
icon: Play,
|
||||
aiId: 'sidebar-menu-link-runs',
|
||||
aiDescription: 'Button to navigate to runs',
|
||||
onclick: () => {
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('popstate'))
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Variables',
|
||||
href: `${base}/variables`,
|
||||
icon: DollarSign,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-variables',
|
||||
aiDescription: 'Button to navigate to variables'
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
href: `${base}/resources`,
|
||||
icon: Boxes,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-resources',
|
||||
aiDescription: 'Button to navigate to resources'
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: `${base}/assets`,
|
||||
icon: Pyramid,
|
||||
aiId: 'sidebar-menu-link-assets',
|
||||
aiDescription: 'Button to navigate to assets'
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
href: `${base}/folders`,
|
||||
icon: FolderOpen,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-folders',
|
||||
aiDescription: 'Button to navigate to folders'
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
href: `${base}/groups`,
|
||||
icon: Users,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-groups',
|
||||
aiDescription: 'Button to navigate to groups'
|
||||
},
|
||||
// Add Tutorials to main menu only if not all completed and not skipped
|
||||
...($tutorialsToDo.length > 0 && !$skippedAll
|
||||
? [
|
||||
{
|
||||
label: 'Tutorials',
|
||||
href: `${base}/tutorials`,
|
||||
icon: GraduationCap,
|
||||
aiId: 'sidebar-menu-link-tutorials-main',
|
||||
aiDescription: 'Button to navigate to tutorials'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
].filter((l) => !excludeMainLabels.includes(l.label))
|
||||
)
|
||||
let defaultExtraTriggerLinks = $derived([
|
||||
{
|
||||
label: 'HTTP',
|
||||
@@ -570,20 +458,6 @@
|
||||
faIcon: undefined
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(currentWsIsFork
|
||||
? [
|
||||
{
|
||||
label: 'Delete Forked Workspace',
|
||||
action: async () => {
|
||||
await loadForkedDatatables()
|
||||
deleteForkedChildren = false
|
||||
deleteWorkspaceForkModal = true
|
||||
},
|
||||
icon: Trash2,
|
||||
faIcon: undefined
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
disabled: $userStore?.operator
|
||||
@@ -596,33 +470,6 @@
|
||||
aiId: 'sidebar-menu-link-workers',
|
||||
aiDescription: 'Button to navigate to workers'
|
||||
},
|
||||
{
|
||||
label: 'Folders & Groups',
|
||||
icon: FolderOpen,
|
||||
aiId: 'sidebar-menu-link-folders-groups',
|
||||
aiDescription: 'Button to navigate to folders and groups',
|
||||
subItems: [
|
||||
{
|
||||
label: 'Folders',
|
||||
href: `${base}/folders`,
|
||||
icon: FolderOpen,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-folders',
|
||||
aiDescription: 'Button to navigate to folders',
|
||||
faIcon: undefined
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
href: `${base}/groups`,
|
||||
icon: UserCog,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-groups',
|
||||
aiDescription: 'Button to navigate to groups',
|
||||
faIcon: undefined
|
||||
}
|
||||
],
|
||||
disabled: $userStore?.operator
|
||||
},
|
||||
$devopsRole || $userStore?.is_admin
|
||||
? {
|
||||
label: 'Logs',
|
||||
@@ -675,217 +522,224 @@
|
||||
])
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class={twMerge(
|
||||
'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between gap-2'
|
||||
)}
|
||||
>
|
||||
<div class={twMerge('pt-4 flex flex-col grow')}>
|
||||
<div class="space-y-1">
|
||||
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
{#if isCollapsed}
|
||||
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
|
||||
Triggers
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (triggersCollapsed.val = !triggersCollapsed.val)}
|
||||
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
|
||||
aria-expanded={!triggersCollapsed.val}
|
||||
>
|
||||
Triggers
|
||||
{#if triggersCollapsed.val}
|
||||
<ChevronRight size={10} />
|
||||
{:else}
|
||||
<ChevronDown size={10} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if isCollapsed || !triggersCollapsed.val}
|
||||
<div transition:slide={{ duration: 180 }}>
|
||||
<Menubar class="flex flex-col gap-1">
|
||||
{#snippet children({ createMenu })}
|
||||
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
aiId="sidebar-menu-link-add-trigger"
|
||||
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
|
||||
class={twMerge(
|
||||
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
|
||||
'data-[highlighted]:bg-surface-hover'
|
||||
)}
|
||||
meltElement={trigger}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
aiId={subItem.aiId}
|
||||
aiDescription={subItem.aiDescription}
|
||||
href={subItem.disabled ? '' : subItem.href}
|
||||
class={twMerge(
|
||||
itemClass,
|
||||
subItem.disabled ? 'pointer-events-none opacity-50' : ''
|
||||
)}
|
||||
{item}
|
||||
disabled={subItem.disabled}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
{/if}
|
||||
{subItem.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-auto pt-4">
|
||||
<!-- Single Menubar so melt-ui's hover-to-switch spans the whole bottom
|
||||
group (Settings/Workers/Folders/Logs AND Help). With Help in its own
|
||||
Menubar the menus stack instead of switching (WIN-1993). Each group
|
||||
keeps its own flex container for spacing. -->
|
||||
<Menubar class="flex flex-col gap-2">
|
||||
{#snippet children({ createMenu })}
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserMenu {isCollapsed} {createMenu} />
|
||||
|
||||
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
{#if menuLink.subItems}
|
||||
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MenuButton
|
||||
class="!text-2xs"
|
||||
{...menuLink}
|
||||
{isCollapsed}
|
||||
{notificationsCount}
|
||||
{trigger}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet children({ item })}
|
||||
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
href={subItem.href}
|
||||
{item}
|
||||
onClick={() => {
|
||||
subItem?.['action']?.()
|
||||
}}
|
||||
aiId={subItem.aiId}
|
||||
aiDescription={subItem.aiDescription}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
{/if}
|
||||
{subItem.label}
|
||||
{#if subItem?.['notificationCount']}
|
||||
<div class="ml-auto">
|
||||
<SideBarNotification
|
||||
notificationCount={subItem['notificationCount']}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{:else}
|
||||
<MenuSingleItem>
|
||||
{#snippet children({})}
|
||||
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
|
||||
{/snippet}
|
||||
</MenuSingleItem>
|
||||
{/if}
|
||||
{/each}
|
||||
<nav class={twMerge('flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 gap-2')}>
|
||||
<div class={twMerge('flex flex-col')}>
|
||||
{#if showMain}
|
||||
<div class="space-y-1">
|
||||
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
{#if isCollapsed}
|
||||
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
|
||||
Triggers
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each thirdMenuLinks as menuLink (menuLink)}
|
||||
{#if menuLink.subItems}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<button
|
||||
class="relative w-full"
|
||||
onclick={() => {
|
||||
if (menuLink.label === 'Help') {
|
||||
openChangelogs()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
|
||||
{#if menuLink.label === 'Help' && hasNewChangelogs}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex h-2 w-2 absolute',
|
||||
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
href={subItem.href}
|
||||
class={itemClass}
|
||||
target={subItem.external !== false ? '_blank' : undefined}
|
||||
{item}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (triggersCollapsed.val = !triggersCollapsed.val)}
|
||||
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
|
||||
aria-expanded={!triggersCollapsed.val}
|
||||
>
|
||||
Triggers
|
||||
{#if triggersCollapsed.val}
|
||||
<ChevronRight size={10} />
|
||||
{:else}
|
||||
<ChevronDown size={10} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if isCollapsed || !triggersCollapsed.val}
|
||||
<div transition:slide={{ duration: 180 }}>
|
||||
<Menubar class="flex flex-col gap-1">
|
||||
{#snippet children({ createMenu })}
|
||||
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
aiId="sidebar-menu-link-add-trigger"
|
||||
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
|
||||
class={twMerge(
|
||||
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
|
||||
'data-[highlighted]:bg-surface-hover'
|
||||
)}
|
||||
meltElement={trigger}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
{/if}
|
||||
|
||||
{subItem.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{#if recentChangelogs.length > 0}
|
||||
<div class="w-full h-1 border-t"></div>
|
||||
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
|
||||
{#each recentChangelogs as changelog}
|
||||
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
|
||||
<Plus size={14} />
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
aiId={subItem.aiId}
|
||||
aiDescription={subItem.aiDescription}
|
||||
href={subItem.disabled ? '' : subItem.href}
|
||||
class={twMerge(
|
||||
itemClass,
|
||||
subItem.disabled ? 'pointer-events-none opacity-50' : ''
|
||||
)}
|
||||
{item}
|
||||
disabled={subItem.disabled}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{changelog.label}
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
{/if}
|
||||
{subItem.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSecondary}
|
||||
<div class="flex flex-col gap-2 mt-auto pt-4">
|
||||
<!-- Single Menubar so melt-ui's hover-to-switch spans the whole bottom
|
||||
group (Settings/Workers/Folders/Logs AND Help). With Help in its own
|
||||
Menubar the menus stack instead of switching (WIN-1993). Each group
|
||||
keeps its own flex container for spacing. -->
|
||||
<Menubar class="flex flex-col gap-2">
|
||||
{#snippet children({ createMenu })}
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserMenu {isCollapsed} {createMenu} />
|
||||
|
||||
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
{#if menuLink.subItems}
|
||||
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MenuButton
|
||||
class="!text-2xs"
|
||||
{...menuLink}
|
||||
{isCollapsed}
|
||||
{notificationsCount}
|
||||
showChevron
|
||||
{trigger}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet children({ item })}
|
||||
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
href={subItem.href}
|
||||
{item}
|
||||
onClick={() => {
|
||||
subItem?.['action']?.()
|
||||
}}
|
||||
aiId={subItem.aiId}
|
||||
aiDescription={subItem.aiDescription}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
{/if}
|
||||
{subItem.label}
|
||||
{#if subItem?.['notificationCount']}
|
||||
<div class="ml-auto">
|
||||
<SideBarNotification
|
||||
notificationCount={subItem['notificationCount']}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{:else}
|
||||
<MenuSingleItem>
|
||||
{#snippet children({})}
|
||||
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
|
||||
{/snippet}
|
||||
</MenuSingleItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each thirdMenuLinks as menuLink (menuLink)}
|
||||
{#if menuLink.subItems}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<button
|
||||
class="relative w-full"
|
||||
onclick={() => {
|
||||
if (menuLink.label === 'Help') {
|
||||
openChangelogs()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuButton
|
||||
class="!text-2xs"
|
||||
{...menuLink}
|
||||
{isCollapsed}
|
||||
showChevron
|
||||
{trigger}
|
||||
/>
|
||||
{#if menuLink.label === 'Help' && hasNewChangelogs}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex h-2 w-2 absolute',
|
||||
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
href={subItem.href}
|
||||
class={itemClass}
|
||||
target={subItem.external !== false ? '_blank' : undefined}
|
||||
{item}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
{/if}
|
||||
|
||||
{subItem.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{#if recentChangelogs.length > 0}
|
||||
<div class="w-full h-1 border-t"></div>
|
||||
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
|
||||
{#each recentChangelogs as changelog}
|
||||
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{changelog.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
{/if}
|
||||
</div></nav
|
||||
>
|
||||
|
||||
@@ -897,76 +751,10 @@
|
||||
leaveWorkspaceModal = false
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
leaveWorkspace()
|
||||
void leaveCurrentWorkspace()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to leave this workspace?</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
{#if currentWsIsFork}
|
||||
<ConfirmationModal
|
||||
open={deleteWorkspaceForkModal}
|
||||
title="Delete forked workspace"
|
||||
confirmationText="Remove"
|
||||
on:canceled={() => {
|
||||
deleteWorkspaceForkModal = false
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
deleteWorkspaceForkModal = false
|
||||
deleteFork()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
|
||||
{#if forkedDescendants.length > 0}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="px-4 py-2 flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-xs font-semibold text-secondary">Forked children</span>
|
||||
<span class="text-3xs text-hint">
|
||||
This fork has {forkedDescendants.length} forked
|
||||
{forkedDescendants.length === 1 ? 'child' : 'children'} (transitively).
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
class="shrink-0"
|
||||
size="xs"
|
||||
bind:checked={deleteForkedChildren}
|
||||
options={{ right: 'Also delete children' }}
|
||||
/>
|
||||
</div>
|
||||
<ul class="px-4 py-2 text-3xs text-hint max-h-32 overflow-y-auto">
|
||||
{#each forkedDescendants as child}
|
||||
<li class="font-mono truncate" title={child.id}>{child.id}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if forkedDatatables.length > 0}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-secondary"> Forked databases </div>
|
||||
{#each forkedDatatables as dt}
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium text-secondary">{dt.name}</span>
|
||||
<span class="text-3xs text-hint">
|
||||
{dt.resourceType === 'instance'
|
||||
? dt.resourcePath
|
||||
: `${$workspaceStore?.replace(/-/g, '_')}__${dt.name}`}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
class="shrink-0"
|
||||
size="xs"
|
||||
bind:checked={dt.dropOnDelete}
|
||||
options={{ right: 'Drop database' }}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
color,
|
||||
class: className = '',
|
||||
children
|
||||
}: {
|
||||
// Rail background, so the overflow fades blend into it rather than sitting
|
||||
// as visible bars.
|
||||
color: string
|
||||
class?: string
|
||||
children: Snippet
|
||||
} = $props()
|
||||
|
||||
let viewport = $state<HTMLElement | undefined>(undefined)
|
||||
let overflowTop = $state(false)
|
||||
let overflowBottom = $state(false)
|
||||
|
||||
function measure() {
|
||||
const el = viewport
|
||||
if (!el) return
|
||||
overflowTop = el.scrollTop > 1
|
||||
overflowBottom = Math.ceil(el.scrollTop + el.clientHeight) < el.scrollHeight - 1
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = viewport
|
||||
if (!el) return
|
||||
measure()
|
||||
// Re-measure when the viewport or its content changes height — menus
|
||||
// expanding, sessions loading, the rail being resized — not just on scroll.
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
for (const child of Array.from(el.children)) ro.observe(child)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class={'relative flex-1 min-h-0 ' + className}>
|
||||
<div
|
||||
bind:this={viewport}
|
||||
onscroll={measure}
|
||||
class="sidebar-scroll h-full overflow-y-auto overflow-x-hidden scrollbar-hidden flex flex-col gap-3"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
<!-- Overflow hints: a short fade to the rail colour that only shows when there
|
||||
is content past that edge. pointer-events-none so it never eats clicks. -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-4 transition-opacity duration-150"
|
||||
style:opacity={overflowTop ? 1 : 0}
|
||||
style:background="linear-gradient(to bottom, {color}, transparent)"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-6 transition-opacity duration-150"
|
||||
style:opacity={overflowBottom ? 1 : 0}
|
||||
style:background="linear-gradient(to top, {color}, transparent)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Sections keep their natural height so the column scrolls as one block
|
||||
instead of flex-shrinking each section (which would overlap their rows). */
|
||||
.sidebar-scroll > :global(*) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,7 @@
|
||||
label={`User (${$userStore?.username ?? $userStore?.email})`}
|
||||
{isCollapsed}
|
||||
{lightMode}
|
||||
showChevron
|
||||
{trigger}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
workspaceUsageStore,
|
||||
workspaceColor,
|
||||
clearWorkspaceFromStorage,
|
||||
globalForkModal
|
||||
globalForkModal,
|
||||
type UserWorkspace
|
||||
} from '$lib/stores'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
|
||||
import { Building, Check, ChevronDown, ChevronRight, Plus, Settings } from 'lucide-svelte'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { Badge, CopyButton, NameIdTooltip } from '$lib/components/common'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { Menu, MenuItem } from '$lib/components/meltComponents'
|
||||
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
|
||||
import { fixupUrlAfterWorkspaceSwitch } from './workspaceSwitchUrl'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { page } from '$app/state'
|
||||
@@ -28,8 +31,10 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarBuilders } from '@melt-ui/svelte'
|
||||
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { devBadgeText, devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
@@ -38,13 +43,6 @@
|
||||
strictWorkspaceSelect?: boolean
|
||||
}
|
||||
|
||||
function removePrefix(str: string, prefix: string): string {
|
||||
if (str.startsWith(prefix)) {
|
||||
return str.substring(prefix.length)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props()
|
||||
|
||||
async function toggleSwitchWorkspace(id: string) {
|
||||
@@ -52,38 +50,24 @@
|
||||
return
|
||||
}
|
||||
workspaceAIClients.init(id)
|
||||
const editPages = [
|
||||
'/scripts/edit/',
|
||||
'/flows/edit/',
|
||||
'/apps/edit/',
|
||||
'/scripts/get/',
|
||||
'/flows/get/',
|
||||
'/apps/get/'
|
||||
]
|
||||
const isOnEditPage = editPages.some((editPage) => page.route.id?.includes(editPage) ?? false)
|
||||
// An AI session is scoped to its (forked) workspace, so it makes no sense
|
||||
// to keep showing it after the user switches workspace — go home instead.
|
||||
const isOnSessionPage = page.route.id?.includes('/sessions') ?? false
|
||||
|
||||
switchWorkspace(id)
|
||||
if (isOnEditPage || isOnSessionPage) {
|
||||
await goto('/')
|
||||
} else if (page.url.searchParams.get('workspace')) {
|
||||
page.url.searchParams.set('workspace', id)
|
||||
}
|
||||
// The sessions page needs no navigation here: the item's link navigation
|
||||
// (workspaceHref) keeps the route, and the page's family reconcile swaps
|
||||
// out a chat that doesn't belong to the new workspace's family.
|
||||
await fixupUrlAfterWorkspaceSwitch(id)
|
||||
}
|
||||
|
||||
// An AI session is scoped to its (forked) workspace, so switching workspace
|
||||
// should leave for home (the link's navigation wins over onClick's
|
||||
// preventDefault; onClick still performs the switch). Pure logic +
|
||||
// new-tab/workspace-param handling lives in workspaceMenuHref (unit-tested).
|
||||
// Href for the item's navigation (including modifier/middle clicks opening a
|
||||
// new tab): same page, `workspace` param swapped to the clicked id, the open
|
||||
// session kept only within its family. Pure logic lives in workspaceMenuHref
|
||||
// (unit-tested).
|
||||
function workspaceHref(id: string): string {
|
||||
const all = $userWorkspaces ?? []
|
||||
return workspaceMenuHref({
|
||||
routeId: page.route.id,
|
||||
base,
|
||||
pathname: page.url.pathname,
|
||||
searchParams: page.url.searchParams,
|
||||
id
|
||||
id,
|
||||
sameFamily: workspaceRootId(id, all) === workspaceRootId($workspaceStore ?? undefined, all)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,134 +84,279 @@
|
||||
toggleSwitchWorkspace(workspace.id)
|
||||
}
|
||||
|
||||
function getForkedWorkspace(workspaceId: string) {
|
||||
if (!$userWorkspaces) return undefined
|
||||
return $userWorkspaces.find((w) => w.id === workspaceId && w.parent_workspace_id != null)
|
||||
}
|
||||
|
||||
function getParentWorkspace(parentId: string) {
|
||||
if (!$userWorkspaces) return undefined
|
||||
return $userWorkspaces.find((w) => w.id === parentId)
|
||||
}
|
||||
|
||||
// Group workspaces into parent-child hierarchy using Svelte 5 derived and the new utility
|
||||
const groupedWorkspaces = $derived.by(() => {
|
||||
if (!$userWorkspaces) return []
|
||||
return buildWorkspaceHierarchy($userWorkspaces)
|
||||
// Family-first picker: list the workspace families (roots) with their forks
|
||||
// collapsed behind a per-family chevron, so direct fork navigation stays
|
||||
// available without flattening fork-heavy instances into a long menu. The
|
||||
// scope header's WorkspaceFamilyPicker remains the primary fork surface.
|
||||
//
|
||||
// strictWorkspaceSelect is used on standalone pages (e.g. svix webhook
|
||||
// creation) that render this menu with no scope header, so there forks must
|
||||
// stay directly selectable — list the full hierarchy unconditionally.
|
||||
const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : [])
|
||||
const expandedFamilies = new SvelteSet<string>()
|
||||
// Root ids with at least one fork — only they get the expand chevron.
|
||||
// hierarchy is a DFS (parent before child), so a depth>0 row belongs to the
|
||||
// last depth-0 row seen.
|
||||
const familiesWithForks = $derived.by(() => {
|
||||
const withForks = new Set<string>()
|
||||
let rootId: string | undefined
|
||||
for (const h of hierarchy) {
|
||||
if (h.depth === 0) rootId = h.workspace.id
|
||||
else if (rootId) withForks.add(rootId)
|
||||
}
|
||||
return withForks
|
||||
})
|
||||
// Gate for the "Workspace fork" entry pinned below the list (the global fork
|
||||
// modal carries its own base-workspace picker). Hidden on non-premium cloud,
|
||||
// in the admins workspace, or when forking is disabled.
|
||||
const canForkHere = $derived(
|
||||
(!isCloudHosted() || $isPremiumStore) &&
|
||||
$workspaceStore !== 'admins' &&
|
||||
canCreateFork($userStore)
|
||||
)
|
||||
const familyWorkspaces = $derived.by(() => {
|
||||
if (strictWorkspaceSelect) return hierarchy
|
||||
let rootId: string | undefined
|
||||
return hierarchy.filter((h) => {
|
||||
if (h.depth === 0) {
|
||||
rootId = h.workspace.id
|
||||
return true
|
||||
}
|
||||
return !!rootId && expandedFamilies.has(rootId)
|
||||
})
|
||||
})
|
||||
|
||||
let menuOpen = $state(false)
|
||||
|
||||
// ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt
|
||||
// stamps data-highlighted on the item; the row wrapper carries the workspace
|
||||
// id). Capture phase, so melt's menubar left/right (adjacent-menu switching)
|
||||
// doesn't fire when the keypress means expansion here.
|
||||
function onExpandKeydown(e: KeyboardEvent) {
|
||||
if (!menuOpen || strictWorkspaceSelect) return
|
||||
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
||||
// Scoped inside a row wrapper: the menubar trigger also carries
|
||||
// data-highlighted while its menu is open.
|
||||
const id = document
|
||||
.querySelector('[data-workspace-id] [data-highlighted]')
|
||||
?.closest('[data-workspace-id]')
|
||||
?.getAttribute('data-workspace-id')
|
||||
if (!id || !familiesWithForks.has(id)) return
|
||||
if (e.key === 'ArrowRight' && !expandedFamilies.has(id)) expandedFamilies.add(id)
|
||||
else if (e.key === 'ArrowLeft' && expandedFamilies.has(id)) expandedFamilies.delete(id)
|
||||
else return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
function findRoot(id: string | undefined): UserWorkspace | undefined {
|
||||
if (!id || !$userWorkspaces) return undefined
|
||||
let current = $userWorkspaces.find((w) => w.id === id)
|
||||
while (current?.parent_workspace_id) {
|
||||
const parent = $userWorkspaces.find((w) => w.id === current!.parent_workspace_id)
|
||||
if (!parent) break
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// The active workspace's family root — shown in the trigger so a forked
|
||||
// active workspace still surfaces its family name here (the fork itself is
|
||||
// shown in the breadcrumb).
|
||||
const currentFamily = $derived(findRoot($workspaceStore ?? undefined))
|
||||
|
||||
// The active workspace itself (fork included) — names the settings entry.
|
||||
const activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
|
||||
const canManageWorkspace = $derived($userStore?.is_admin || $superadmin)
|
||||
|
||||
// font-normal is explicit: href-less MenuItems render as <button>, which the
|
||||
// global stylesheet makes semibold, unlike the <a> the href entries get.
|
||||
const itemClass =
|
||||
'text-primary w-full flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
'text-primary font-normal w-full flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
</script>
|
||||
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
<svelte:window onkeydowncapture={onExpandKeydown} />
|
||||
|
||||
<!-- Expansion is per-open: every open starts with all families collapsed,
|
||||
including the active fork's. -->
|
||||
<Menu
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
bind:open={menuOpen}
|
||||
on:close={() => expandedFamilies.clear()}
|
||||
>
|
||||
{#snippet triggr({ trigger })}
|
||||
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
|
||||
{@const parentWorkspace = forkedWorkspace
|
||||
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
|
||||
: null}
|
||||
{@const iconColor = getContrastTextColor($workspaceColor)}
|
||||
{#if forkedWorkspace && parentWorkspace}
|
||||
<!-- Family header reflects the family (root) color, not the active
|
||||
workspace's — switching into a fork must not recolor it. -->
|
||||
{@const familyColor = currentFamily?.color ?? $workspaceColor}
|
||||
{@const iconColor = getContrastTextColor(familyColor)}
|
||||
<!-- Collapsed mode already shows MenuButton's own right-side popover, so the
|
||||
rich tooltip only takes over when expanded. -->
|
||||
<NameIdTooltip
|
||||
name={currentFamily?.name ?? $workspaceStore ?? ''}
|
||||
id={currentFamily?.id ?? $workspaceStore ?? ''}
|
||||
disablePopup={isCollapsed}
|
||||
class="block w-full"
|
||||
>
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
icon={GitFork}
|
||||
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
|
||||
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
|
||||
sublabel={parentWorkspace?.name
|
||||
? `${forkedWorkspace.is_dev_workspace ? `${devLabelWord(forkedWorkspace.dev_workspace_label)} workspace of` : 'Fork of'} ${parentWorkspace.name}`
|
||||
: undefined}
|
||||
{isCollapsed}
|
||||
color={$workspaceColor}
|
||||
{trigger}
|
||||
/>
|
||||
{:else}
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
icon={Building}
|
||||
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
|
||||
label={$workspaceStore ?? ''}
|
||||
label={currentFamily?.name ?? $workspaceStore ?? ''}
|
||||
{isCollapsed}
|
||||
color={$workspaceColor}
|
||||
color={familyColor}
|
||||
showChevron
|
||||
emphasizeLabel
|
||||
disableTitle
|
||||
{trigger}
|
||||
/>
|
||||
{/if}
|
||||
</NameIdTooltip>
|
||||
{/snippet}
|
||||
|
||||
{#snippet children({ item })}
|
||||
<div class="divide-y" role="none">
|
||||
<div class="py-1">
|
||||
{#each groupedWorkspaces as { workspace, depth, isForked, parentName }}
|
||||
{@const isSelected = $workspaceStore === workspace.id}
|
||||
<MenuItem
|
||||
<!-- The list scrolls internally so the sections below (+ Workspace,
|
||||
Workspace fork, All workspaces) stay visible however long it gets. -->
|
||||
<div class="py-1 overflow-y-auto max-h-[min(50vh,26rem)]">
|
||||
{#each familyWorkspaces as { workspace, depth, isForked, parentName }}
|
||||
{@const isActive = $workspaceStore === workspace.id}
|
||||
{@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined}
|
||||
<!-- Selection is a trailing tick (single-choice picker convention). A
|
||||
collapsed root carries the tick while one of its forks is active —
|
||||
the fork row is hidden and its family is the only trace of the
|
||||
selection; expanding moves the tick to the fork itself. -->
|
||||
{@const isSelected =
|
||||
isActive ||
|
||||
(!strictWorkspaceSelect &&
|
||||
depth === 0 &&
|
||||
currentFamily?.id === workspace.id &&
|
||||
!expandedFamilies.has(workspace.id))}
|
||||
{@const expandable =
|
||||
!strictWorkspaceSelect && depth === 0 && familiesWithForks.has(workspace.id)}
|
||||
<!-- The expand chevron sits OUTSIDE the melt item: melt activates items
|
||||
via document-level handlers, so a nested button can't stop the row's
|
||||
navigate-and-close with stopPropagation. As a sibling it toggles the
|
||||
family without selecting the row or closing the menu. -->
|
||||
<!-- Hover/keyboard-highlight backgrounds live on the wrapper (the
|
||||
highlight via :has(), since melt puts data-highlighted on the item) so
|
||||
they span the full row, chevron included; the chevron keeps only its
|
||||
own hover tint. The active workspace is marked by its row's check
|
||||
icon, not a background or text accent. -->
|
||||
<div
|
||||
data-workspace-id={workspace.id}
|
||||
class={twMerge(
|
||||
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-2 px-3',
|
||||
workspace.disabled && 'opacity-50 cursor-not-allowed',
|
||||
isSelected
|
||||
? 'cursor-default bg-surface-accent-selected'
|
||||
: workspace.disabled
|
||||
? ''
|
||||
: 'cursor-pointer hover:bg-surface-hover data-[highlighted]:bg-surface-hover'
|
||||
'group flex items-center min-w-0 w-full',
|
||||
workspace.disabled
|
||||
? ''
|
||||
: 'hover:bg-surface-hover [&:has([data-highlighted])]:bg-surface-hover'
|
||||
)}
|
||||
href={workspace.disabled ? undefined : workspaceHref(workspace.id)}
|
||||
onClick={(e) => onWorkspaceItemClick(e, workspace)}
|
||||
{item}
|
||||
>
|
||||
<div class="flex items-center justify-between min-w-0 w-full">
|
||||
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
|
||||
<WorkspaceIcon
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
isDevWorkspace={workspace.is_dev_workspace}
|
||||
devWorkspaceLabel={workspace.dev_workspace_label}
|
||||
{parentName}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class={twMerge(
|
||||
'truncate text-left text-xs font-normal',
|
||||
isSelected ? 'text-accent' : 'text-primary'
|
||||
)}
|
||||
title={workspace.name}
|
||||
>
|
||||
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
|
||||
<MenuItem
|
||||
class={twMerge(
|
||||
'text-xs min-w-0 flex-1 overflow-hidden flex flex-col py-1.5 px-3',
|
||||
workspace.disabled && 'opacity-50 cursor-not-allowed',
|
||||
isActive ? 'cursor-default' : workspace.disabled ? '' : 'cursor-pointer'
|
||||
)}
|
||||
href={workspace.disabled ? undefined : workspaceHref(workspace.id)}
|
||||
onClick={(e) => onWorkspaceItemClick(e, workspace)}
|
||||
{item}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 min-w-0 w-full">
|
||||
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
|
||||
<WorkspaceIcon
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
isDevWorkspace={workspace.is_dev_workspace}
|
||||
devWorkspaceLabel={workspace.dev_workspace_label}
|
||||
{parentName}
|
||||
padding="p-1"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="truncate text-left text-xs font-normal {forkAccent
|
||||
? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]'
|
||||
: 'text-primary'}"
|
||||
style={forkAccent}
|
||||
title={workspace.name}
|
||||
>
|
||||
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
|
||||
</div>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge
|
||||
color="dark-blue"
|
||||
small
|
||||
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
|
||||
>{devBadgeText(workspace.dev_workspace_label)}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={twMerge(
|
||||
'font-mono text-2xs whitespace-nowrap truncate text-left font-normal',
|
||||
isSelected ? 'text-accent/80' : 'text-secondary'
|
||||
)}
|
||||
title={workspace.id}
|
||||
>
|
||||
{workspace.id}
|
||||
</div>
|
||||
</div>
|
||||
{#if isSelected}
|
||||
<Check size={14} class="shrink-0 ml-2 text-accent" />
|
||||
{/if}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<!-- Hover-revealed like the fork picker's rows; a flex sibling of the melt
|
||||
item (nested buttons are invalid HTML) that always occupies its slot so
|
||||
nothing shifts on hover. -->
|
||||
<div
|
||||
class="shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
|
||||
>
|
||||
<CopyButton value={workspace.id} title={`Copy id: ${workspace.id}`} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
{#if expandable}
|
||||
{@const expanded = expandedFamilies.has(workspace.id)}
|
||||
<button
|
||||
type="button"
|
||||
title={expanded ? 'Hide forks' : 'Show forks'}
|
||||
aria-expanded={expanded}
|
||||
class="shrink-0 mr-1 px-2 self-stretch flex items-center rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
onclick={() => {
|
||||
if (expanded) expandedFamilies.delete(workspace.id)
|
||||
else expandedFamilies.add(workspace.id)
|
||||
}}
|
||||
>
|
||||
{#if expanded}
|
||||
<ChevronDown size={14} />
|
||||
{:else}
|
||||
<ChevronRight size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if (isCloudHosted() || $superadmin) && !strictWorkspaceSelect}
|
||||
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
|
||||
<Plus size={16} />
|
||||
Workspace
|
||||
</MenuItem>
|
||||
{#if isCloudHosted() || $superadmin}
|
||||
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
|
||||
<Plus size={16} />
|
||||
Workspace
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{#if canForkHere}
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
onClick={() => (globalForkModal.val = { opened: true })}
|
||||
{item}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Workspace fork
|
||||
</MenuItem>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !strictWorkspaceSelect && (!isCloudHosted() || $isPremiumStore) && canCreateFork($userStore) && $workspaceStore !== 'admins'}
|
||||
{#if canManageWorkspace && !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem
|
||||
href="{base}/workspace_settings?workspace={$workspaceStore}"
|
||||
class={itemClass}
|
||||
{item}
|
||||
onClick={() => (globalForkModal.val = { opened: true })}
|
||||
>
|
||||
<GitFork size={16} />
|
||||
Fork current workspace
|
||||
<Settings size={16} />
|
||||
{activeWorkspace?.name ?? $workspaceStore} settings
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -243,14 +372,6 @@
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
{#if ($userStore?.is_admin || $superadmin) && !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem href="{base}/workspace_settings" class={itemClass} {item}>
|
||||
<Settings size={16} />
|
||||
Workspace settings
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isCloudHosted() && !$isPremiumStore && !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
globalForkModal,
|
||||
userStore,
|
||||
superadmin
|
||||
} from '$lib/stores'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { fixupUrlAfterWorkspaceSwitch } from './workspaceSwitchUrl'
|
||||
import { base } from '$lib/base'
|
||||
import { workspaceAIClients } from '$lib/components/copilot/lib'
|
||||
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import { findWorkspaceRoot, findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
|
||||
|
||||
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
|
||||
|
||||
const effectiveId = $derived($workspaceStore ?? undefined)
|
||||
const currentWs = $derived(
|
||||
effectiveId ? $userWorkspaces.find((w) => w.id === effectiveId) : undefined
|
||||
)
|
||||
|
||||
// Fork count surfaces the family's size right on the trigger, hinting that
|
||||
// the muted "root" chip is also the entry point to its forks.
|
||||
const forkCount = $derived.by(() => {
|
||||
const root = findWorkspaceRoot(effectiveId, $userWorkspaces)
|
||||
return root ? findWorkspaceDescendants(root.id, $userWorkspaces).length : 0
|
||||
})
|
||||
const rootLabel = $derived(`${forkCount} fork${forkCount === 1 ? '' : 's'}`)
|
||||
|
||||
// Settings link at the bottom of the picker — admin/superadmin only, scoped
|
||||
// to the active workspace (fork or root).
|
||||
const canManageWorkspace = $derived($userStore?.is_admin || $superadmin)
|
||||
const settingsHref = $derived(canManageWorkspace ? `${base}/workspace_settings` : undefined)
|
||||
const settingsLabel = $derived(`${currentWs?.name ?? effectiveId ?? 'Workspace'} settings`)
|
||||
|
||||
function switchWorkspaceDirect(id: string) {
|
||||
if ($workspaceStore === id) return
|
||||
workspaceAIClients.init(id)
|
||||
switchWorkspace(id)
|
||||
void fixupUrlAfterWorkspaceSwitch(id)
|
||||
}
|
||||
|
||||
function openForkModal() {
|
||||
globalForkModal.val = { opened: true }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center min-w-0 px-2 {isCollapsed ? 'justify-center' : ''} py-0.5 rounded-md">
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={effectiveId}
|
||||
onPick={switchWorkspaceDirect}
|
||||
onRequestCreateFork={openForkModal}
|
||||
{settingsHref}
|
||||
{settingsLabel}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={effectiveId}
|
||||
{isCollapsed}
|
||||
{rootLabel}
|
||||
wrap
|
||||
class="w-full"
|
||||
/>
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
</div>
|
||||
@@ -384,4 +384,22 @@ const changelogs: Changelog[] = [
|
||||
}
|
||||
]
|
||||
|
||||
export { changelogs }
|
||||
export { changelogs }
|
||||
// Single owner of the "which changelogs are new" localStorage key — every
|
||||
// menu surfacing changelogs must read/stamp through these, or two surfaces
|
||||
// with independent state would fight over the same key.
|
||||
const LAST_OPENED_KEY = 'changelogsLastOpened'
|
||||
|
||||
export function readRecentChangelogs(): { recent: Changelog[]; hasNew: boolean } {
|
||||
const lastOpened = localStorage.getItem(LAST_OPENED_KEY)
|
||||
const recent = lastOpened
|
||||
? changelogs.filter((changelog) => changelog.date > lastOpened)
|
||||
: changelogs.slice(0, 3)
|
||||
const hasNew =
|
||||
lastOpened != null && recent.length > 0 && lastOpened !== new Date().toISOString().split('T')[0]
|
||||
return { recent, hasNew }
|
||||
}
|
||||
|
||||
export function markChangelogsOpened(): void {
|
||||
localStorage.setItem(LAST_OPENED_KEY, new Date().toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearStores } from '$lib/storeUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
// Leave the active workspace and land on the workspace list. Shared by the
|
||||
// settings dropdown and the sidebar menu.
|
||||
export async function leaveCurrentWorkspace(): Promise<void> {
|
||||
await WorkspaceService.leaveWorkspace({ workspace: get(workspaceStore) ?? '' })
|
||||
sendUserToast('You left the workspace')
|
||||
clearStores()
|
||||
await goto('/user/workspaces')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Sidebar rail background. Deliberately raw hex — no theme token exists for
|
||||
// the rail chrome — kept here as the single source for every host that renders
|
||||
// or mimics the sidebar (app layout, kitchen-sink harness).
|
||||
export const SIDEBAR_BG = '#F3F3F7'
|
||||
export const SIDEBAR_BG_DARK = '#1e232e'
|
||||
@@ -2,38 +2,35 @@ import { describe, it, expect } from 'vitest'
|
||||
import { workspaceMenuHref } from './workspaceMenuHref'
|
||||
|
||||
describe('workspaceMenuHref', () => {
|
||||
it('on a session route, keeps the workspace id (so new-tab lands in the right workspace)', () => {
|
||||
it('keeps the open session for a same-family target (new-tab stays on the chat)', () => {
|
||||
expect(
|
||||
workspaceMenuHref({
|
||||
routeId: '/(root)/(logged)/sessions',
|
||||
base: '',
|
||||
pathname: '/sessions',
|
||||
searchParams: new URLSearchParams('session_name=foo'),
|
||||
id: 'wm-fork-bar'
|
||||
id: 'wm-fork-bar',
|
||||
sameFamily: true
|
||||
})
|
||||
).toBe('/?workspace=wm-fork-bar')
|
||||
).toBe('/sessions?session_name=foo&workspace=wm-fork-bar')
|
||||
})
|
||||
|
||||
it('respects the base prefix on a session route', () => {
|
||||
it('drops the open session for a cross-family target', () => {
|
||||
expect(
|
||||
workspaceMenuHref({
|
||||
routeId: '/(root)/(logged)/sessions',
|
||||
base: '/wm',
|
||||
pathname: '/wm/sessions',
|
||||
searchParams: new URLSearchParams(),
|
||||
id: 'ws2'
|
||||
pathname: '/sessions',
|
||||
searchParams: new URLSearchParams('session_name=foo'),
|
||||
id: 'other-root',
|
||||
sameFamily: false
|
||||
})
|
||||
).toBe('/wm/?workspace=ws2')
|
||||
).toBe('/sessions?workspace=other-root')
|
||||
})
|
||||
|
||||
it('off a session route, swaps the workspace param on the current path', () => {
|
||||
it('swaps the workspace param on the current path', () => {
|
||||
expect(
|
||||
workspaceMenuHref({
|
||||
routeId: '/(root)/(logged)/scripts/edit/[...path]',
|
||||
base: '',
|
||||
pathname: '/scripts/edit/u/me/x',
|
||||
searchParams: new URLSearchParams('workspace=old&foo=1'),
|
||||
id: 'new_ws'
|
||||
id: 'new_ws',
|
||||
sameFamily: false
|
||||
})
|
||||
).toBe('/scripts/edit/u/me/x?workspace=new_ws&foo=1')
|
||||
})
|
||||
@@ -41,11 +38,10 @@ describe('workspaceMenuHref', () => {
|
||||
it('adds the workspace param when none was present', () => {
|
||||
expect(
|
||||
workspaceMenuHref({
|
||||
routeId: '/(root)/(logged)/runs',
|
||||
base: '',
|
||||
pathname: '/runs',
|
||||
searchParams: new URLSearchParams(),
|
||||
id: 'w'
|
||||
id: 'w',
|
||||
sameFamily: true
|
||||
})
|
||||
).toBe('/runs?workspace=w')
|
||||
})
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// Href for a workspace-switch link in the sidebar WorkspaceMenu.
|
||||
//
|
||||
// On an AI-session route, switching workspace leaves for home — but we keep the
|
||||
// `?workspace=<id>` param so a modifier/middle click (open in new tab, which
|
||||
// bypasses the onClick fast-path) still lands in the *clicked* workspace's home
|
||||
// rather than the default one. Everywhere else, stay on the current path and
|
||||
// just swap the `workspace` query param.
|
||||
// Href for a workspace-switch link in the sidebar WorkspaceMenu: stay on the
|
||||
// current path and just swap the `workspace` query param, so a modifier/middle
|
||||
// click (open in new tab, which bypasses the onClick fast-path) lands on the
|
||||
// same page — session mode included — in the *clicked* workspace. A session
|
||||
// named in the URL is kept only for same-family targets: a cross-family tab
|
||||
// must not open a foreign family's chat, so it lands on the sessions page with
|
||||
// nothing selected instead.
|
||||
export function workspaceMenuHref(args: {
|
||||
routeId: string | null | undefined
|
||||
base: string
|
||||
pathname: string
|
||||
searchParams: URLSearchParams
|
||||
id: string
|
||||
// Whether `id` belongs to the same workspace family as the active workspace.
|
||||
sameFamily?: boolean
|
||||
}): string {
|
||||
if (args.routeId?.includes('/sessions')) {
|
||||
return `${args.base}/?workspace=${args.id}`
|
||||
}
|
||||
const params = new URLSearchParams(args.searchParams)
|
||||
params.set('workspace', args.id)
|
||||
if (!args.sameFamily) {
|
||||
params.delete('session_name')
|
||||
}
|
||||
return `${args.pathname}?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { page } from '$app/state'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
|
||||
// Post-workspace-switch URL fixup shared by the sidebar workspace pickers.
|
||||
// Item-scoped pages would show a wrong-workspace (or missing) item after a
|
||||
// switch — go home instead. Otherwise, really rewrite a ?workspace= param in
|
||||
// the address bar (mutating page.url is a no-op there): left stale, it gets
|
||||
// re-applied on reload or when exiting session mode restores the route,
|
||||
// silently switching the workspace back.
|
||||
const EDIT_PAGES = [
|
||||
'/scripts/edit/',
|
||||
'/flows/edit/',
|
||||
'/apps/edit/',
|
||||
'/apps_raw/edit/',
|
||||
'/scripts/get/',
|
||||
'/flows/get/',
|
||||
'/apps/get/',
|
||||
'/apps_raw/get/'
|
||||
]
|
||||
|
||||
export async function fixupUrlAfterWorkspaceSwitch(id: string): Promise<void> {
|
||||
if (EDIT_PAGES.some((p) => page.route.id?.includes(p) ?? false)) {
|
||||
await goto('/')
|
||||
} else if (page.url.searchParams.get('workspace')) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('workspace', id)
|
||||
replaceState(url, page.state)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { reconcileAfterWorkspaceChange } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { pluralize } from '$lib/utils'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import WorkspaceIcon from './WorkspaceIcon.svelte'
|
||||
import WorkspaceCard from './WorkspaceCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -52,6 +53,9 @@
|
||||
|
||||
const paddingLeft = untrack(() => depth) * 24
|
||||
const isSelected = $derived(selectedWorkspaceId === workspace.id)
|
||||
// Colored forks render icon + name in the derived fork accent (the fork
|
||||
// picker convention); the icon side is handled inside WorkspaceIcon.
|
||||
const forkAccent = $derived(isForked ? forkAccentStyle(workspace.color) : undefined)
|
||||
|
||||
// The canonical dev workspace sorts before throwaway forks.
|
||||
const sortedChildren = $derived(
|
||||
@@ -129,7 +133,12 @@
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-semibold text-primary truncate">
|
||||
<span
|
||||
class="text-xs font-semibold truncate {forkAccent
|
||||
? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]'
|
||||
: 'text-primary'}"
|
||||
style={forkAccent}
|
||||
>
|
||||
{#if workspace.marked}
|
||||
{@html workspace.marked}
|
||||
{:else}
|
||||
@@ -137,7 +146,12 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if workspace.is_dev_workspace}
|
||||
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
|
||||
<Badge
|
||||
color="dark-blue"
|
||||
small
|
||||
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
|
||||
>{devBadgeText(workspace.dev_workspace_label)}</Badge
|
||||
>
|
||||
{/if}
|
||||
<span class="text-secondary text-xs">-</span>
|
||||
{#if workspace.id === 'admins'}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Building, GitFork } from 'lucide-svelte'
|
||||
import { Tooltip } from '$lib/components/meltComponents'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
|
||||
interface Props {
|
||||
@@ -11,6 +12,9 @@
|
||||
devWorkspaceLabel?: string | null
|
||||
parentName?: string
|
||||
size?: number
|
||||
// Tailwind padding class for the round badge — shrink it (e.g. 'p-0.5') for
|
||||
// a more compact icon.
|
||||
padding?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -19,13 +23,22 @@
|
||||
isDevWorkspace = false,
|
||||
devWorkspaceLabel,
|
||||
parentName,
|
||||
size = 14
|
||||
size = 14,
|
||||
padding = 'p-1.5'
|
||||
}: Props = $props()
|
||||
|
||||
const iconColor = $derived(getContrastTextColor(workspaceColor))
|
||||
// A colored FORK tints the fork icon with the derived accent (the fork
|
||||
// picker convention) instead of a raw-color disc; roots keep the filled
|
||||
// disc with a contrast-picked icon color.
|
||||
const accentStyle = $derived(isForked ? forkAccentStyle(workspaceColor) : undefined)
|
||||
const iconColor = $derived(isForked ? undefined : getContrastTextColor(workspaceColor))
|
||||
</script>
|
||||
|
||||
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
|
||||
<div
|
||||
style={accentStyle ??
|
||||
(!isForked && workspaceColor ? `background-color: ${workspaceColor}` : undefined)}
|
||||
class="rounded-full {padding} center-center"
|
||||
>
|
||||
{#if isForked}
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
@@ -34,9 +47,20 @@
|
||||
{parentName}
|
||||
{/if}
|
||||
{/snippet}
|
||||
<GitFork {size} class="flex-shrink-0" style="color: {iconColor}" />
|
||||
<!-- Explicit neutral fallback: rows are often <a> elements, and an
|
||||
icon left on currentColor would inherit the global link blue. -->
|
||||
<GitFork
|
||||
{size}
|
||||
class="flex-shrink-0 {accentStyle
|
||||
? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]'
|
||||
: 'text-tertiary'}"
|
||||
/>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<Building {size} style="color: {iconColor}" />
|
||||
<Building
|
||||
{size}
|
||||
class={iconColor ? '' : 'text-tertiary'}
|
||||
style={iconColor ? `color: ${iconColor}` : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
import { logoutWithRedirect } from '$lib/logoutKit'
|
||||
import { page } from '$app/state'
|
||||
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import {
|
||||
workspaceIsFork,
|
||||
findWorkspaceRoot,
|
||||
findWorkspaceDescendants
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { resource } from 'runed'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
@@ -39,14 +43,21 @@
|
||||
import { base } from '$lib/base'
|
||||
import Label from '../Label.svelte'
|
||||
import ForkDatatableSection from './ForkDatatableSection.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import WorkspaceScopeTrigger from '../WorkspaceScopeTrigger.svelte'
|
||||
import DarkModeToggle from '../sidebar/DarkModeToggle.svelte'
|
||||
import ForkDucklakeSection from './ForkDucklakeSection.svelte'
|
||||
|
||||
interface Props {
|
||||
isFork?: boolean
|
||||
// Rendered inside the global fork modal rather than the standalone
|
||||
// /user/create_workspace page: content-driven height and no
|
||||
// "Back to workspaces" navigation.
|
||||
inModal?: boolean
|
||||
onFinish?: () => void
|
||||
}
|
||||
|
||||
let { isFork = false, onFinish }: Props = $props()
|
||||
let { isFork = false, inModal = false, onFinish }: Props = $props()
|
||||
|
||||
// Dev-workspace mode: create the fork as a persistent, prefix-less dev workspace and (optionally)
|
||||
// lock the parent ("prod") against direct edits.
|
||||
@@ -59,6 +70,39 @@
|
||||
$effect(() => {
|
||||
copyMembers = createAsDevWorkspace
|
||||
})
|
||||
|
||||
// A dev workspace can only be created off a root base (backend rejects a dev of a fork). Clear a
|
||||
// stale toggle when the base no longer qualifies so we never submit is_dev_workspace against a fork.
|
||||
$effect(() => {
|
||||
if (!canDesignateDevWorkspace && createAsDevWorkspace) {
|
||||
createAsDevWorkspace = false
|
||||
}
|
||||
})
|
||||
|
||||
// The base workspace to fork from. A fork's git branch is based on its parent's branch, so picking
|
||||
// a fork here (rather than the root) yields a fork of a fork.
|
||||
let baseWorkspaceId = $state<string | undefined>(undefined)
|
||||
|
||||
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
|
||||
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, $userWorkspaces))
|
||||
let baseCandidates = $derived(
|
||||
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, $userWorkspaces)] : []
|
||||
)
|
||||
let baseItems = $derived(
|
||||
baseCandidates.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.id === familyRoot?.id ? `${w.name} (root)` : w.name,
|
||||
subtitle: w.is_dev_workspace ? 'dev workspace' : w.id === familyRoot?.id ? undefined : 'fork'
|
||||
}))
|
||||
)
|
||||
let defaultBaseWorkspaceId = $derived(familyRoot?.id)
|
||||
// Seed the base once the family is known; keep an explicit user choice as long as it stays valid.
|
||||
$effect(() => {
|
||||
if (!isFork) return
|
||||
if (baseWorkspaceId && baseCandidates.some((w) => w.id === baseWorkspaceId)) return
|
||||
baseWorkspaceId = defaultBaseWorkspaceId
|
||||
})
|
||||
|
||||
// Cosmetic display label for the new dev workspace: 'dev' | 'staging'. Purely visual (badge text +
|
||||
// wording); reset when the dev toggle is turned off.
|
||||
let devWorkspaceLabel = $state<'dev' | 'staging'>('dev')
|
||||
@@ -68,25 +112,25 @@
|
||||
|
||||
// The dev-workspace option is only offered when forking a root workspace that doesn't already
|
||||
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
|
||||
let currentWorkspaceEntry = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
// Require the current workspace to be loaded before treating it as a root: a missing entry must
|
||||
let baseWorkspaceEntry = $derived($userWorkspaces.find((w) => w.id === baseWorkspaceId))
|
||||
// Require the base workspace to be loaded before treating it as a root: a missing entry must
|
||||
// not read as root (it would offer invalid dev creation while the workspace list is still loading).
|
||||
// `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent
|
||||
// FK was set null — it has no parent but is still a fork, so it can't host a dev workspace.
|
||||
let currentIsRoot = $derived(
|
||||
!!currentWorkspaceEntry && !workspaceIsFork($workspaceStore, $userWorkspaces)
|
||||
!!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, $userWorkspaces)
|
||||
)
|
||||
// Ask the server whether a dev already exists: the caller may not be a member of this prod's dev,
|
||||
// so the client workspace list can't see it and would offer an invalid "create dev" action.
|
||||
const devWorkspaceResource = resource(
|
||||
() => (currentIsRoot ? $workspaceStore : undefined),
|
||||
() => (currentIsRoot ? baseWorkspaceId : undefined),
|
||||
async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined)
|
||||
)
|
||||
// Offer dev designation only once the server confirms there's no dev yet (returns null); stay
|
||||
// conservative (no offer) while the check is loading (current is undefined).
|
||||
let canDesignateDevWorkspace = $derived(currentIsRoot && devWorkspaceResource.current === null)
|
||||
let currentWorkspaceName = $derived(
|
||||
currentWorkspaceEntry?.name ?? $workspaceStore ?? 'the root workspace'
|
||||
baseWorkspaceEntry?.name ?? baseWorkspaceId ?? 'the root workspace'
|
||||
)
|
||||
|
||||
let id = $state('')
|
||||
@@ -130,6 +174,9 @@
|
||||
: 'ID already exists'
|
||||
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
|
||||
errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
} else if (effectiveId.length > 50) {
|
||||
// `wm-fork-` prefix included: matches the backend's 50-char (git-branch / DB) limit.
|
||||
errorId = `ID '${effectiveId}' is too long (${effectiveId.length} chars). Maximum is 50.`
|
||||
} else {
|
||||
errorId = ''
|
||||
}
|
||||
@@ -179,7 +226,7 @@
|
||||
for (const job of jobs) {
|
||||
let j = await JobService.getCompletedJob({
|
||||
id: job,
|
||||
workspace: $workspaceStore!
|
||||
workspace: baseWorkspaceId!
|
||||
})
|
||||
ret.push(j)
|
||||
}
|
||||
@@ -215,7 +262,7 @@
|
||||
}
|
||||
|
||||
async function forkWorkspace(prefixed_id: string): Promise<void> {
|
||||
if ($workspaceStore) {
|
||||
if (baseWorkspaceId) {
|
||||
forkCreationLoading = true
|
||||
errorMsgs = []
|
||||
failedSyncJobs = []
|
||||
@@ -240,7 +287,7 @@
|
||||
let gitSyncJobIds: string[]
|
||||
try {
|
||||
gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: baseWorkspaceId!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
@@ -268,7 +315,7 @@
|
||||
await Promise.all(
|
||||
gitSyncJobIds.map((jobId) =>
|
||||
jobManager.runWithProgress(() => Promise.resolve(jobId), {
|
||||
workspace: $workspaceStore!,
|
||||
workspace: baseWorkspaceId!,
|
||||
timeout: 60000,
|
||||
timeoutMessage: `Deploy fork job timed out after 60s`,
|
||||
onProgress: (status) => {
|
||||
@@ -283,7 +330,7 @@
|
||||
} catch (error) {
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs} - ${error}`,
|
||||
`Could not fork workspace ${baseWorkspaceId} because branch creation failed: ${errorMsgs} - ${error}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
@@ -292,7 +339,7 @@
|
||||
forkCreationError = 'Failed to create a branch for this fork on the git sync repo(s)'
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs}`,
|
||||
`Could not fork workspace ${baseWorkspaceId} because branch creation failed: ${errorMsgs}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
@@ -308,7 +355,7 @@
|
||||
|
||||
try {
|
||||
await WorkspaceService.createWorkspaceFork({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: baseWorkspaceId!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
@@ -333,8 +380,8 @@
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
createAsDevWorkspace
|
||||
? `Created ${devWorkspaceLabel === 'staging' ? 'staging' : 'dev'} workspace ${effectiveForkId} for ${$workspaceStore}`
|
||||
: `Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`
|
||||
? `Created ${devWorkspaceLabel === 'staging' ? 'staging' : 'dev'} workspace ${effectiveForkId} for ${baseWorkspaceId}`
|
||||
: `Successfully forked workspace ${baseWorkspaceId} as: wm-fork-${id}`
|
||||
)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
@@ -474,7 +521,12 @@
|
||||
let autoAdd = $state(true)
|
||||
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
|
||||
run(() => {
|
||||
id = name.toLowerCase().replace(/\s/gi, '-')
|
||||
if (isFork) {
|
||||
// Forks have no separate display name — the unique id is the name.
|
||||
name = id
|
||||
} else {
|
||||
id = name.toLowerCase().replace(/\s/gi, '-')
|
||||
}
|
||||
})
|
||||
// When creating a dev workspace, prefill the fork name with `<root>-dev` / `<root>-stg` (the effect
|
||||
// above slugifies it into the id). Only fill an empty field or one still holding a prior suggestion,
|
||||
@@ -503,9 +555,16 @@
|
||||
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex-1 relative min-h-[32rem]">
|
||||
<div class="flex flex-col gap-8 absolute inset-0 overflow-y-auto">
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Standalone page: fixed 32rem scroll area (absolute inset). In the modal the
|
||||
form flows naturally so the adaptive modal hugs the content, scrolling only
|
||||
when capped by the viewport. -->
|
||||
<div class={inModal ? 'flex-1 min-h-0 overflow-y-auto' : 'flex-1 relative min-h-[32rem]'}>
|
||||
<div
|
||||
class={inModal
|
||||
? 'flex flex-col gap-8'
|
||||
: 'flex flex-col gap-8 absolute inset-0 overflow-y-auto'}
|
||||
>
|
||||
{#if errorMsgs.length != 0}
|
||||
<Alert class="p-2" title={forkCreationError} type="error">
|
||||
<ul class="pl-2 pr-4 break-words pb-5">
|
||||
@@ -525,7 +584,7 @@
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${job.id}?workspace=${$workspaceStore}`}
|
||||
href={`/run/${job.id}?workspace=${baseWorkspaceId}`}
|
||||
>
|
||||
{job.id}
|
||||
</a>
|
||||
@@ -550,7 +609,7 @@
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${jobId}?workspace=${$workspaceStore}`}
|
||||
href={`/run/${jobId}?workspace=${baseWorkspaceId}`}
|
||||
>
|
||||
{jobId}
|
||||
</a>
|
||||
@@ -561,34 +620,32 @@
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1">
|
||||
{#if isFork}
|
||||
<span class="text-xs font-semibold text-emphasis">Fork name</span>
|
||||
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
|
||||
{:else}
|
||||
{#if !isFork}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
||||
<span class="text-xs text-secondary">Displayable name</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
||||
</label>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
||||
{#if isFork}
|
||||
<span class="text-xs font-semibold text-emphasis">Fork ID</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>Slug to uniquely identify your fork (this will also set the branch name)</span
|
||||
>Unique name of your fork (this will also set the branch name)</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
||||
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
||||
{/if}
|
||||
|
||||
{#if isFork && !createAsDevWorkspace}
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="example.com"
|
||||
class={errorId != '' ? 'input-error' : ''}
|
||||
placeholder="my-fork"
|
||||
autofocus
|
||||
error={errorId != ''}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput bind:value={id} error={errorId} />
|
||||
@@ -613,6 +670,64 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</label>
|
||||
<Label label={isFork ? 'Fork color' : 'Workspace color'}>
|
||||
{#snippet header()}
|
||||
<span class="text-2xs text-secondary">(optional)</span>
|
||||
{/snippet}
|
||||
<span class="text-xs text-secondary">
|
||||
Color to identify the current workspace in the list of workspaces
|
||||
</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
<div class="flex items-center gap-1 grow">
|
||||
<input
|
||||
class="grow min-w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
class="w-24"
|
||||
bind:value={workspaceColor}
|
||||
inputProps={{ disabled: !colorEnabled }}
|
||||
/>
|
||||
<Button
|
||||
on:click={generateRandomColor}
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!colorEnabled}>Random</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isFork && colorEnabled}
|
||||
<div class="flex flex-col gap-1 pt-2">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span class="text-2xs text-secondary">Fork picker preview</span>
|
||||
<!-- Toggles the real app theme: a preview-local .dark scope can't
|
||||
re-theme the modal (theme vars are bound to html.dark), and the
|
||||
actual theme is what the chip must be judged against anyway. -->
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="w-64">
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={baseWorkspaceId}
|
||||
pendingFork={{
|
||||
id: effectiveForkId,
|
||||
name: id || 'my-fork',
|
||||
parent_workspace_id: baseWorkspaceId ?? ''
|
||||
}}
|
||||
color={workspaceColor}
|
||||
showChevron={false}
|
||||
wrap
|
||||
class="w-full pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Label>
|
||||
{#if isFork && canDesignateDevWorkspace}
|
||||
<Label label="Persistent dev workspace">
|
||||
<span class="text-xs text-secondary">
|
||||
@@ -665,39 +780,32 @@
|
||||
</div>
|
||||
</Label>
|
||||
{/if}
|
||||
<Label label="Workspace color">
|
||||
<span class="text-xs text-secondary">
|
||||
Color to identify the current workspace in the list of workspaces
|
||||
</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
<div class="flex items-center gap-1 grow">
|
||||
<input
|
||||
class="grow min-w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
class="w-24"
|
||||
bind:value={workspaceColor}
|
||||
inputProps={{ disabled: !colorEnabled }}
|
||||
/>
|
||||
<Button
|
||||
on:click={generateRandomColor}
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!colorEnabled}>Random</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Label>
|
||||
{#if isFork}
|
||||
<label class="flex flex-col gap-1">
|
||||
<div class="flex flex-row gap-2 items-baseline">
|
||||
<span class="text-xs font-semibold text-emphasis">Base workspace</span>
|
||||
<span class="text-2xs text-secondary">(optional)</span>
|
||||
</div>
|
||||
<span class="text-xs text-secondary">
|
||||
{#if createAsDevWorkspace}
|
||||
A dev workspace is always based on the root workspace.
|
||||
{:else}
|
||||
Workspace to fork from. Defaults to the root; pick an existing fork to create a fork
|
||||
of a fork (the new branch is based on the selected workspace's branch).
|
||||
{/if}
|
||||
</span>
|
||||
<Select
|
||||
items={baseItems}
|
||||
bind:value={baseWorkspaceId}
|
||||
placeholder="Select a base workspace"
|
||||
disabled={createAsDevWorkspace}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{#if isFork}
|
||||
<ForkDatatableSection
|
||||
bind:this={forkDatatableSection}
|
||||
sourceWorkspace={baseWorkspaceId}
|
||||
onAllDone={() => {
|
||||
completeFork(effectiveForkId)
|
||||
}}
|
||||
@@ -705,7 +813,7 @@
|
||||
forkCreationLoading = false
|
||||
}}
|
||||
/>
|
||||
<ForkDucklakeSection bind:this={forkDucklakeSection} />
|
||||
<ForkDucklakeSection bind:this={forkDucklakeSection} sourceWorkspace={baseWorkspaceId} />
|
||||
{/if}
|
||||
{#if !automateUsernameCreation}
|
||||
<Label label="Your username in that workspace">
|
||||
@@ -832,10 +940,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button disabled={forkCreationLoading} variant="default" size="sm" href="{base}/user/workspaces"
|
||||
>← Back to workspaces</Button
|
||||
>
|
||||
<div class="flex flex-wrap flex-row {inModal ? 'justify-end' : 'justify-between'} gap-4 pt-4">
|
||||
{#if !inModal}
|
||||
<Button
|
||||
disabled={forkCreationLoading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
href="{base}/user/workspaces">← Back to workspaces</Button
|
||||
>
|
||||
{/if}
|
||||
{#if !forkCreationLoading}
|
||||
<Button
|
||||
variant="accent"
|
||||
|
||||
@@ -30,16 +30,20 @@
|
||||
import { Check, X, Loader2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
// Workspace whose datatables are cloned into the fork (the fork's base). Falls back to the
|
||||
// current workspace so existing callers keep working.
|
||||
sourceWorkspace?: string
|
||||
onAllDone?: () => void
|
||||
onCanceled?: () => void
|
||||
}
|
||||
|
||||
let { onAllDone, onCanceled }: Props = $props()
|
||||
let { sourceWorkspace, onAllDone, onCanceled }: Props = $props()
|
||||
|
||||
let allDatatables = resource([], async () =>
|
||||
$workspaceStore
|
||||
? WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
|
||||
: undefined
|
||||
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
|
||||
|
||||
let allDatatables = resource(
|
||||
() => effectiveSource,
|
||||
async (ws) => (ws ? WorkspaceService.listDataTables({ workspace: ws }) : undefined)
|
||||
)
|
||||
|
||||
let datatableBehaviors: Record<string, 'schema_only' | 'schema_and_data' | 'keep_original'> =
|
||||
@@ -83,7 +87,7 @@
|
||||
steps,
|
||||
_newDbName: newDbName,
|
||||
_isInstance: isInstance,
|
||||
_sourceWorkspace: $workspaceStore!,
|
||||
_sourceWorkspace: effectiveSource!,
|
||||
_targetWorkspace: targetWorkspaceId,
|
||||
_resourcePath: dt.resource_path
|
||||
}
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
import Label from '../Label.svelte'
|
||||
import { TriangleAlert } from 'lucide-svelte'
|
||||
|
||||
// Keyed on the store so a direct page load (store hydrates after mount) still fetches.
|
||||
// The workspace the fork is created from (matches ForkDatatableSection): a
|
||||
// fork-of-fork bases off the selected base, not the navigation workspace, so
|
||||
// the lakes listed here must match the ones the create-fork call submits to.
|
||||
let { sourceWorkspace }: { sourceWorkspace?: string } = $props()
|
||||
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
|
||||
|
||||
// Keyed on the source so a direct page load (store hydrates after mount) still fetches.
|
||||
let allDucklakes = resource(
|
||||
() => $workspaceStore,
|
||||
() => effectiveSource,
|
||||
async (ws) => (ws ? WorkspaceService.listDucklakes({ workspace: ws }) : undefined)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Derives the fork-chip accent palette from a workspace color: the default
|
||||
// blue's light/dark bg+text profile, re-hued. Lightness is fixed per role so
|
||||
// any picked hue stays readable; saturation follows the user color within a
|
||||
// safe band so grayish picks yield grayish chips.
|
||||
|
||||
function hexToHsl(hex: string): { h: number; s: number; l: number } | undefined {
|
||||
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
|
||||
if (!m) return undefined
|
||||
let c = m[1]
|
||||
if (c.length === 3) c = [...c].map((x) => x + x).join('')
|
||||
const r = parseInt(c.slice(0, 2), 16) / 255
|
||||
const g = parseInt(c.slice(2, 4), 16) / 255
|
||||
const b = parseInt(c.slice(4, 6), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const l = (max + min) / 2
|
||||
const d = max - min
|
||||
if (d === 0) return { h: 0, s: 0, l }
|
||||
const s = d / (1 - Math.abs(2 * l - 1))
|
||||
let h: number
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60
|
||||
else if (max === g) h = ((b - r) / d + 2) * 60
|
||||
else h = ((r - g) / d + 4) * 60
|
||||
return { h, s, l }
|
||||
}
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, x))
|
||||
}
|
||||
|
||||
function hsl(h: number, s: number, l: number): string {
|
||||
return `hsl(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-style string setting the four `--fork-accent-*` custom properties a
|
||||
* colored fork chip consumes (see WorkspaceScopeTrigger). Returns undefined
|
||||
* for an unparsable color, letting the chip fall back to the default accent.
|
||||
*/
|
||||
export function forkAccentStyle(color: string | undefined): string | undefined {
|
||||
if (!color) return undefined
|
||||
const parsed = hexToHsl(color)
|
||||
if (!parsed) return undefined
|
||||
const { h, s } = parsed
|
||||
const lightBg = hsl(h, clamp(s, 0.3, 1), 0.955)
|
||||
const lightText = hsl(h, clamp(s, 0.3, 0.8), 0.42)
|
||||
const darkBg = hsl(h, clamp(s * 0.35, 0.08, 0.25), 0.26)
|
||||
const darkText = hsl(h, clamp(s, 0.3, 0.9), 0.86)
|
||||
return `--fork-accent-bg: ${lightBg}; --fork-accent-text: ${lightText}; --fork-accent-bg-dark: ${darkBg}; --fork-accent-text-dark: ${darkText};`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fly, type TransitionConfig } from 'svelte/transition'
|
||||
|
||||
/**
|
||||
* Fly a melt-positioned floating element in from its anchor. Melt stamps the
|
||||
* resolved side (after flip/fitViewport) on `data-side`, but only once its
|
||||
* async computePosition settles — after the intro's config is read. So the
|
||||
* caller passes its requested `placement` as the intro fallback, and
|
||||
* `data-side` (read via a deferred config) takes over when present, i.e. on
|
||||
* the outro and whenever positioning already settled. Missing both falls back
|
||||
* to the common case (opens below → slides down from the trigger).
|
||||
*/
|
||||
export function placementFly(
|
||||
node: Element,
|
||||
{
|
||||
duration = 100,
|
||||
distance = 16,
|
||||
placement
|
||||
}: { duration?: number; distance?: number; placement?: string } = {}
|
||||
): () => TransitionConfig {
|
||||
return () => {
|
||||
const side = node.getAttribute('data-side') ?? placement?.split('-')[0] ?? 'bottom'
|
||||
const x = side === 'left' ? distance : side === 'right' ? -distance : 0
|
||||
const y = side === 'top' ? distance : side === 'left' || side === 'right' ? 0 : -distance
|
||||
return fly(node, { duration, x, y })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* svelte-splitpanes tracks drags with document-level mousemove/mouseup only.
|
||||
* A button release outside the browser window never reaches the document, so
|
||||
* the drag state sticks: global col-resize cursor and pointer-events:none on
|
||||
* every pane until the next click. Capturing the pointer on the splitter at
|
||||
* pointerdown makes the browser deliver the release (and its compatibility
|
||||
* mouseup, which bubbles to the library's document listener) even when it
|
||||
* happens outside the window. Attach to any ancestor of the Splitpanes.
|
||||
*/
|
||||
export function splitterPointerCapture(node: HTMLElement) {
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const splitter = (e.target as Element | null)?.closest?.('.splitpanes__splitter')
|
||||
if (splitter instanceof HTMLElement) {
|
||||
try {
|
||||
splitter.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// Non-capturable pointer (already released) — the plain document
|
||||
// listeners still handle the in-window case.
|
||||
}
|
||||
}
|
||||
}
|
||||
node.addEventListener('pointerdown', onPointerDown, true)
|
||||
return { destroy: () => node.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}
|
||||
@@ -104,6 +104,25 @@ export function isRootWorkspace(workspace: UserWorkspace): boolean {
|
||||
return workspace.parent_workspace_id == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up `parent_workspace_id` to the top of a workspace's family. Stops at the first ancestor not
|
||||
* present in `allWorkspaces` (e.g. a parent the user can't see) and returns it, so the result is
|
||||
* always the highest reachable ancestor. Returns undefined when the id itself isn't in the list.
|
||||
*/
|
||||
export function findWorkspaceRoot(
|
||||
workspaceId: string | undefined,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): UserWorkspace | undefined {
|
||||
if (!workspaceId) return undefined
|
||||
let current = allWorkspaces.find((w) => w.id === workspaceId)
|
||||
while (current?.parent_workspace_id) {
|
||||
const parent = allWorkspaces.find((w) => w.id === current!.parent_workspace_id)
|
||||
if (!parent) break
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a workspace (by id) is a fork or dev workspace. Forks and dev workspaces both set
|
||||
* `parent_workspace_id` (a dev workspace has no `wm-fork-` id prefix), but a `wm-fork-` workspace can
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
|
||||
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
|
||||
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
|
||||
import SidebarScrollArea from '$lib/components/sidebar/SidebarScrollArea.svelte'
|
||||
import { SIDEBAR_BG, SIDEBAR_BG_DARK } from '$lib/components/sidebar/sidebarChrome'
|
||||
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
|
||||
import ForkConflictModal from '$lib/components/ForkConflictModal.svelte'
|
||||
import {
|
||||
@@ -50,23 +54,31 @@
|
||||
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { syncTutorialsTodos } from '$lib/tutorialUtils'
|
||||
import { ArrowLeft, Search, WandSparkles } from 'lucide-svelte'
|
||||
import { PanelLeftClose, PanelLeftOpen, Home, Play, Search, WandSparkles } from 'lucide-svelte'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import MenuLink from '$lib/components/sidebar/MenuLink.svelte'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { purgeLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
|
||||
import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration'
|
||||
import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { onDestroy, setContext, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte'
|
||||
import SessionPicker from '$lib/components/sessions/SessionPicker.svelte'
|
||||
import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { parsePreviewItemRoute } from '$lib/components/sessions/previewRouter'
|
||||
import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import { sessionState } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { currentWorkspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
|
||||
import WorkspaceScopeHeader from '$lib/components/sidebar/WorkspaceScopeHeader.svelte'
|
||||
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
|
||||
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
|
||||
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
|
||||
@@ -86,16 +98,129 @@
|
||||
let { children }: Props = $props()
|
||||
OpenAPI.WITH_CREDENTIALS = true
|
||||
let menuOpen = $state(false)
|
||||
// Set by the workspace⇄session switch before it navigates, so the mobile menu
|
||||
// drawer stays open across a mode toggle (unlike a normal link navigation,
|
||||
// which dismisses it). Consumed once in beforeNavigate.
|
||||
let preserveMenuOnNextNav = false
|
||||
let globalSearchModal: GlobalSearchModal | undefined = $state(undefined)
|
||||
let isCollapsed = $state(false)
|
||||
// Persisted nav-rail collapse preference. A deliberate collapse writes to it —
|
||||
// the manual toggle and a drag past the collapse threshold. The contextual
|
||||
// auto-collapse (app-mode routes, narrow widths) mutates the in-memory
|
||||
// `isCollapsed` without persisting, so it stays transient and never gets
|
||||
// "stuck" collapsed across reloads.
|
||||
const collapsePref = useLocalStorageValue<boolean>('nav_menu_collapsed', false, 'boolean')
|
||||
let isCollapsed = $state(collapsePref.val)
|
||||
|
||||
// Resizable desktop rail, sized in REM so it scales with the root font-size the
|
||||
// same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px past 1760px
|
||||
// wide (app.css), which grows the rem-based button content; a fixed-px rail would
|
||||
// not grow with it and the content would overflow. SIDEBAR_MIN_REM is the default
|
||||
// expanded width (the old w-52); the handle only resizes when expanded and only
|
||||
// widens from there — collapsing is the toggle button's job, not the drag's.
|
||||
const SIDEBAR_MIN_REM = 13
|
||||
const SIDEBAR_COLLAPSED_REM = 3
|
||||
// Root font-size in px, used to convert the pointer's clientX (px) into rem.
|
||||
function rootFontPx(): number {
|
||||
if (!BROWSER) return 16
|
||||
const px = parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
return Number.isFinite(px) && px > 0 ? px : 16
|
||||
}
|
||||
const widthPref = useLocalStorageValue<number>('nav_menu_width_rem', SIDEBAR_MIN_REM, 'number')
|
||||
// A non-finite stored width (e.g. NaN from a corrupt entry) must never reach the
|
||||
// `style:width` binding: `Math.max(13, NaN)` is NaN, which renders as the invalid
|
||||
// `width: NaNrem` and collapses the fixed rail to content width, mangling every
|
||||
// menu button. Clamp defensively.
|
||||
function clampSidebarWidth(v: number): number {
|
||||
return Number.isFinite(v) ? Math.max(SIDEBAR_MIN_REM, v) : SIDEBAR_MIN_REM
|
||||
}
|
||||
const initialSidebarWidth = clampSidebarWidth(widthPref.val)
|
||||
let sidebarWidth = $state(initialSidebarWidth)
|
||||
// Heal a corrupt stored value so it stops breaking the rail on every reload.
|
||||
if (widthPref.val !== initialSidebarWidth) widthPref.val = initialSidebarWidth
|
||||
let resizingSidebar = $state(false)
|
||||
// Width (in rem) the content offset must track: the icon strip when collapsed,
|
||||
// the user-chosen width otherwise.
|
||||
let railWidth = $derived(isCollapsed ? SIDEBAR_COLLAPSED_REM : sidebarWidth)
|
||||
// Width transition shared by the rail and the content offset: none for the whole
|
||||
// drag (the rail tracks the pointer 1:1 and hits the min as a hard wall, no
|
||||
// friction), a plain ease only for the collapse/expand toggle.
|
||||
let sidebarTransitionClass = $derived(
|
||||
resizingSidebar ? '' : 'transition-all duration-200 ease-in-out'
|
||||
)
|
||||
// Set while a drag is live so it can be torn down if the layout unmounts
|
||||
// mid-drag (otherwise the window listeners would leak).
|
||||
let stopSidebarResize: (() => void) | null = null
|
||||
|
||||
function startSidebarResize(e: PointerEvent) {
|
||||
// One drag at a time: ignore a second concurrent pointer (multi-touch on the
|
||||
// handle) so its window listeners can't outlive the first pointer's stop().
|
||||
if (resizingSidebar) return
|
||||
e.preventDefault()
|
||||
const handle = e.currentTarget as HTMLElement
|
||||
// Capture the pointer so events keep flowing to us even when the cursor
|
||||
// crosses an iframe in the content area (app/session previews) — without
|
||||
// this the drag silently stalls the moment it enters the iframe.
|
||||
try {
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
resizingSidebar = true
|
||||
// The rail is fixed at left:0, so the pointer's clientX is the width — in px.
|
||||
// Convert to rem (the unit the rail is sized in) via the root font-size. Pure
|
||||
// resize: clamp at the min so the rail stops there like a wall (dragging left
|
||||
// never collapses — that's the toggle button's job).
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
sidebarWidth = Math.max(SIDEBAR_MIN_REM, ev.clientX / rootFontPx())
|
||||
}
|
||||
// pointercancel (and unmount, via onDestroy) must clear the state too, or
|
||||
// `resizingSidebar` sticks true — the overlay and handle highlight stay up
|
||||
// and the window listeners leak.
|
||||
const stop = () => {
|
||||
if (!resizingSidebar) return
|
||||
resizingSidebar = false
|
||||
widthPref.val = sidebarWidth
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', stop)
|
||||
window.removeEventListener('pointercancel', stop)
|
||||
try {
|
||||
handle.releasePointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
stopSidebarResize = null
|
||||
}
|
||||
stopSidebarResize = stop
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', stop)
|
||||
window.addEventListener('pointercancel', stop)
|
||||
}
|
||||
|
||||
onDestroy(() => stopSidebarResize?.())
|
||||
let userSettings: UserSettings | undefined = $state()
|
||||
let superadminSettings: SuperadminSettings | undefined = $state()
|
||||
let menuHidden = $state(false)
|
||||
let isDarkMode = useIsDarkMode()
|
||||
let darkMode = $derived(isDarkMode.val)
|
||||
|
||||
const SIDEBAR_BG = '#F3F3F7'
|
||||
const SIDEBAR_BG_DARK = '#1e232e'
|
||||
// Session mode is route-derived: the rail shows the sessions sidebar on the
|
||||
// /sessions page and the workspace navigation everywhere else. The switch
|
||||
// (SessionModeSwitch) just navigates in and out of that route.
|
||||
let sessionMode = $derived(page.url.pathname.startsWith(base + '/sessions'))
|
||||
// Session mode points the bottom settings entry at the open session's own
|
||||
// workspace. An unsent draft hasn't committed one yet, so it falls back to
|
||||
// the family root.
|
||||
let sessionSettingsWorkspace = $derived.by(() => {
|
||||
if (!sessionMode) return undefined
|
||||
const current = sessionState.sessions.find((s) => s.id === sessionState.currentSessionId)
|
||||
return current?.workspace_id ?? $currentWorkspaceRootId ?? $workspaceStore ?? undefined
|
||||
})
|
||||
// Inside a preview iframe the rail still renders (navigation mode), but the
|
||||
// switch must not — entering session mode from within the preview would
|
||||
// nest the whole experience. Hide it when embedded.
|
||||
const embedded = BROWSER && window.self !== window.top
|
||||
|
||||
// AI sessions are still dev-gated (localStorage wm_dev_global_ai=1), same as
|
||||
// the global chat. The Workspace ⇄ Sessions switch is the only entry point, so
|
||||
// gate it on the flag too — otherwise it would ship the unfinished experience
|
||||
// to prod. The /sessions page has its own gate for direct navigation.
|
||||
const globalAiEnabled = isGlobalAiEnabled()
|
||||
|
||||
if (page.status == 404) {
|
||||
goto('/user/login')
|
||||
@@ -120,18 +245,46 @@
|
||||
$workspaceStore = queryWorkspace
|
||||
}
|
||||
|
||||
menuHidden =
|
||||
page.url.searchParams.get('nomenubar') === 'true' ||
|
||||
page.url.pathname.startsWith('/oauth/callback/')
|
||||
// When this window is an iframe (e.g. the sessions preview), keep the menu
|
||||
// hidden once `nomenubar` has been requested: navigating inside the preview
|
||||
// drops the query param (both client-side routing and full-document loads),
|
||||
// and we don't want the global nav to pop back in. Stickiness is stored in
|
||||
// sessionStorage so it survives full reloads within the iframe's browsing
|
||||
// context. The top window is unaffected (embedded is false there), so the
|
||||
// oauth-callback case and ordinary navigation still toggle normally.
|
||||
const embedded = typeof window !== 'undefined' && window.self !== window.top
|
||||
const requested = page.url.searchParams.get('nomenubar') === 'true'
|
||||
if (embedded && requested) {
|
||||
try {
|
||||
sessionStorage.setItem('nomenubar_embedded', 'true')
|
||||
} catch {}
|
||||
}
|
||||
let stickyEmbedded = false
|
||||
if (embedded) {
|
||||
try {
|
||||
stickyEmbedded = sessionStorage.getItem('nomenubar_embedded') === 'true'
|
||||
} catch {}
|
||||
}
|
||||
menuHidden = requested || page.url.pathname.startsWith('/oauth/callback/') || stickyEmbedded
|
||||
}
|
||||
|
||||
async function updateUserStore(workspace: string | undefined) {
|
||||
if (workspace) {
|
||||
try {
|
||||
sessionStorage.setItem('workspace', String(workspace))
|
||||
localStorage.setItem('workspace', String(workspace))
|
||||
} catch (e) {
|
||||
console.error('Could not persist workspace to local storage', e)
|
||||
// A preview iframe shares BOTH localStorage and sessionStorage with the
|
||||
// top-level app (same-origin nested browsing contexts share the top-level
|
||||
// session storage). Persisting its session-scoped workspace to either would
|
||||
// clobber the workspace the user is actually navigating. Keep it in-memory
|
||||
// only ($workspaceStore is still set from the ?workspace= param for the
|
||||
// iframe's own API calls); the fork survives iframe reloads because the
|
||||
// preview always reloads a URL that carries ?workspace= (see
|
||||
// PreviewTabHost.reload). Only the top window owns the persisted keys.
|
||||
if (!embedded) {
|
||||
try {
|
||||
sessionStorage.setItem('workspace', String(workspace))
|
||||
localStorage.setItem('workspace', String(workspace))
|
||||
} catch (e) {
|
||||
console.error('Could not persist workspace to local storage', e)
|
||||
}
|
||||
}
|
||||
const user = await getUserExt(workspace)
|
||||
if (!deepEqual(user, $userStore)) {
|
||||
@@ -162,8 +315,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
// True when this window is a sessions-preview iframe (embedded + nomenubar,
|
||||
// which the preview always sets and stickies — see the menu-hide block above).
|
||||
function isSessionPreviewEmbed(): boolean {
|
||||
if (!embedded) return false
|
||||
try {
|
||||
return sessionStorage.getItem('nomenubar_embedded') === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Map a navigation target to the session editor it should open as a component,
|
||||
// or undefined for anything without a live-editor wrapper (regular apps, pages,
|
||||
// /get viewers). Only /edit routes — the wrappers are editors.
|
||||
function previewEditorTarget(
|
||||
url: URL | undefined
|
||||
): { kind: 'script' | 'flow' | 'raw_app'; path: string } | undefined {
|
||||
if (!url || !/\/(scripts|flows|apps_raw)\/edit\//.test(url.pathname)) return undefined
|
||||
const route = parsePreviewItemRoute(url.pathname)
|
||||
if (!route) return undefined
|
||||
const kind =
|
||||
route.kind === 'script'
|
||||
? 'script'
|
||||
: route.kind === 'flow'
|
||||
? 'flow'
|
||||
: route.raw_app
|
||||
? 'raw_app'
|
||||
: undefined
|
||||
return kind ? { kind, path: route.itemPath } : undefined
|
||||
}
|
||||
|
||||
beforeNavigate((navigation) => {
|
||||
menuOpen = false
|
||||
if (preserveMenuOnNextNav) {
|
||||
preserveMenuOnNextNav = false
|
||||
} else {
|
||||
menuOpen = false
|
||||
}
|
||||
|
||||
// Inside a sessions-preview iframe, hand an editor-route navigation up to the
|
||||
// parent so it mounts the in-process editor (sharing the session runtime)
|
||||
// instead of booting a second, disconnected editor in this frame. Cancel so
|
||||
// the heavy editor never mounts here at all. Runs before the apps_raw reload
|
||||
// below so a raw-app editor promotes rather than full-reloading the iframe.
|
||||
if (isSessionPreviewEmbed()) {
|
||||
const target = previewEditorTarget(navigation.to?.url)
|
||||
if (target) {
|
||||
navigation.cancel()
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm.session.openEditor', kind: target.kind, path: target.path },
|
||||
window.location.origin
|
||||
)
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Force page reload when navigating to /apps_raw/add or /apps_raw/edit
|
||||
// This ensures the cross-origin isolation headers are fetched from the server
|
||||
@@ -327,19 +534,12 @@
|
||||
$usedTriggerKinds = usedKinds
|
||||
}
|
||||
|
||||
function pathInAppMode(pathname: string | undefined): boolean {
|
||||
if (!pathname) return false
|
||||
return (
|
||||
pathname.startsWith(base + '/apps') ||
|
||||
pathname.startsWith(base + '/flows/add') ||
|
||||
pathname.startsWith(base + '/flows/edit') ||
|
||||
pathname.startsWith(base + '/scripts/add') ||
|
||||
pathname.startsWith(base + '/scripts/edit')
|
||||
)
|
||||
}
|
||||
afterNavigate((n) => {
|
||||
if (pathInAppMode(n.to?.url.pathname) && innerWidth >= 768) {
|
||||
isCollapsed = true
|
||||
// Remember the last navigation-mode route so exiting session mode returns
|
||||
// the user where they were rather than to the home page.
|
||||
const to = n.to?.url
|
||||
if (to && !to.pathname.startsWith(base + '/sessions')) {
|
||||
rememberNavRoute(to.pathname + to.search)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -350,9 +550,6 @@
|
||||
}
|
||||
|
||||
let devOnly = $derived(page.url.pathname.startsWith(base + '/scripts/dev'))
|
||||
// Sessions own their own chat pane; suppress the global Ask-AI chat on the /sessions route
|
||||
// so it doesn't render a second chat overlay on top of the session.
|
||||
let inSessionRoute = $derived(page.url.pathname.startsWith(base + '/sessions'))
|
||||
|
||||
async function loadDefaultScripts(workspace: string, user: UserExt | undefined) {
|
||||
if (!user?.operator) {
|
||||
@@ -510,6 +707,61 @@
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<!-- Home + Runs lifted to the top of the workspace nav, sitting with Favorites
|
||||
and Search as the primary quick-access cluster. Excluded from SidebarContent
|
||||
(via excludeMainLabels) so they aren't rendered twice. -->
|
||||
{#snippet quickLinks(collapsed: boolean)}
|
||||
<MenuLink
|
||||
class="!text-xs"
|
||||
label="Home"
|
||||
href={`${base}/`}
|
||||
icon={Home}
|
||||
isCollapsed={collapsed}
|
||||
aiId="sidebar-menu-link-home"
|
||||
aiDescription="Button to navigate to home which contains all the user's scripts, flows and apps"
|
||||
/>
|
||||
<MenuLink
|
||||
class="!text-xs"
|
||||
label="Runs"
|
||||
href={`${base}/runs`}
|
||||
icon={Play}
|
||||
isCollapsed={collapsed}
|
||||
aiId="sidebar-menu-link-runs"
|
||||
aiDescription="Button to navigate to runs"
|
||||
onclick={() => {
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('popstate'))
|
||||
}, 100)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- Windmill brand mark anchoring the sidebar bottom (the header slot is taken
|
||||
by the workspace picker). -->
|
||||
{#snippet brandMark(collapsed: boolean)}
|
||||
<div class="flex items-center gap-x-1.5 text-xs font-semibold text-emphasis">
|
||||
<WindmillIcon white={darkMode} height="16px" width="16px" />
|
||||
{#if !collapsed}
|
||||
{$whitelabelNameStore ? capitalize($whitelabelNameStore) : 'Windmill'}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Settings / instance section. Shared by both sidebar surfaces and both modes
|
||||
(in nav mode it's the last section inside the unified scroll area; in session
|
||||
mode it stays pinned under the session list). -->
|
||||
{#snippet settingsMenu(collapsed: boolean)}
|
||||
<div class="px-2 pb-1">
|
||||
<SettingsMenu
|
||||
isCollapsed={collapsed}
|
||||
workspaceSettingsTarget={sessionSettingsWorkspace}
|
||||
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
|
||||
? 0
|
||||
: numUnacknowledgedCriticalAlerts}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<UserSettings bind:this={userSettings} showMcpMode={true} />
|
||||
<DraftMigrationErrorModal />
|
||||
{#if page.status == 404}
|
||||
@@ -523,6 +775,11 @@
|
||||
<CriticalAlertModal bind:muteSettings bind:numUnacknowledgedCriticalAlerts />
|
||||
{/if}
|
||||
<div class="h-screen flex flex-col">
|
||||
{#if resizingSidebar}
|
||||
<!-- While dragging, this overlay gives the whole viewport the resize cursor
|
||||
and stops the pointer from selecting text or hovering content/iframes. -->
|
||||
<div class="fixed inset-0 z-50 cursor-col-resize select-none"></div>
|
||||
{/if}
|
||||
{#if !menuHidden}
|
||||
{#if !$userStore?.operator}
|
||||
{#if innerWidth < 768}
|
||||
@@ -585,58 +842,101 @@
|
||||
class="h-full flex flex-col"
|
||||
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
|
||||
>
|
||||
<div class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-emphasis w-40">
|
||||
<WindmillIcon white={darkMode} height="20px" width="20px" />
|
||||
{#if $whitelabelNameStore}
|
||||
{$whitelabelNameStore}
|
||||
{:else}
|
||||
Windmill
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-2 py-4 border-y border-light dark:border-gray-700">
|
||||
<Menubar>
|
||||
<!-- Workspace picker as the drawer header (replaces the Windmill logo). -->
|
||||
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center">
|
||||
<Menubar class="w-full">
|
||||
{#snippet children({ createMenu })}
|
||||
<WorkspaceMenu {createMenu} />
|
||||
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => openSearchModal()}
|
||||
isCollapsed={false}
|
||||
icon={Search}
|
||||
label="Search"
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
isCollapsed={false}
|
||||
icon={WandSparkles}
|
||||
iconProps={{
|
||||
forceDarkMode: true
|
||||
}}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-ai"
|
||||
shortcut={`${getModifierKey()}L`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- w-40 cap: the drawer is max-w-min, and long session titles
|
||||
(nowrap before truncation) would otherwise inflate its
|
||||
min-content width to the full text width. -->
|
||||
<div class="w-40">
|
||||
<SessionPicker isCollapsed={false} />
|
||||
</div>
|
||||
{#if !embedded && globalAiEnabled}
|
||||
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
|
||||
<div class="px-2 pb-1 w-52">
|
||||
<SessionModeSwitch
|
||||
mode={sessionMode ? 'session' : 'nav'}
|
||||
onToggle={() => (preserveMenuOnNextNav = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SidebarContent
|
||||
isCollapsed={false}
|
||||
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
|
||||
? 0
|
||||
: numUnacknowledgedCriticalAlerts}
|
||||
/>
|
||||
{#if !sessionMode}
|
||||
<!-- Workspace scope (fork picker): part of the top workspace group. -->
|
||||
<div class="pb-1 w-52 {globalAiEnabled ? '' : '-mt-1'}">
|
||||
<WorkspaceScopeHeader isCollapsed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sessionMode}
|
||||
<!-- Session mode: the session list owns the rail.
|
||||
w-52 cap (matches the desktop sidebar width): the drawer is
|
||||
max-w-min, and long session titles (nowrap before truncation)
|
||||
would otherwise inflate its min-content width to the full
|
||||
text width. -->
|
||||
<div class="px-2 py-2 w-52">
|
||||
<SessionPicker isCollapsed={false} embedded collapsible={false} />
|
||||
</div>
|
||||
{@render settingsMenu(false)}
|
||||
{:else}
|
||||
<!-- Navigation mode: Home → Settings scroll as ONE block with an
|
||||
overflow fade hint (same treatment as the desktop rail). -->
|
||||
<SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
|
||||
<!-- Section 1: Home/Runs + Favorites + Search -->
|
||||
<div class="px-2 pt-1 w-52 flex flex-col gap-1">
|
||||
{@render quickLinks(false)}
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => openSearchModal()}
|
||||
isCollapsed={false}
|
||||
icon={Search}
|
||||
label="Search"
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
{#if !globalAiEnabled}
|
||||
<!-- Global Ask-AI pane. When the sessions dev flag is on it is
|
||||
replaced by SessionModeSwitch, so it only shows in prod. -->
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
isCollapsed={false}
|
||||
icon={WandSparkles}
|
||||
iconProps={{ forceDarkMode: true }}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-ai"
|
||||
shortcut={`${getModifierKey()}L`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Section 2: workspace items -->
|
||||
<SidebarContent
|
||||
isCollapsed={false}
|
||||
showSecondary={false}
|
||||
excludeMainLabels={['Home', 'Runs']}
|
||||
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
|
||||
? 0
|
||||
: numUnacknowledgedCriticalAlerts}
|
||||
/>
|
||||
|
||||
<!-- Section 3: instance settings — mt-auto drops it to the bottom
|
||||
when there is spare room (see the desktop rail for the rationale). -->
|
||||
<div class="mt-auto">
|
||||
{@render settingsMenu(false)}
|
||||
</div>
|
||||
</SidebarScrollArea>
|
||||
{/if}
|
||||
|
||||
<div class="px-4 pt-3 pb-3.5 w-52">
|
||||
{@render brandMark(false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -645,93 +945,150 @@
|
||||
<div
|
||||
id="sidebar"
|
||||
class={classNames(
|
||||
'flex flex-col fixed inset-y-0 transition-all ease-in-out duration-200 z-40 ',
|
||||
isCollapsed ? 'w-12' : 'w-40',
|
||||
'flex flex-col fixed inset-y-0 z-40 ',
|
||||
sidebarTransitionClass,
|
||||
devOnly ? '!hidden' : ''
|
||||
)}
|
||||
style:width="{railWidth}rem"
|
||||
>
|
||||
<div
|
||||
class="flex-1 flex flex-col min-h-0 h-screen border-r border-light dark:border-gray-700"
|
||||
class="flex-1 flex flex-col min-h-0 h-screen shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_-1px_0_0_0_#374151]"
|
||||
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
goto('/')
|
||||
}}
|
||||
>
|
||||
{#if !isCollapsed}
|
||||
<!-- Resize handle straddling the right edge, only while expanded:
|
||||
drag to widen (clamped at the min). Collapsing is the toggle
|
||||
button's job. -->
|
||||
<div
|
||||
class="flex-row flex-shrink-0 px-3.5 py-3.5 text-opacity-70 h-12 flex items-center gap-1.5"
|
||||
class:w-40={!isCollapsed}
|
||||
>
|
||||
<div class:mr-1={!isCollapsed}>
|
||||
<WindmillIcon white={darkMode} height="20px" width="20px" />
|
||||
</div>
|
||||
{#if !isCollapsed}
|
||||
<div class="text-sm mt-0.5 text-emphasis">
|
||||
{#if $whitelabelNameStore}{capitalize(
|
||||
$whitelabelNameStore
|
||||
)}{:else}Windmill{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div class="px-2 py-4 border-y border-light dark:border-gray-700 flex flex-col gap-1">
|
||||
<Menubar class="flex flex-col gap-1">
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
title="Drag to resize"
|
||||
class={classNames(
|
||||
'absolute inset-y-0 -right-0.5 w-1.5 cursor-col-resize z-50 transition-colors',
|
||||
resizingSidebar ? '' : 'hover:bg-surface-hover'
|
||||
)}
|
||||
onpointerdown={startSidebarResize}
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Workspace picker as the sidebar header (replaces the Windmill logo).
|
||||
Kept in both modes: it scopes which workspace family's sessions
|
||||
the sessions sidebar shows. -->
|
||||
<div class="flex-shrink-0 px-2 h-12 flex items-center">
|
||||
<Menubar class="w-full">
|
||||
{#snippet children({ createMenu })}
|
||||
<WorkspaceMenu {createMenu} {isCollapsed} />
|
||||
<FavoriteMenu
|
||||
{createMenu}
|
||||
favoriteLinks={favoriteManager.current}
|
||||
{isCollapsed}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => openSearchModal()}
|
||||
{isCollapsed}
|
||||
icon={Search}
|
||||
label="Search"
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
{isCollapsed}
|
||||
icon={WandSparkles}
|
||||
iconProps={{
|
||||
forceDarkMode: true
|
||||
}}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-ai"
|
||||
shortcut={`${getModifierKey()}L`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SessionPicker {isCollapsed} />
|
||||
{#if !embedded && globalAiEnabled}
|
||||
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
|
||||
<div class="px-2 pb-1 {isCollapsed ? 'flex justify-center' : ''}">
|
||||
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SidebarContent
|
||||
{isCollapsed}
|
||||
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
|
||||
? 0
|
||||
: numUnacknowledgedCriticalAlerts}
|
||||
/>
|
||||
{#if !sessionMode}
|
||||
<!-- Workspace scope (fork picker): part of the top workspace group,
|
||||
together with the family menu and the mode switch above. Without
|
||||
the switch, pull it up so the group still reads as one block. -->
|
||||
<div class="pb-1 {globalAiEnabled ? '' : '-mt-1'}">
|
||||
<WorkspaceScopeHeader {isCollapsed} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-shrink-0 flex px-4 pb-3.5">
|
||||
{#if sessionMode}
|
||||
<!-- Session mode: the session list owns the rail. Workspace nav moves
|
||||
into the preview side panel (the iframe renders its own nav). -->
|
||||
<div class="px-2 py-2 flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionPicker {isCollapsed} embedded collapsible={false} />
|
||||
</div>
|
||||
{@render settingsMenu(isCollapsed)}
|
||||
{:else}
|
||||
<!-- Navigation mode: Home → Settings scroll as ONE block with an
|
||||
overflow fade hint. The three sections (Home/Runs + Favorites +
|
||||
Search / workspace items / instance settings) are spaced by the
|
||||
scroll area's gap rather than each scrolling on its own. -->
|
||||
<SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
|
||||
<!-- Section 1: Home/Runs + Favorites + Search -->
|
||||
<div class="px-2 pt-1 flex flex-col gap-1">
|
||||
{@render quickLinks(isCollapsed)}
|
||||
<Menubar class="flex flex-col gap-1">
|
||||
{#snippet children({ createMenu })}
|
||||
<FavoriteMenu
|
||||
{createMenu}
|
||||
favoriteLinks={favoriteManager.current}
|
||||
{isCollapsed}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => openSearchModal()}
|
||||
{isCollapsed}
|
||||
icon={Search}
|
||||
label="Search"
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
{#if !globalAiEnabled}
|
||||
<!-- Global Ask-AI pane. When the sessions dev flag is on it is
|
||||
replaced by SessionModeSwitch, so it only shows in prod. -->
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
{isCollapsed}
|
||||
icon={WandSparkles}
|
||||
iconProps={{ forceDarkMode: true }}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-ai"
|
||||
shortcut={`${getModifierKey()}L`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Section 2: workspace items -->
|
||||
<SidebarContent
|
||||
{isCollapsed}
|
||||
showSecondary={false}
|
||||
excludeMainLabels={['Home', 'Runs']}
|
||||
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
|
||||
? 0
|
||||
: numUnacknowledgedCriticalAlerts}
|
||||
/>
|
||||
|
||||
<!-- Section 3: instance settings — mt-auto drops it to the bottom
|
||||
when the column has spare room, and collapses back to the normal
|
||||
inter-section gap once the content overflows and scrolls. -->
|
||||
<div class="mt-auto">
|
||||
{@render settingsMenu(isCollapsed)}
|
||||
</div>
|
||||
</SidebarScrollArea>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex-shrink-0 flex pt-3 pb-3.5 {isCollapsed
|
||||
? 'flex-col items-center gap-3'
|
||||
: 'items-center justify-between px-4'}"
|
||||
>
|
||||
{@render brandMark(isCollapsed)}
|
||||
<!-- p-2/-m-2 widens the hit area to ~32px without moving the icon. -->
|
||||
<button
|
||||
class="p-2 -m-2 rounded hover:bg-surface-hover"
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onclick={() => {
|
||||
isCollapsed = !isCollapsed
|
||||
// Manual toggle is the persisted preference (auto-collapse isn't).
|
||||
collapsePref.val = isCollapsed
|
||||
}}
|
||||
>
|
||||
<ArrowLeft
|
||||
size={16}
|
||||
class={classNames(
|
||||
'flex-shrink-0 h-4 w-4 transition-all ease-in-out duration-200 text-secondary',
|
||||
isCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
{#if isCollapsed}
|
||||
<PanelLeftOpen size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
{:else}
|
||||
<PanelLeftClose size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -872,9 +1229,10 @@
|
||||
{/if}
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly}
|
||||
disableAi={inSessionRoute}
|
||||
{isCollapsed}
|
||||
noPadding={devOnly || menuHidden}
|
||||
disableAi={globalAiEnabled ? true : sessionMode}
|
||||
sidebarWidth={railWidth}
|
||||
transitionClass={sidebarTransitionClass}
|
||||
isMobile={innerWidth < 768}
|
||||
onMenuOpen={() => {
|
||||
menuOpen = true
|
||||
@@ -895,12 +1253,12 @@
|
||||
<Modal2
|
||||
title="Forking {$workspaceStore}"
|
||||
target="#content"
|
||||
fixedHeight="lg"
|
||||
fixedHeight="adaptive"
|
||||
fixedWidth="sm"
|
||||
contentClasses="flex-col"
|
||||
bind:isOpen={() => !!globalForkModal.val?.opened, (v) => !v && (globalForkModal.val = undefined)}
|
||||
>
|
||||
{#if globalForkModal.val}
|
||||
<CreateWorkspaceInner isFork onFinish={() => (globalForkModal.val = undefined)} />
|
||||
<CreateWorkspaceInner isFork inModal onFinish={() => (globalForkModal.val = undefined)} />
|
||||
{/if}
|
||||
</Modal2>
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
<script lang="ts">
|
||||
// DEV-ONLY design exploration: how to visually distinguish a *workspace* from a
|
||||
// *session* in the rail tree, and make the hierarchy (forks) clear. Not linked
|
||||
// anywhere; open at /dev/session-tree. Pure mock data, no real session state.
|
||||
// Gated behind the same dev flag as the sessions UI it explores.
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
|
||||
import {
|
||||
MessageSquare,
|
||||
PencilLine,
|
||||
ChevronRight,
|
||||
CornerDownRight,
|
||||
Folder,
|
||||
FileText,
|
||||
Building,
|
||||
GitFork
|
||||
} from 'lucide-svelte'
|
||||
|
||||
type Sess = {
|
||||
id: string
|
||||
name: string
|
||||
unread?: number
|
||||
draft?: boolean
|
||||
archived?: boolean
|
||||
}
|
||||
type Node = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
depth: number
|
||||
isFork?: boolean
|
||||
parentName?: string
|
||||
sessions: Sess[]
|
||||
}
|
||||
|
||||
// One rich dataset covering: empty workspace, selected session, unread, draft,
|
||||
// archived, and fork depth 1 + 2.
|
||||
const data: Node[] = [
|
||||
{ id: 'admins', name: 'Admins', color: '#6b7280', depth: 0, sessions: [] },
|
||||
{
|
||||
id: 'ai-meta',
|
||||
name: 'AI Meta E2E',
|
||||
color: '#3b82f6',
|
||||
depth: 0,
|
||||
sessions: [
|
||||
{ id: 's-refactor', name: 'Refactor the parser' },
|
||||
{ id: 's-flaky', name: 'Fix flaky test', unread: 3 },
|
||||
{ id: 's-draft', name: 'New idea', draft: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rawapp',
|
||||
name: 'raw-app-parser',
|
||||
color: '#10b981',
|
||||
depth: 0,
|
||||
sessions: [{ id: 's-crud', name: 'Build CRUD app' }]
|
||||
},
|
||||
{
|
||||
id: 'rawapp-fork',
|
||||
name: 'organized-fork',
|
||||
color: '#f59e0b',
|
||||
depth: 1,
|
||||
isFork: true,
|
||||
parentName: 'raw-app-parser',
|
||||
sessions: [{ id: 's-org', name: 'Organize files', archived: true }]
|
||||
},
|
||||
{
|
||||
id: 'rawapp-fork2',
|
||||
name: 'deep-fork',
|
||||
color: '#ec4899',
|
||||
depth: 2,
|
||||
isFork: true,
|
||||
parentName: 'organized-fork',
|
||||
sessions: [{ id: 's-deep', name: 'Deep work', unread: 1 }]
|
||||
}
|
||||
]
|
||||
|
||||
const states = [
|
||||
{ label: 'A session selected', sel: 's-refactor', browsed: '' },
|
||||
{ label: 'Browsing a workspace (no chat)', sel: '', browsed: 'rawapp' }
|
||||
]
|
||||
|
||||
const solutions = [
|
||||
{ key: 'A', title: 'A · Avatar vs dot + indent', render: solutionA },
|
||||
{ key: 'B', title: 'B · Folders & files + workspace-tree lines', render: solutionB },
|
||||
{ key: 'B2', title: 'B2 · Workspace avatars (smaller) · no session icon', render: solutionB2 },
|
||||
{ key: 'C', title: 'C · Workspace as muted header', render: solutionC },
|
||||
{ key: 'D', title: 'D · Workspace cards', render: solutionD }
|
||||
]
|
||||
</script>
|
||||
|
||||
{#snippet unread(n: number)}
|
||||
<span
|
||||
class="ml-auto shrink-0 inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
>
|
||||
{n > 9 ? '9+' : n}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<!-- ───────────────── Solution A: avatar (workspace) vs dot (session) + indent ──── -->
|
||||
{#snippet solutionA(sel: string, browsed: string)}
|
||||
<div class="flex flex-col gap-0.5 p-2">
|
||||
{#each data as ws (ws.id)}
|
||||
<button
|
||||
type="button"
|
||||
style:padding-left={`${8 + ws.depth * 14}px`}
|
||||
class="flex items-center gap-2 w-full text-left pr-2 py-1 rounded {browsed === ws.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'}"
|
||||
>
|
||||
<WorkspaceIcon workspaceColor={ws.color} isForked={ws.isFork} size={12} />
|
||||
<span
|
||||
class="text-xs font-medium truncate {browsed === ws.id
|
||||
? 'text-emphasis'
|
||||
: 'text-primary'}">{ws.name}</span
|
||||
>
|
||||
</button>
|
||||
{#each ws.sessions as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
style:padding-left={`${8 + ws.depth * 14 + 18}px`}
|
||||
class="flex items-center gap-2 w-full text-left pr-2 py-1 rounded {sel === s.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'} {s.archived ? 'opacity-50' : ''}"
|
||||
>
|
||||
<MessageSquare size={12} class="shrink-0 text-tertiary" />
|
||||
<span
|
||||
class="text-xs truncate {sel === s.id
|
||||
? 'text-emphasis font-semibold'
|
||||
: 'text-secondary'}">{s.name}</span
|
||||
>
|
||||
{#if s.draft}<PencilLine size={11} class="ml-auto shrink-0 text-tertiary" />{/if}
|
||||
{#if s.unread}{@render unread(s.unread)}{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ───────────────── Solution B: folders (workspaces) & files (sessions) ──────────
|
||||
Workspaces are folders, sessions are files. The connecting lines render the
|
||||
workspace tree (fork nesting / containment) — not the session elements. Small
|
||||
folder/file icons. -->
|
||||
{#snippet vline()}
|
||||
<span class="relative w-3.5 shrink-0 self-stretch">
|
||||
<span class="absolute inset-y-0 left-1/2 w-px bg-gray-200 dark:bg-gray-700"></span>
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet solutionB(sel: string, browsed: string)}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each data as ws (ws.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-stretch w-full text-left pr-2 {browsed === ws.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'}"
|
||||
>
|
||||
{#each Array(ws.depth) as _}{@render vline()}{/each}
|
||||
<span class="flex items-center gap-1.5 py-1 pl-1 min-w-0">
|
||||
<Folder size={13} class="shrink-0" style="color: {ws.color}" />
|
||||
<span
|
||||
class="text-xs truncate {browsed === ws.id
|
||||
? 'text-emphasis font-semibold'
|
||||
: 'text-primary font-medium'}">{ws.name}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
{#each ws.sessions as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-stretch w-full text-left pr-2 {sel === s.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'} {s.archived ? 'opacity-50' : ''}"
|
||||
>
|
||||
{#each Array(ws.depth + 1) as _}{@render vline()}{/each}
|
||||
<span class="flex items-center gap-1.5 py-1 pl-1 min-w-0 flex-1">
|
||||
<FileText size={12} class="shrink-0 text-tertiary" />
|
||||
<span
|
||||
class="text-xs truncate {sel === s.id
|
||||
? 'text-emphasis font-semibold'
|
||||
: 'text-secondary'}">{s.name}</span
|
||||
>
|
||||
{#if s.draft}<PencilLine size={11} class="ml-auto shrink-0 text-tertiary" />{/if}
|
||||
{#if s.unread}{@render unread(s.unread)}{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ───────────────── Solution B2: classic workspace/fork avatars (smaller),
|
||||
no icon on sessions, same workspace-tree guide lines ──────────────────────── -->
|
||||
{#snippet solutionB2(sel: string, browsed: string)}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each data as ws (ws.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-stretch w-full text-left pr-2 {browsed === ws.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'}"
|
||||
>
|
||||
{#each Array(ws.depth) as _}{@render vline()}{/each}
|
||||
<span class="flex items-center gap-1.5 py-1 pl-1 min-w-0">
|
||||
{#if ws.isFork}
|
||||
<GitFork size={14} class="shrink-0" style="color: {ws.color}" />
|
||||
{:else}
|
||||
<Building size={14} class="shrink-0" style="color: {ws.color}" />
|
||||
{/if}
|
||||
<span
|
||||
class="text-xs truncate {browsed === ws.id
|
||||
? 'text-emphasis font-medium'
|
||||
: 'text-primary'}">{ws.name}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
{#each ws.sessions as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-stretch w-full text-left pr-2 {sel === s.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'} {s.archived ? 'opacity-50' : ''}"
|
||||
>
|
||||
{#each Array(ws.depth + 1) as _}{@render vline()}{/each}
|
||||
<span class="flex items-center gap-2 py-1 pl-1 min-w-0 flex-1">
|
||||
<span
|
||||
class="text-xs truncate {sel === s.id
|
||||
? 'text-emphasis font-medium'
|
||||
: 'text-secondary'}">{s.name}</span
|
||||
>
|
||||
{#if s.draft}<PencilLine size={11} class="ml-auto shrink-0 text-tertiary" />{/if}
|
||||
{#if s.unread}{@render unread(s.unread)}{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ───────────────── Solution C: workspace as muted uppercase header ──────────── -->
|
||||
{#snippet solutionC(sel: string, browsed: string)}
|
||||
<div class="flex flex-col p-2">
|
||||
{#each data as ws, wi (ws.id)}
|
||||
<button
|
||||
type="button"
|
||||
style:padding-left={`${8 + ws.depth * 14}px`}
|
||||
class="flex items-center gap-1.5 w-full text-left pr-2 py-1 rounded {wi > 0
|
||||
? 'mt-2'
|
||||
: ''} {browsed === ws.id ? 'bg-surface-hover' : 'hover:bg-surface-hover'}"
|
||||
>
|
||||
<WorkspaceIcon workspaceColor={ws.color} isForked={ws.isFork} size={10} />
|
||||
<span
|
||||
class="text-[0.6rem] uppercase tracking-wide font-semibold truncate {browsed === ws.id
|
||||
? 'text-emphasis'
|
||||
: 'text-tertiary'}">{ws.name}</span
|
||||
>
|
||||
{#if ws.isFork}<span class="text-[0.55rem] text-tertiary">· fork</span>{/if}
|
||||
</button>
|
||||
{#each ws.sessions as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
style:padding-left={`${8 + ws.depth * 14 + 6}px`}
|
||||
class="flex items-center gap-2 w-full text-left pr-2 py-1 rounded {sel === s.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'} {s.archived ? 'opacity-50' : ''}"
|
||||
>
|
||||
<MessageSquare size={13} class="shrink-0 text-blue-500" />
|
||||
<span
|
||||
class="text-xs truncate {sel === s.id ? 'text-emphasis font-semibold' : 'text-primary'}"
|
||||
>{s.name}</span
|
||||
>
|
||||
{#if s.draft}<PencilLine size={11} class="ml-auto shrink-0 text-tertiary" />{/if}
|
||||
{#if s.unread}{@render unread(s.unread)}{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ───────────────── Solution D: workspace cards ──────────────────────────────── -->
|
||||
{#snippet solutionD(sel: string, browsed: string)}
|
||||
<div class="flex flex-col gap-1.5 p-2">
|
||||
{#each data as ws (ws.id)}
|
||||
<div style:margin-left={`${ws.depth * 12}px`}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-t-md {browsed ===
|
||||
ws.id
|
||||
? 'bg-surface-accent-selected'
|
||||
: 'bg-surface-secondary hover:bg-surface-hover'} {ws.sessions.length
|
||||
? ''
|
||||
: 'rounded-b-md'}"
|
||||
>
|
||||
<WorkspaceIcon workspaceColor={ws.color} isForked={ws.isFork} size={12} />
|
||||
<span class="text-xs font-semibold truncate text-primary">{ws.name}</span>
|
||||
{#if ws.isFork}<CornerDownRight size={11} class="ml-auto shrink-0 text-tertiary" />{/if}
|
||||
</button>
|
||||
{#if ws.sessions.length}
|
||||
<div
|
||||
class="border border-t-0 border-light rounded-b-md divide-y divide-light overflow-hidden"
|
||||
>
|
||||
{#each ws.sessions as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full text-left px-2 py-1 {sel === s.id
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-surface-hover'} {s.archived ? 'opacity-50' : ''}"
|
||||
>
|
||||
<MessageSquare size={12} class="shrink-0 text-tertiary" />
|
||||
<span
|
||||
class="text-xs truncate {sel === s.id
|
||||
? 'text-emphasis font-semibold'
|
||||
: 'text-secondary'}">{s.name}</span
|
||||
>
|
||||
{#if s.draft}<PencilLine size={11} class="ml-auto shrink-0 text-tertiary" />{/if}
|
||||
{#if s.unread}{@render unread(s.unread)}{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if isGlobalAiEnabled()}
|
||||
<div class="h-full overflow-auto p-6 bg-surface">
|
||||
<div class="max-w-[1400px] mx-auto flex flex-col gap-2">
|
||||
<h1 class="text-xl font-semibold text-primary flex items-center gap-2">
|
||||
<Folder size={18} /> Session vs Workspace tree — design exploration
|
||||
</h1>
|
||||
<p class="text-sm text-secondary">
|
||||
Dev-only mock. Each solution distinguishes <strong>workspaces</strong> (clickable, open a
|
||||
preview) from <strong>sessions</strong> (open the chat), and shows the fork hierarchy. Two states
|
||||
per solution: a session selected, and a workspace being browsed.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-8">
|
||||
{#each solutions as sol (sol.key)}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2 class="text-sm font-semibold text-emphasis">{sol.title}</h2>
|
||||
<div class="flex flex-row flex-wrap gap-4">
|
||||
{#each states as st (st.label)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-tertiary flex items-center gap-1">
|
||||
<ChevronRight size={12} />{st.label}
|
||||
</span>
|
||||
<div
|
||||
class="w-64 rounded-md border border-light bg-surface-secondary overflow-hidden"
|
||||
>
|
||||
{@render sol.render(st.sel, st.browsed)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,16 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { page } from '$app/state'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import {
|
||||
Plus,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ExternalLink,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ChevronDown,
|
||||
MonitorPlay,
|
||||
Loader2,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import PreviewRouterPicker, {
|
||||
type Scope
|
||||
} from '$lib/components/sessions/PreviewRouterPicker.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte'
|
||||
import PreviewTabHost from '$lib/components/sessions/PreviewTabHost.svelte'
|
||||
import {
|
||||
createSession,
|
||||
getEffectiveWorkspaceId,
|
||||
selectSession,
|
||||
sessionInCurrentFamily,
|
||||
sessionState,
|
||||
syncWorkspaceTo
|
||||
type SessionPreviewTab
|
||||
} from '$lib/components/sessions/sessionState.svelte'
|
||||
import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte'
|
||||
import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
@@ -19,36 +43,60 @@
|
||||
} from '$lib/components/sessions/sessionRuntime.svelte'
|
||||
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { setToolCompletionListener } from '$lib/components/copilot/chat/shared'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
matchPreviewPage,
|
||||
pageKey,
|
||||
stripBase,
|
||||
parsePreviewItemRoute,
|
||||
type PreviewTarget
|
||||
} from '$lib/components/sessions/previewRouter'
|
||||
import { leafKeyFor, type WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
|
||||
|
||||
const globalEnabled = isGlobalAiEnabled()
|
||||
|
||||
// The sessions page hosts preview iframes that load Windmill pages. If one of
|
||||
// those iframes navigates back to /sessions, mounting the full UI again would
|
||||
// nest another sessions page (with its own iframes) endlessly. Detect the
|
||||
// iframe context and refuse to mount when embedded.
|
||||
const embedded = typeof window !== 'undefined' && window.self !== window.top
|
||||
|
||||
const sessionName = $derived(page.url.searchParams.get('session_name') ?? '')
|
||||
|
||||
// Unfiltered resolution by name — used to drive workspace switching
|
||||
// when a deep-linked session lives outside the current workspace.
|
||||
// Unfiltered resolution by name — drives the "session not found" fallback and
|
||||
// the active-session lookup below.
|
||||
const sessionByName = $derived(
|
||||
sessionName ? sessionState.sessions.find((s) => s.name === sessionName) : undefined
|
||||
)
|
||||
|
||||
// If the deep-linked session committed to a workspace different from
|
||||
// the active one, switch globally so visibility resolves and the
|
||||
// editor loads against the right workspace. Skip the switch when the
|
||||
// target workspace is no longer in the user's list — pointing the
|
||||
// global workspace at a deleted id would break sidebar scope and the
|
||||
// editor; SessionWrapper handles the unavailable state separately.
|
||||
$effect(() => {
|
||||
const ws = sessionByName?.workspace_id
|
||||
if (!ws) return
|
||||
if (!$userWorkspaces.find((w) => w.id === ws)) return
|
||||
untrack(() => syncWorkspaceTo(ws))
|
||||
})
|
||||
// Opening a session deliberately does NOT switch the global workspace: the
|
||||
// chat targets the session's own workspace via the manager's workspace
|
||||
// resolver, so the user's active (navigation-mode) workspace is left alone.
|
||||
|
||||
// sessionState.sessions holds every local session for the user. Resolve by
|
||||
// name without applying the sidebar root filter so an open chat survives
|
||||
// workspace switches.
|
||||
// Resolve by name without applying the sidebar scope filter so an open
|
||||
// chat survives within-family workspace switches.
|
||||
const activeSession = $derived(sessionState.sessions.find((s) => s.name === sessionName))
|
||||
|
||||
// Family reconcile: a workspace switch can land this page with no session
|
||||
// selected or with another family's session in the URL (the sidebar picker's
|
||||
// link navigation keeps the route), and a chat must not bleed across
|
||||
// families. Re-enter session mode scoped to the active family: keep the open
|
||||
// chat when it belongs there, else its most recent active session, else a
|
||||
// fresh one. The `session_name`-without-a-session case is left to the
|
||||
// not-found UI below.
|
||||
$effect(() => {
|
||||
if (embedded || !sessionState.hydrated) return
|
||||
// sessionInCurrentFamily reads these via get(), so track them explicitly.
|
||||
$workspaceStore
|
||||
$userWorkspaces
|
||||
const current = activeSession
|
||||
const shouldReenter = current ? !sessionInCurrentFamily(current) : !sessionName
|
||||
if (!shouldReenter) return
|
||||
untrack(() => void enterSessionMode({ replace: true }))
|
||||
})
|
||||
|
||||
// Touch the runtime for the active session so it gets created on first visit
|
||||
// and the pane shows up. Subsequent renders find it via listRuntimes().
|
||||
//
|
||||
@@ -67,19 +115,17 @@
|
||||
if (lastArrivedSessionId === session.id) return
|
||||
lastArrivedSessionId = session.id
|
||||
untrack(() => {
|
||||
// Keep currentSessionId in sync with the URL so consumers
|
||||
// (refresh hooks, picker selection) react to deep links the
|
||||
// same way they react to picker clicks.
|
||||
// Keep currentSessionId in sync with the URL so consumers react to
|
||||
// deep links the same way they react to picker clicks.
|
||||
selectSession(session.id)
|
||||
getOrCreateRuntime(session)
|
||||
})
|
||||
})
|
||||
|
||||
// Warm = sessions that currently have a live (module-scoped) runtime. The
|
||||
// picker eagerly creates runtimes for its visible sessions, so this tracks
|
||||
// whatever the picker shows — the current family, or every family when
|
||||
// "Show all workspaces" is on. Runtimes whose session record isn't loaded
|
||||
// resolve to undefined here and drop out.
|
||||
// Warm = sessions with a live runtime. The picker eagerly creates runtimes
|
||||
// for its visible sessions, so this tracks whatever it shows. Keeping warm
|
||||
// chats mounted (stacked, visibility-toggled) preserves their scroll/draft
|
||||
// state across switches.
|
||||
const warmSessions = $derived(
|
||||
listRuntimes()
|
||||
.map((r) => sessionState.sessions.find((s) => s.id === r.sessionId))
|
||||
@@ -94,13 +140,10 @@
|
||||
untrack(() => promoteEditorWarm(id))
|
||||
})
|
||||
|
||||
// Mark the active session "seen" up to its current displayMessages
|
||||
// length. Watching messages.length here means: arrive at the page →
|
||||
// clear unread; AI streams a new message while you're on the page →
|
||||
// clear unread again so the badge never lights up for a session
|
||||
// you're actively looking at. The effect only depends on the
|
||||
// length, not the array contents, so token-by-token streams within
|
||||
// a single message don't fire it on every chunk.
|
||||
// Mark the active session "seen" up to its current message count: arrive →
|
||||
// clear unread; AI streams a new message while we're here → clear again. The
|
||||
// effect depends only on the length, not the array contents, so token-by-token
|
||||
// streams within a single message don't fire it on every chunk.
|
||||
$effect(() => {
|
||||
const id = activeSession?.id
|
||||
if (!id) return
|
||||
@@ -114,39 +157,650 @@
|
||||
const fresh = createSession()
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
|
||||
}
|
||||
|
||||
// Preview panel: a tiny tabbed browser over Windmill. Every tab stays mounted
|
||||
// (stacked, visibility-toggled, like the warm chat sessions) so switching
|
||||
// preserves each previewed page's scroll/edit state. The tab model lives on
|
||||
// the active session's runtime (previewTabs) — one live copy that both this
|
||||
// page (renderer) and the chat's open_preview tool drive — so there's no
|
||||
// page-local tab state to seed from IndexedDB or reconcile against the tool.
|
||||
//
|
||||
// Pure read (not getOrCreateRuntime): creating a runtime mutates the global
|
||||
// `runtimes` map, which is forbidden inside a $derived. The arrival effect
|
||||
// creates it in effect context; getRuntime reads the SvelteMap reactively so
|
||||
// this re-derives the moment it lands.
|
||||
const activeRuntime = $derived(activeSession ? getRuntime(activeSession.id) : undefined)
|
||||
const owner = $derived(activeRuntime?.previewTabs)
|
||||
|
||||
// The workspace the active session acts on. Preview pickers load their items
|
||||
// from it and "Open in workspace" targets it, so a fork-scoped session never
|
||||
// lists or opens against the navigation workspace ($workspaceStore).
|
||||
const previewWorkspace = $derived(
|
||||
(activeSession ? getEffectiveWorkspaceId(activeSession) : undefined) ?? $workspaceStore
|
||||
)
|
||||
|
||||
// Lazy-mount gate: a tab's content only renders once its key lands here (on
|
||||
// first activation) — so restoring a session with N saved tabs boots just
|
||||
// the active one instead of N full Windmill apps at once. Pure "has this
|
||||
// content been created yet" DOM bookkeeping, so it stays page-local while
|
||||
// the owner holds the tab identity. Keys are session-scoped (tab ids alone
|
||||
// collide across sessions — every session seeds a 'session' tab id) and the
|
||||
// set survives session switches: a warm session's mounted tabs stay alive
|
||||
// while another session is shown, same as its stacked chat column.
|
||||
//
|
||||
// MRU-capped at MAX_MOUNTED_TABS, shared across sessions: each mounted tab
|
||||
// is a full Windmill app (iframe) or live editor — without a cap warm
|
||||
// sessions accumulate one per tab ever activated. An evicted tab stays in
|
||||
// the strip and simply remounts on its next activation, same as the
|
||||
// lazy-mount path.
|
||||
const MAX_MOUNTED_TABS = 5
|
||||
const mountedTabKeys = new SvelteSet<string>()
|
||||
const tabKey = (sessionId: string, tabId: string) => `${sessionId}:${tabId}`
|
||||
const sessionOfKey = (key: string) => key.slice(0, key.indexOf(':'))
|
||||
function mountTab(key: string) {
|
||||
// Delete-then-add moves the key to the MRU end; evict from the LRU front,
|
||||
// never the tab just activated.
|
||||
mountedTabKeys.delete(key)
|
||||
mountedTabKeys.add(key)
|
||||
while (mountedTabKeys.size > MAX_MOUNTED_TABS) {
|
||||
const oldest = mountedTabKeys.values().next().value
|
||||
if (oldest === undefined || oldest === key) break
|
||||
mountedTabKeys.delete(oldest)
|
||||
}
|
||||
}
|
||||
// Mount the active session's active tab whenever either changes. Background
|
||||
// sessions' activeId changes (a chat tool opening a tab) don't mount — their
|
||||
// tabs boot lazily on first visible activation.
|
||||
$effect(() => {
|
||||
const sid = activeRuntime?.sessionId
|
||||
const activeId = owner?.activeId
|
||||
if (!sid || !activeId) return
|
||||
untrack(() => mountTab(tabKey(sid, activeId)))
|
||||
})
|
||||
// A disposed runtime unmounts its hosts; drop its keys too, else a later
|
||||
// re-open would boot every previously-mounted tab at once instead of
|
||||
// lazily, and stale keys would squat the shared MRU budget.
|
||||
$effect(() => {
|
||||
const warm = new Set(listRuntimes().map((r) => r.sessionId))
|
||||
untrack(() => {
|
||||
for (const key of [...mountedTabKeys]) {
|
||||
if (!warm.has(sessionOfKey(key))) mountedTabKeys.delete(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function selectTab(id: string) {
|
||||
owner?.select(id)
|
||||
const sid = activeRuntime?.sessionId
|
||||
if (sid) mountTab(tabKey(sid, id))
|
||||
activeTabPickerOpen = false
|
||||
}
|
||||
function openInNewTab(target: PreviewTarget) {
|
||||
owner?.open(target)
|
||||
}
|
||||
function closeTab(id: string) {
|
||||
owner?.close(id)
|
||||
const sid = activeRuntime?.sessionId
|
||||
if (sid) mountedTabKeys.delete(tabKey(sid, id))
|
||||
}
|
||||
let newTabOpen = $state(false)
|
||||
// Separate open flag for the empty-state launcher: it can be mounted at the
|
||||
// same time as the tab-strip "+" popover, so sharing one flag would open both
|
||||
// pickers at once.
|
||||
let emptyStateNewTabOpen = $state(false)
|
||||
|
||||
let fullscreen = $state(false)
|
||||
// Collapse the preview panel to give the chat the full width. Per-session and
|
||||
// owned by the runtime's previewTabs (restored on switch, written back on
|
||||
// toggle) so it survives session switches with the rest of the tab model.
|
||||
const previewCollapsed = $derived(owner?.collapsed ?? false)
|
||||
|
||||
// Collapse works by resizing the preview pane to zero, never unmounting it:
|
||||
// the pane hosts every warm session's preview tabs, and unmounting would
|
||||
// destroy them all whenever the active session's preview is collapsed (the
|
||||
// default for a session with no tabs). Both panes are driven together — a
|
||||
// given size on one pane against a stale size on the other makes Splitpanes
|
||||
// normalize (e.g. 50 vs 100 lands at 33%), drifting on every toggle.
|
||||
// null = let Splitpanes auto-distribute (initial even split).
|
||||
let previewPaneSize = $state<number | null>(null)
|
||||
let chatPaneSize = $state<number | null>(null)
|
||||
let lastExpandedPreviewSize = 50
|
||||
$effect(() => {
|
||||
const collapsed = previewCollapsed
|
||||
const full = fullscreen
|
||||
untrack(() => {
|
||||
if (full) {
|
||||
// Chat pane is unmounted: the preview is the only pane and must own
|
||||
// the full width, not its remembered split share.
|
||||
previewPaneSize = 100
|
||||
} else if (collapsed) {
|
||||
if (previewPaneSize && previewPaneSize > 0 && previewPaneSize < 100) {
|
||||
lastExpandedPreviewSize = previewPaneSize
|
||||
}
|
||||
previewPaneSize = 0
|
||||
chatPaneSize = 100
|
||||
} else {
|
||||
if (previewPaneSize === 0 || previewPaneSize === 100 || previewPaneSize === null) {
|
||||
previewPaneSize = lastExpandedPreviewSize
|
||||
}
|
||||
chatPaneSize = 100 - previewPaneSize
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Page path shown after the workspace breadcrumb — the active tab's observed
|
||||
// location, so the breadcrumb tracks where the user browses inside the tab.
|
||||
const displayPath = $derived(owner?.activeTab?.loc ?? owner?.activeTab?.url ?? `${base}/`)
|
||||
// Writes to the tab's own session model: a hidden warm session's iframe can
|
||||
// finish loading while another session is shown, and its location must not
|
||||
// land on the visible session's tabs.
|
||||
function onTabLoad(tabs: SessionPreviewTabs, tab: SessionPreviewTab, frame: HTMLIFrameElement) {
|
||||
try {
|
||||
const loc = frame.contentWindow?.location
|
||||
if (!loc) return
|
||||
// observeLocation canonicalizes away the injected nomenubar/workspace
|
||||
// params so the tab's `loc` stays symmetric with `url` for dedupe/display.
|
||||
tabs.observeLocation(tab.id, loc.pathname + loc.search)
|
||||
} catch {
|
||||
// Best-effort: the preview is same-origin, but reading location could
|
||||
// still throw mid-navigation — keep the seeded path in that case.
|
||||
}
|
||||
}
|
||||
|
||||
// Reload mounted preview tabs affected by a mutating chat tool (write_/patch_/
|
||||
// delete_/deploy_/…; read/test/navigate tools don't match). Scoped to the changed
|
||||
// item so editing one item never blank-reboots an unrelated item's preview iframe
|
||||
// (a full-page /apps_raw/edit reload is jarring).
|
||||
const tabHosts: Record<string, PreviewTabHost | undefined> = {}
|
||||
const MUTATING_TOOL_RE = /^(write_|patch_|delete_|deploy_|discard_|set_|create_|update_|remove_)/
|
||||
let reloadHandle: ReturnType<typeof setTimeout> | undefined
|
||||
// Drained each flush: item paths touched since the last flush, and a flag for an
|
||||
// unresolved mutation that forces a full reload (safe fallback).
|
||||
let pendingReloadPaths = new Set<string>()
|
||||
let pendingReloadAll = false
|
||||
|
||||
// Reload the batched-mutation tabs across all warm sessions' mounted tabs (a
|
||||
// hidden preview would otherwise show pre-mutation content on return). `null`
|
||||
// reloads all; otherwise an item-route iframe reloads only when its item was
|
||||
// touched. Non-item pages always reload; a live-editor slot no-ops in reload().
|
||||
function reloadTabs(paths: Set<string> | null) {
|
||||
for (const s of warmSessions) {
|
||||
const tabs = getRuntime(s.id)?.previewTabs?.tabs ?? []
|
||||
for (const tab of tabs) {
|
||||
const key = tabKey(s.id, tab.id)
|
||||
if (!mountedTabKeys.has(key)) continue
|
||||
if (paths) {
|
||||
const route = parsePreviewItemRoute(tab.url)
|
||||
if (route && !paths.has(route.itemPath)) continue
|
||||
}
|
||||
tabHosts[key]?.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
function flushReload() {
|
||||
const paths = pendingReloadAll ? null : pendingReloadPaths
|
||||
pendingReloadPaths = new Set()
|
||||
pendingReloadAll = false
|
||||
reloadTabs(paths)
|
||||
}
|
||||
$effect(() => {
|
||||
// Debounced so a burst of writes (the AI editing several files) reloads once.
|
||||
setToolCompletionListener((name, args) => {
|
||||
if (!MUTATING_TOOL_RE.test(name)) return
|
||||
// A workspace item path scopes the reload to that item. 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 → reload everything (safe fallback).
|
||||
const p = typeof args?.path === 'string' ? args.path : undefined
|
||||
if (p && !p.startsWith('/')) pendingReloadPaths.add(p)
|
||||
else if (p && activeSession?.target?.path) pendingReloadPaths.add(activeSession.target.path)
|
||||
else pendingReloadAll = true
|
||||
clearTimeout(reloadHandle)
|
||||
reloadHandle = setTimeout(flushReload, 500)
|
||||
})
|
||||
return () => {
|
||||
clearTimeout(reloadHandle)
|
||||
pendingReloadPaths = new Set()
|
||||
pendingReloadAll = false
|
||||
setToolCompletionListener(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
// Editor-style breadcrumb over the previewed page. We only render clickable
|
||||
// segments when the preview is sitting on a script/flow/app route — for any
|
||||
// other page (home, runs, …) there's no item to drill into, so we fall back
|
||||
// to the plain path.
|
||||
const parsedRoute = $derived(parsePreviewItemRoute(displayPath))
|
||||
|
||||
// Split the item path into breadcrumb dirs + leaf, mirroring EditorHeader:
|
||||
// scope (`f/<folder>` | `u/<user>`) → subfolders → item name.
|
||||
const segments = $derived.by(() => {
|
||||
const itemPath = parsedRoute?.itemPath
|
||||
if (!itemPath) return null
|
||||
const parts = itemPath.split('/')
|
||||
if (parts.length < 3) return null
|
||||
const scope = parts.slice(0, 2).join('/')
|
||||
const slug = parts.slice(2)
|
||||
const dirs: { name: string; fullPath: string }[] = [{ name: scope, fullPath: scope }]
|
||||
let acc = scope
|
||||
for (let i = 0; i < slug.length - 1; i++) {
|
||||
acc = `${acc}/${slug[i]}`
|
||||
dirs.push({ name: slug[i], fullPath: acc })
|
||||
}
|
||||
const leaf = { name: slug[slug.length - 1], fullPath: itemPath }
|
||||
return { dirs, leaf }
|
||||
})
|
||||
|
||||
const currentItem = $derived<WorkspaceItem & { savedPath?: string }>({
|
||||
path: parsedRoute?.itemPath ?? '',
|
||||
summary: '',
|
||||
kind: parsedRoute?.kind ?? 'script',
|
||||
raw_app: parsedRoute?.raw_app ?? false
|
||||
})
|
||||
|
||||
// On a non-item page, identify the known workspace page so the tab shows its
|
||||
// name (e.g. "Workspace settings") and the picker highlights it.
|
||||
const currentPage = $derived(parsedRoute ? undefined : matchPreviewPage(displayPath))
|
||||
|
||||
// The active tab's picker lands on its current location: an item is scoped
|
||||
// into its folder and highlighted; a known page is highlighted at root.
|
||||
const activePickerScope = $derived<Scope>(
|
||||
parsedRoute
|
||||
? segments && segments.dirs.length > 0
|
||||
? { kind: 'all', dir: segments.dirs[segments.dirs.length - 1].fullPath }
|
||||
: { kind: 'all' }
|
||||
: undefined
|
||||
)
|
||||
const activePickerHighlight = $derived(
|
||||
parsedRoute
|
||||
? leafKeyFor(parsedRoute.kind, parsedRoute.itemPath)
|
||||
: currentPage
|
||||
? pageKey(currentPage.path)
|
||||
: undefined
|
||||
)
|
||||
let activeTabPickerOpen = $state(false)
|
||||
|
||||
// Breadcrumb picks steer the *active* tab; the "+" picker opens new ones. An
|
||||
// editable item also becomes the session's live editor (owner.navigate).
|
||||
function navigatePreviewTo(target: PreviewTarget) {
|
||||
owner?.navigate(target)
|
||||
}
|
||||
|
||||
// Short tab label: a known page's name, else the item's leaf name, else path.
|
||||
function tabLabel(url: string): string {
|
||||
const page = matchPreviewPage(url)
|
||||
if (page) return page.label
|
||||
const parsed = parsePreviewItemRoute(url)
|
||||
if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath
|
||||
return stripBase(url)
|
||||
}
|
||||
|
||||
// A link click inside a live editor (e.g. a subflow reference) re-points the
|
||||
// active tab, which — for an editable item — makes it the session's live
|
||||
// editor via owner.navigate. Legacy drag-and-drop apps have no preview
|
||||
// wrapper, so they open in the standalone editor instead.
|
||||
function navigateEditorTo(item: WorkspaceItem) {
|
||||
if (item.kind === 'app' && !item.raw_app) {
|
||||
// Leaving the preview for the standalone editor — carry the session
|
||||
// workspace so the app opens in the fork the session acts on, not the
|
||||
// navigation workspace.
|
||||
goto(withWorkspaceParam(`${base}/apps/edit/${item.path}`, previewWorkspace))
|
||||
return
|
||||
}
|
||||
owner?.navigate({ type: 'item', item })
|
||||
}
|
||||
|
||||
// A preview iframe that navigates to an editor route posts up to us instead of
|
||||
// booting the editor inside the frame (see the logged layout's beforeNavigate).
|
||||
// Retarget the active tab — the navigating frame is the visible one the user
|
||||
// just clicked in — which flips its seam from iframe → live editor.
|
||||
$effect(() => {
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.origin !== window.location.origin) return
|
||||
const d = e.data
|
||||
if (!d || d.type !== 'wm.session.openEditor') return
|
||||
if (d.kind !== 'script' && d.kind !== 'flow' && d.kind !== 'raw_app') return
|
||||
if (typeof d.path !== 'string') return
|
||||
const item: WorkspaceItem =
|
||||
d.kind === 'raw_app'
|
||||
? { kind: 'app', raw_app: true, path: d.path, summary: '' }
|
||||
: { kind: d.kind, path: d.path, summary: '' }
|
||||
owner?.navigate({ type: 'item', item })
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if !globalEnabled}
|
||||
<div class="p-8 text-secondary text-sm">
|
||||
Sessions are gated on the global-AI dev flag. Enable with
|
||||
<code class="text-2xs font-mono">localStorage.setItem('wm_dev_global_ai', '1')</code> and reload.
|
||||
</div>
|
||||
{:else if !sessionName}
|
||||
<div class="p-8 text-secondary">No session selected — pick one in the sidebar.</div>
|
||||
{:else if !sessionByName}
|
||||
<!-- A session_name is in the URL but no session by that name exists — e.g. a
|
||||
deleted session or a link opened in a different browser. -->
|
||||
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-primary font-medium">Session not found</p>
|
||||
<p>
|
||||
No session named <code class="font-mono text-2xs">{sessionName}</code> exists. It may have been
|
||||
deleted, or this link was created in a different browser.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="xs" startIcon={{ icon: Plus }} onclick={startNewSession}>New session</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative flex-1 min-h-0">
|
||||
{#each warmSessions as s (s.id)}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col {s.id === activeSession?.id
|
||||
? 'z-10 opacity-100 pointer-events-auto'
|
||||
: 'z-0 opacity-0 pointer-events-none'}"
|
||||
aria-hidden={s.id !== activeSession?.id}
|
||||
<!-- A tab mutation inside the owner's debounce window would be lost to a
|
||||
reload/navigation; hidden fires before pagehide, so flush there. -->
|
||||
<svelte:document
|
||||
onvisibilitychange={() => {
|
||||
if (document.visibilityState === 'hidden') owner?.flushNow()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
{#if embedded}
|
||||
<!-- Rendered inside a preview iframe — opening the sessions UI here would
|
||||
recurse. Offer to break out to the top-level window instead. -->
|
||||
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
|
||||
<p class="text-primary font-medium">Sessions can't open inside a preview</p>
|
||||
<p>This page is being previewed in a session panel. Open it at the top level instead.</p>
|
||||
<Button
|
||||
size="xs"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
onclick={() => {
|
||||
const u = new URL(window.location.href)
|
||||
u.searchParams.delete('nomenubar')
|
||||
window.top?.location.assign(u.pathname + u.search)
|
||||
}}>Open sessions</Button
|
||||
>
|
||||
<SessionWrapper sessionId={s.id} />
|
||||
</div>
|
||||
{:else if !globalEnabled}
|
||||
<div class="p-8 text-secondary text-sm">
|
||||
Sessions are gated on the global-AI dev flag. Enable with
|
||||
<code class="text-2xs font-mono">localStorage.setItem('wm_dev_global_ai', '1')</code> and reload.
|
||||
</div>
|
||||
{:else if !sessionState.hydrated}
|
||||
<!-- Sessions hydrate from IndexedDB after the user resolves; until then an
|
||||
empty list means "loading", so the not-found branch below must not fire. -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if !sessionName}
|
||||
<div class="p-8 text-secondary">No session selected — pick one in the sidebar.</div>
|
||||
{:else if !sessionByName}
|
||||
<!-- A session_name is in the URL but no session by that name exists — e.g. a
|
||||
deleted session or a link opened in a different browser. -->
|
||||
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-primary font-medium">Session not found</p>
|
||||
<p>
|
||||
No session named <code class="font-mono text-2xs">{sessionName}</code> exists. It may have
|
||||
been deleted, or this link was created in a different browser.
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="xs" startIcon={{ icon: Plus }} onclick={startNewSession}>New session</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 min-h-0 flex flex-row relative" use:splitterPointerCapture>
|
||||
<Splitpanes
|
||||
horizontal={false}
|
||||
class="flex-1 min-h-0 splitter-hidden {previewCollapsed ? 'splitter-off' : ''}"
|
||||
>
|
||||
{#if !fullscreen}
|
||||
<!-- Chat column. Warm sessions stay mounted (stacked, visibility-toggled)
|
||||
so switching between them preserves chat scroll/draft state. -->
|
||||
<Pane bind:size={chatPaneSize} minSize={25} class="flex flex-col min-h-0">
|
||||
<div class="relative flex-1 min-h-0">
|
||||
{#each warmSessions as s (s.id)}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col {s.id === activeSession?.id
|
||||
? 'z-10 opacity-100 pointer-events-auto'
|
||||
: 'z-0 opacity-0 pointer-events-none'}"
|
||||
aria-hidden={s.id !== activeSession?.id}
|
||||
>
|
||||
<SessionWrapper sessionId={s.id} hideEditor />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
|
||||
<!-- Preview panel: the live Windmill page, framed like the editor pane.
|
||||
Always mounted (collapse resizes it to 0 — see previewPaneSize) so
|
||||
warm sessions' preview hosts survive a collapsed active session. -->
|
||||
<Pane
|
||||
bind:size={previewPaneSize}
|
||||
minSize={previewCollapsed ? 0 : 30}
|
||||
maxSize={previewCollapsed ? 0 : 100}
|
||||
class="flex flex-col min-h-0"
|
||||
>
|
||||
<div class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}">
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 overflow-hidden relative bg-surface {fullscreen
|
||||
? ''
|
||||
: 'rounded-md border border-light'}"
|
||||
>
|
||||
{#if !fullscreen}
|
||||
<!-- Collapse the preview panel — floats over the top-left corner so
|
||||
the tab strip keeps the full width. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => owner?.setCollapsed(true)}
|
||||
title="Collapse preview"
|
||||
aria-label="Collapse preview"
|
||||
class="absolute top-1 left-1 z-30 inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover bg-surface-secondary"
|
||||
>
|
||||
<PanelRightClose size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
|
||||
corner to mirror the collapse control. -->
|
||||
<div class="absolute top-1 right-1 z-30 flex items-center gap-0.5">
|
||||
<a
|
||||
href={withWorkspaceParam(
|
||||
owner?.activeTab?.loc || owner?.activeTab?.url || `${base}/`,
|
||||
previewWorkspace
|
||||
)}
|
||||
title="Open in workspace"
|
||||
aria-label="Open in workspace"
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover bg-surface-secondary"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (fullscreen = !fullscreen)}
|
||||
title={fullscreen ? 'Exit full screen' : 'Full screen'}
|
||||
aria-label={fullscreen ? 'Exit full screen' : 'Full screen'}
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover bg-surface-secondary"
|
||||
>
|
||||
{#if fullscreen}
|
||||
<Minimize2 size={14} />
|
||||
{:else}
|
||||
<Maximize2 size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab strip: open preview pages. "+" opens the router picker to
|
||||
add more. -->
|
||||
<div
|
||||
class="flex items-center gap-1 h-8 border-b border-light shrink-0 bg-surface-secondary overflow-x-auto {fullscreen
|
||||
? 'pl-1.5'
|
||||
: 'pl-9'} pr-16"
|
||||
>
|
||||
{#each owner?.tabs ?? [] as tab (tab.id)}
|
||||
<div
|
||||
class="group/tab flex items-center gap-1 shrink-0 max-w-[14rem] h-6 pl-2 pr-1 rounded-md text-xs border transition-colors {tab.id ===
|
||||
owner?.activeId
|
||||
? 'bg-surface text-primary border-light'
|
||||
: 'text-secondary border-transparent hover:bg-surface-hover'}"
|
||||
>
|
||||
{#if tab.id === owner?.activeId}
|
||||
<!-- Active tab doubles as its own breadcrumb picker. -->
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
disableFocusTrap
|
||||
closeOnOtherPopoverOpen
|
||||
enableFlyTransition
|
||||
bind:isOpen={activeTabPickerOpen}
|
||||
openFocus="[data-workspace-picker-search]"
|
||||
class="flex items-center gap-1.5 min-w-0 cursor-pointer"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span class="truncate">{tabLabel(tab.loc)}</span>
|
||||
<ChevronDown size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<PreviewRouterPicker
|
||||
initialScope={activePickerScope}
|
||||
initialHighlight={activePickerHighlight}
|
||||
{currentItem}
|
||||
workspaceId={previewWorkspace}
|
||||
onPick={(t) => {
|
||||
activeTabPickerOpen = false
|
||||
navigatePreviewTo(t)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
onclick={() => selectTab(tab.id)}
|
||||
title={tabLabel(tab.loc)}
|
||||
>
|
||||
<span class="truncate">{tabLabel(tab.loc)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => closeTab(tab.id)}
|
||||
title="Close tab"
|
||||
aria-label="Close tab"
|
||||
class="shrink-0 inline-flex items-center justify-center w-4 h-4 rounded text-tertiary hover:text-primary hover:bg-surface-hover opacity-0 group-hover/tab:opacity-100"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
disableFocusTrap
|
||||
closeOnOtherPopoverOpen
|
||||
bind:isOpen={newTabOpen}
|
||||
enableFlyTransition
|
||||
openFocus="[data-workspace-picker-search]"
|
||||
class="shrink-0 inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover cursor-pointer"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Plus size={14} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<PreviewRouterPicker
|
||||
workspaceId={previewWorkspace}
|
||||
onPick={(t) => {
|
||||
newTabOpen = false
|
||||
openInNewTab(t)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- One host per tab of every warm session, stacked and
|
||||
visibility-toggled so switching tabs or sessions never reloads
|
||||
a mounted tab — hosts live as long as the session's runtime,
|
||||
content-gated by the shared mount MRU. Each host renders a
|
||||
live editor (script/flow/raw_app target) or an iframe fallback. -->
|
||||
<div class="relative flex-1 min-h-0">
|
||||
{#each warmSessions as s (s.id)}
|
||||
{@const rt = getRuntime(s.id)}
|
||||
{@const tabs = rt?.previewTabs}
|
||||
{#each tabs?.tabs ?? [] as tab (tab.id)}
|
||||
<PreviewTabHost
|
||||
bind:this={tabHosts[tabKey(s.id, tab.id)]}
|
||||
{tab}
|
||||
session={s}
|
||||
runtime={rt}
|
||||
active={s.id === activeSession?.id && tab.id === tabs?.activeId}
|
||||
mounted={mountedTabKeys.has(tabKey(s.id, tab.id))}
|
||||
label={tabLabel(tab.loc)}
|
||||
onNavigate={navigateEditorTo}
|
||||
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if (owner?.tabs.length ?? 0) === 0}
|
||||
<!-- New session with nothing to preview: an empty state with a
|
||||
picker to open one, instead of defaulting to the home page. -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center px-6 bg-surface"
|
||||
>
|
||||
<MonitorPlay size={28} class="text-tertiary" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-secondary">No preview open</span>
|
||||
<span class="text-xs text-tertiary max-w-xs"
|
||||
>Open a page, flow, script or app to preview it alongside the chat.</span
|
||||
>
|
||||
</div>
|
||||
<Popover
|
||||
placement="bottom"
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
disableFocusTrap
|
||||
closeOnOtherPopoverOpen
|
||||
bind:isOpen={emptyStateNewTabOpen}
|
||||
enableFlyTransition
|
||||
openFocus="[data-workspace-picker-search]"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs border border-light text-secondary hover:bg-surface-hover cursor-pointer"
|
||||
>
|
||||
<Plus size={14} /> Open a preview
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<PreviewRouterPicker
|
||||
workspaceId={previewWorkspace}
|
||||
onPick={(t) => {
|
||||
emptyStateNewTabOpen = false
|
||||
openInNewTab(t)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{#if previewCollapsed && !fullscreen}
|
||||
<!-- Collapsed preview: no rail — a floating launcher in the top-right to
|
||||
reopen the side panel. -->
|
||||
<div class="absolute top-2 right-3 z-50">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
title="Open side panel"
|
||||
onclick={() => owner?.setCollapsed(false)}
|
||||
>
|
||||
Open side panel
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Invisible-but-draggable splitter between the chat and the preview: a real
|
||||
(layout-occupying) gutter, wide enough to grab. No overlap tricks — the
|
||||
zone can't cover the chat's scrollbar or the preview's edge. */
|
||||
:global(.splitpanes--vertical.splitter-hidden) > :global(.splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0 !important;
|
||||
width: 10px !important;
|
||||
}
|
||||
|
||||
/* Collapsed preview: the pane is resized to 0 but stays mounted, so remove
|
||||
the (invisible) gutter entirely — it would otherwise leave a dead 10px
|
||||
drag zone on the chat's right edge. */
|
||||
:global(.splitpanes--vertical.splitter-off) > :global(.splitpanes__splitter) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common'
|
||||
import { Alert, Button, CopyButton, Section, Skeleton, Tab, Tabs } from '$lib/components/common'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
|
||||
@@ -1304,8 +1304,13 @@
|
||||
|
||||
<CenteredPage wrapperClasses="pb-0 h-screen" handleOverflow={false} class="flex flex-col h-full">
|
||||
{#if $userStore?.is_admin || $superadmin}
|
||||
<PageHeader title="Workspace settings: {$workspaceStore}"
|
||||
>{#if $superadmin}
|
||||
<PageHeader title="Workspace settings: {$workspaceStore}">
|
||||
{#snippet titleActions()}
|
||||
{#if $workspaceStore}
|
||||
<CopyButton value={$workspaceStore} title={`Copy id: ${$workspaceStore}`} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if $superadmin}
|
||||
<Button variant="default" size="sm" on:click={() => goto('#superadmin-settings')}>
|
||||
Instance settings
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user