feat: sessions page with isolated AI chat + flow editor (#9034)

* feat(sessions): chat + editor side-by-side with multi-session state

Introduces the Sessions feature: a workspace where the AI chat and an
editor (flow / script / app / raw-app) sit side-by-side, with each session
having its own AIChatManager instance, history, and target item. Sessions
are persisted across reloads and can be staged into forks for review.

Key pieces:

- sessions/ — SessionWrapper (the split-pane shell), SessionPicker
  (sidebar list), SessionForkBar, SessionWorkspaceBar, FlowEditorView /
  ScriptEditorView / AppEditorView / RawAppEditorView, ForkDiffDrawer,
  sessionRuntime (per-session AIChatManager + draft state),
  sessionState (in-memory + persisted index), sessionUnread, sessionScope,
  appDraftCodec / flowDraftCodec, forkEditUrl, /sessions route.

- WorkspaceItemDrillPicker refactor — extracts WorkspaceItemRow + adds
  surfaceAI drafts, stale-while-revalidate. workspacePicker.ts drops
  explicit invalidate() in favor of always re-fetching in the background.

- ForkDiffDrawer + WorkspaceItemDiffViewer — per-kind diff bodies
  reusable from the compare page. FlowGraphDiffViewer / FlowGraphV2 gain
  inlineDiff forwarding + onHeight callback for equal-height layout.

- Global AI chat sessions plumbing — AIChatManager exports the class +
  adds disabledModes, beforeSend hook, scoped instance context. AIChat /
  AIChatDisplay accept session-only props (wideLayout, emptyHint,
  inputPreface, hideHeader, hideModeSelector, forceDisabled). Chat
  preserved across /flows/add → /flows/edit, /scripts/add → /scripts/edit.

- Draft-first loaders — sessions open drafts when present, otherwise
  seed a draft from the last deployed value via globalDraftStore.
  RawAppEditor / AppEditor / AppEditorHeaderDeploy get newApp prop +
  fixes so draft-only apps can deploy.

- Compare page (/forks/compare) — bigger overhaul to plug into the new
  drawer.

- Sidebar — Sessions entry + unread badge + status dot in
  SidebarContent / MenuButton / SideBarNotification.

- Misc fixes — chat group color palette constraint, deploy_workspace_item
  confirmation dropped, open_preview tool, picker drafts surfacing,
  fork archive/delete buttons on compare page.

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

* fix(sessions): bypass UserDraft inside session panes + sessionUnread crash

After merging main's UserDraft PR (#9121) into the sessions branch, two
integration issues surfaced:

1. AppEditor.svelte calls `UserDraft.use<App>('app', path)` at the
   component level — keyed by ($workspaceStore, 'app', path). Sessions
   that haven't materialized a fork yet stay at the user's main
   workspace, so a session targeting an app at the same path as a
   regular /apps/edit tab shared the same LS key. The session would
   read the regular tab's autosave and write its fork-edits back over
   it.

   Gate UserDraft.use on `!getContext('aiChatManager')` — sessions
   inject the manager via setContext, so inside a session pane the
   handle is `undefined`, stateApp falls through to the `app` prop
   the session loaded, and the auto-save $effect bails. Same gate on
   the four UserDraft.remove call sites in AppEditorHeader and
   RawAppEditorHeader so save/deploy from a session pane doesn't wipe
   the LS draft of a non-session tab at the same path.

2. sessionUnread.svelte.ts called useLocalStorageValue at module
   scope. Main's PR added a deep-mutation $effect inside that helper,
   which now requires component-initialization context — every page
   crashed at import time with `Svelte error: effect_orphan`.
   Replaced with a plain module-level $state + manual localStorage
   persist; same reactivity contract for callers.

3. ScriptEditorView.svelte was passing a `replaceStateFn` prop that
   ScriptBuilder dropped on main. Removed.

Verified end-to-end with Playwright:
- /flows/edit/{path} regression: UserDraft handle still created, no
  console errors
- /sessions loads, sessionUnread doesn't crash
- Session targeting non-raw app `u/admin/userdraft_collision_test`
  displays the fork content (FORK_ONLY_MARKER) even with an LS
  poison at `userdraft/w/local/app/{path}` containing a
  POISONED_BY_REGULAR_TAB_AUTOSAVE marker; poison remains untouched
  after the session loads and renders

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

* fix(sessions): stop fork-create retry loop on first user message

Removed the SessionWrapper $effect that retroactively committed the
session's workspace from the in-memory chat history. When opening a
session whose previous commit attempt had failed (or whose response was
lost) the effect ran in a tight retry loop, flooding the user with
`workspace_pkey` violations from `create_workspace_fork`.

The send path already commits through `AIChatManager.beforeSend` →
`commitSessionWorkspace`, which is the deterministic moment-of-action.
The $effect was a redundant reactive bridge that turned every backend
failure into an infinite retry.

Also hardens `materializeFork`/`commitSessionWorkspace` so the most
common cause of the duplicate-key error self-heals:

- `materializeFork` short-circuits when `fork.id` is already in
  `$userWorkspaces` (the previous create actually succeeded, we just
  lost the response). On a `workspace_pkey` catch, refresh the workspace
  list and adopt the existing row instead of toasting an error.
- On a real `materializeFork` failure, `commitSessionWorkspace` now
  drops `pending_fork` so the session falls through to the
  workspace-pick fallback instead of looping on the same broken intent.

* feat(sessions): show EditorHeader breadcrumb in the not-found state

When a session's target item has been deleted or moved, the editor pane
used to render a bare "Script not found at path X" line — leaving the
user with no way to navigate to a different target without backing out
of the session.

Each editor view now renders a `SessionItemNotFound` shell instead: a
real `EditorHeader` (read-only summary, no pen popover) with a
breadcrumb keyed to the missing kind+path, plus the "not found" copy
below. Clicking any breadcrumb segment opens the workspace picker
scoped to that level — pick a replacement and the session swaps target
via the existing `onNavigate` callback.

`SessionItemNotFound` maps `raw_app` to `EditorHeader`'s `kind: 'app'
+ raw_app: true` so the picker routes through `/apps_raw/...`; the
local label still says "Raw app not found" (not "App not found") so
the user knows which surface is missing.

* fix(picker): stop self-feeding fetch effect that OOM'd the tab

The drill picker's $effect watched `scope` and called `ensureLoaded`
on every change. `ensureLoaded` reads `loaded[kind]` synchronously
(to decide whether to show a spinner), so the effect ended up
subscribed to the very signal it fills. Each fetch result wrote
`loaded[kind] = items`; Svelte 5's $state proxy notifies on every
property set even when the reference is unchanged from cache, which
refired the effect, which called `ensureLoaded` again, which awaited
the cached fetch, which wrote `loaded[kind]` again... runaway loop.

In `/scripts/edit/...` the picker's lifecycle stabilised quickly
enough to mask the loop, but in a session pane (multiple warm
sessions, picker kept alive by the surrounding state) the cycle
spun freely — 29.8 million iterations in <100 ms during testing,
enough to OOM Firefox / kill the Chromium tab.

Two changes:

- Replace the scope-watching $effect with an explicit `setScope()`
  helper called from `drill()`, `goUp()`, and `onMount`. Fetch is
  now a callback reaction to user navigation, never a reactive
  consequence of one. No closed feedback cycle is possible.

- Untrack the `loaded[kind]` read inside `ensureLoaded`. The search
  $effect (which loads every kind on first keystroke) is still a
  reactive caller; the untrack stops it from subscribing to the
  signal `ensureLoaded` fills, so the same loop can't form there.

* feat(script-editor): wire initialTestPanelCollapsed through ScriptBuilder

The `initialTestPanelCollapsed` prop was already declared on
`ScriptBuilderProps` (used by the session preview to start the editor
with the run/test pane closed) but never destructured in
`ScriptBuilder.svelte`, so the value silently dropped on the floor
and the test pane always opened.

- `ScriptBuilder.svelte` — destructure the prop and forward it to
  `<ScriptEditor>`.
- `ScriptEditor.svelte` — accept the prop and seed `rawTestPanelSize`
  to 0 when true, while keeping `storedTestPanelSize` at the default
  30 so the user's first toggle expands the pane to a sensible width
  rather than 0.

Regular `/scripts/edit/...` doesn't pass the prop → default `false`
→ panel still opens by default.

* fix(sessions): resolve aiChatManager via context in AskUserQuestionDisplay

Inside a session the chat uses a per-pane AIChatManager injected via context. AskUserQuestionDisplay imported the global singleton, so answers clicked in a session dispatched to the singleton's callback map and the AI loop stalled. Resolve via getContext with singleton fallback, matching ChatMode / ToolExecutionDisplay.

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

* fix(raw_apps): let preview start in single-view on the preview tab

Add a defaultSplitWithPreview prop (default true). When false (session preview), the editor boots in single view with the preview tab selected: gate the onMount default-file activation, the setActiveDocument auto-activation, and iframeShouldMount so the UI Builder bundler iframe still mounts when preview is the active tab. RawAppEditorView passes defaultSplitWithPreview={false}.

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

* feat(copilot): add get_preview_status tool and make open_preview idempotent

So the assistant can tell whether the session preview already shows the item it just edited, instead of re-opening or re-offering it. Mirrors the open_preview handler plumbing (setGetPreviewStatusHandler) and the session runtime registers it alongside open_preview. open_preview now returns 'already open' when the requested target matches the active session's current target. The system prompt steers the AI to check status before offering. Unit tests cover the no-arg schema, the session-only error, and handler dispatch.

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

* fix(sessions): make script preview reactive to AI draft writes

ScriptEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists. None did for the preview path, so the chat's writes (UserDraft.save) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft, materializing the shared $state cell that bridges the chat's writes to the editor.

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

* fix(sessions): make raw-app preview reactive to AI draft writes

Mirror of the script-preview fix. RawAppEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists. None did for the preview path, so the chat's raw-app writes (UserDraft.save / setDraftAndMeta, from write_app_file / patch_app_file / write_app_runnable) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft. Verified in-browser: an external UserDraft.save live-updates the bound summary in the open preview.

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

* fix(sessions): make flow preview reactive to AI draft writes

Mirror of the script/raw-app preview fixes, completing two-way binding for all three session editor kinds. FlowEditorView read the draft via static UserDraft.get inside an effect, which only subscribes to UserDraft's reactive cell when a live entry exists — none did, so the chat's writes (write_flow / patch_flow_json / set_flow_module_code) only touched localStorage and the open preview never updated. Hold a live handle via UserDraft.useMany (reactive getter so it re-acquires when open_preview swaps the path without remounting) and read inbound through handle.draft. Verified in-browser both directions: an external UserDraft.save live-updates the flow header summary and rebuilds the module graph; a preview edit propagates through the debounced save to both UserDraft.get and the chat's getGlobalDraft adapter.

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

* feat(sessions): surface local-storage drafts in fork diff & compare page

Augments the backend fork-vs-parent comparison with browser-local (UserDraft) drafts so a session's uncommitted AI/user changes are visible in the Fork Diff Viewer and the /forks/compare page. Adds forkDraftDiff.ts (augmentForkComparisonWithLocalDrafts + getForkItemValue), a 'local changes detected' / new-draft warning surface (checkbox-slot warning icon, no-op-baseline filtering, dedup), a 'Local draft <> fork' tab in DiffDrawer, and selectTooltip/nonSelectableTooltip plumbing in Row/WorkspaceDeployLayout.

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

* Revert "feat(sessions): surface local-storage drafts in fork diff & compare page"

This reverts commit 3cfd858e36.

* fix(sessions): leave for home when switching workspace from the session page

An AI session is scoped to its (forked) workspace, so it makes no sense to keep showing it after the user picks a different workspace. The workspace switcher's link href now points home on the session route (the link navigation wins over onClick's preventDefault), and toggleSwitchWorkspace also redirects home there as a fallback. Session-switching uses a separate path (syncWorkspaceTo), so it's unaffected — which is why reacting at the switcher is more robust than watching workspaceStore.

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

* fix(sessions): clear session highlight off the session page; default delete-fork on

Two SessionPicker fixes: (1) only highlight the active session while on the /sessions route — currentSessionId lingers after navigating away, so the row stayed selected in the sidebar; gate the highlight on the route. (2) The 'Also delete forked workspace' toggle in the delete-session modal now defaults to on (the fork is tied to the session and would be orphaned otherwise); resets keep it defaulted-on for the next open. User can still untick it.

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

* fix(copilot): say "local storage" instead of "draft" in write-tool status

The global chat's write tools persist to the browser's localStorage (UserDraft), not a workspace draft. The tool status / result messages now say the item was saved to local storage (and discard says it was discarded from local storage) so users aren't misled into thinking a workspace draft was created. Covers the shared script/flow/trigger/resource/variable helpers and the app tools.

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

* fix(sessions): sidebar collapse, new-session chat, fork delete & not-found nits

- Hide the collapse chevron and make the section header non-interactive when there are no sessions; reset the persisted collapsed state while the list is empty so the first session always appears expanded.
- Stop grafting a recent past chat onto a freshly created session: ensureChatIdsSeeded now skips transient sessions, so the seed only pairs untagged chats with pre-existing sessions.
- After deleting a fork from a session (SessionPicker / SessionWrapper), fall back to the fork's parent workspace when the deleted fork was the active one, instead of stranding the user on a deleted workspace.
- Show a 'Session not found' message (with a New session action) when the URL names a session that doesn't exist, rather than rendering a blank page.

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

* feat(copilot): expose preview tools only to session chats

open_preview and get_preview_status drive a session's side-panel editor, so they only make sense inside an AI session. They were always present in the global tool list and just errored when called outside a session. Now AIChatManager carries an isSessionChat flag (set by sessionRuntime.createRuntime); the GLOBAL-mode branch uses globalToolsFor({ sessionPreview }) to drop the two tools for the regular side-panel chat, and prepareGlobalSystemMessage omits their guidance unless previewTools is set. The module-level handlers + in-tool error guards stay as defense in depth.

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

* chore(flow-editor): move intra-editor chat preservation to its own PR

The beforeNavigate / preserveChatOnDestroy guard that keeps the global FLOW
chat alive across same-flow editor remounts is a standalone global-chat fix,
unrelated to sessions. Split out to #9339; FlowEditor reverts to the plain
session-guarded saveAndClear lifecycle here.

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

* fix(raw-app): pre-boot session editor hidden so files open instantly

In single-view (sessions) the UI Builder iframe was mounted inside a
display:none wrapper while the Preview tab was active, so the VS Code
workbench booted at 0x0, threw in its LayoutService ("Unable to figure
out browser width and height"), and wedged the editor on "Loading
editor" with no recovery when later revealed.

Keep the iframe mounted at the editor area's real width and hide it with
visibility instead of collapsing it: Monaco boots correctly while hidden,
and revealing a file is an instant un-hide (no reload, no relayout, no
latency).

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

* chore(flow-ai): move flow-group color-palette work to its own PR

The flow-group color-palette guidance + validateFlowGroups guard + tests are
an independent flow-AI improvement, not part of sessions. Split out to #9343;
these three flow files revert to their main state here.

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

* fix(sessions): hide the in-editor Flow AI Chat button in the session preview

The flow preview pane in a session already sits next to the session's own AI
chat, so FlowBuilder's in-editor "Flow AI Chat" toggle (which opens the global
singleton chat) is redundant and confusing there. Pass
customUi={{ topBar: { aiBuilder: false } }} from FlowEditorView, reusing the
existing showFlowAiButton gate (!disableAi && customUi?.topBar?.aiBuilder !=
false) that flows down to FlowStickyNode — no new prop needed.

Verified in-browser: the button (WandSparkles) renders in the regular
/flows/edit route but is absent in the session preview for the same flow.

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

* fix(sessions): mirror /scripts/add for never-saved scripts in editor preview

An AI-created script with no backend version yet left savedScript undefined
in the session preview, which disabled Save draft and hid Show diff. Open it
as a new script (empty initialPath) like /scripts/add so Save draft is enabled
and creates it on first save; seed the path as already-chosen
(initialPathChosen) so the summary->path auto-slug does not rename the
AI-assigned path. On first save ScriptBuilder writes savedScript back through
the bind and flips into edit mode (Save draft + Show diff) without navigating
away.

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

* fix(sessions): refresh fork diff count after an editor draft save

The fork-bar diff count reads a cached comparison refreshed only on AI-turn-end or tab refocus. A 'Save draft' in the session editor registers in the backend fork tally asynchronously (~300ms after the create returns), so the count stayed stale until one of those triggers fired. Add SessionRuntime.scheduleForkComparisonRefresh() (re-fetches at 700ms + 2200ms to clear the async tally) and wire it to onSaveDraft in ScriptEditorView and FlowEditorView.

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

* fix(sessions): don't auto-open the settings drawer in script preview

When the AI's open_preview tool previews a never-saved script, ScriptEditorView
passes initialPath='' so ScriptBuilder behaves like /scripts/add. That empty
path also triggered ScriptBuilder's auto-open of the settings drawer, which is
unwanted in the session preview where the AI manages metadata. Pass
neverShowMeta so the drawer stays closed on mount; the Settings button still
opens it manually.

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

* fix(sessions): don't host legacy drag-and-drop apps in the editor preview

The session preview pane only hosts code-based items (flow, script, raw
app). Drop the legacy 'app' kind from SessionTarget and the open_preview
tool, and route a legacy app picked in the drill picker to the standalone
/apps/edit editor instead. Removes the now-dead AppEditorView and its
runtime load path.

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

* fix(sessions): don't prompt to discard raw-app changes on navigation

In a session the raw-app editor's content is continuously persisted to the
UserDraft (localStorage), so tearing the editor down on navigation loses
nothing. Skip the UnsavedConfirmationModal (and its beforeNavigate guard)
when the editor is mounted inside a session pane; the standalone /apps_raw
editor still shows it.

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

* fix(sidebar): pin Help to the bottom instead of floating

The bottom of the sidebar stacked the User/Settings cluster and the Help
block with a fixed ~40px gap between them, plus a bottom margin that kept
Help from sitting flush — so Help appeared to float. Drop those fixed
margins so the cluster and Help stay glued at the bottom with a small gap
and Help is flush, and let mt-auto own the flexible space above the group.
Add pt-4 so the cluster keeps a minimum gap from the Triggers section when
the sidebar runs out of room and that flexible space collapses.

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

* fix(sessions): surface diff/discard for AI script drafts in preview, refresh diff on deploy

loadScript built the editor's scriptStore by aliasing and mutating savedScript.val, so the deployed baseline got overwritten with the draft content and the diff compared draft-vs-draft. Clone the baseline before layering the AI draft on top. On load, when the local draft diverges from the saved baseline, surface a toast ('AI saved a local draft') with Show diff (opens the diff drawer with a Discard-draft button) and Discard local draft — mirroring the regular /scripts/edit affordance the session's parallel loader omitted. Also wire onDeploy (alongside onSaveDraft) to scheduleForkComparisonRefresh so the fork diff count refreshes after a deploy, not just on an AI turn or tab refocus.

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

* fix(sessions): script preview restore/deploy feedback; drop on-load draft toast

- Implement real restoreDeployed/restoreDraft for the diff drawer: the shared loadScript-based handler was a no-op (loadScript early-returns on the loaded path and would re-read the local draft). Reset the live UserDraft handle to the chosen baseline (deleting the backend draft for 'restore to deployed') so the inbound effect syncs the editor.
- Show a 'Deployed' toast on deploy: the default Deploy takes ScriptBuilder's no-toast branch (the editor navigates away instead); the session stays put, so surface the success toast.
- Remove the on-load 'AI saved a local draft' toast: unnecessary in a session, where the user already expects their changes to be present. Diff/discard remain reachable via ScriptBuilder's Show diff + the diff drawer's restore buttons.

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

* fix(sessions): gate breadcrumb picker draft-merge behind the dev flag

The WorkspaceItemDrillPicker merges localStorage UserDrafts into its
navigable items so in-flight session/chat drafts are reachable. That
merge was ungated, so with the sessions dev flag off it also surfaced
the standalone editors' autosave drafts — they appeared as navigable
rows that 404 on the backend draft fetch. Gate aiDraftsForKind on
isGlobalAiEnabled() so it is a no-op without the flag (no sessions
exist then anyway); inside sessions the merge still works.

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

* fix(editor): reload script/flow editor on client-side breadcrumb nav

Picking a different item in the editor-header breadcrumb picker calls
goto() for a client-side navigation. SvelteKit reuses the same +page
instance across a path-param change, but the script and flow editor
routes captured `draftPath` and the `UserDraft.use()` handle once at
mount and never remounted ScriptBuilder/FlowBuilder. The URL and title
updated while the editor kept showing the previous item's breadcrumb,
summary and content; only a full reload showed the navigated-to item.

Mirror the pattern the app / raw-app editors already use:
- Derive the draft path from the URL and key the handle off it via
  `UserDraft.useMany` (a stable proxy onto the current handle), so the
  reload reads/writes the navigated-to item's draft instead of the
  previous one's — fixing the stale draft-comparison too.
- Gate the builder subtree on a `renderEditor` flag flipped false when a
  navigation kicks off the reload and true once the data is ready, so
  the builder cleanly unmounts and remounts once against stable data. A
  synchronous `{#key}` swap instead races Monaco's async init against
  the torn-down container.
- Flows also reset `nobackenddraft` per navigation so a fresh load
  reconsiders the backend draft.

The unsaved-changes guard is unaffected (it runs in beforeNavigate,
before the remount). The app and raw-app editors already handled this
and are unchanged.

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

* feat(sessions): sync preview with the deployed version on editor + chat deploy

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

* feat(sessions): reload the preview after a chat raw-app deploy

The deploy-reload-preview callback added previously only fired for script and
flow. Now that the merged deploy_workspace_item tool can deploy raw apps
(bundle + createAppRaw/updateAppRaw), wire raw apps in too. A raw app deploys
under type 'app' but the session preview addresses it as 'raw_app', so the
deploy handler maps 'app' -> 'raw_app'; the runtime open-check gains the
loadedRawAppPath case. syncPreviewWithDeployed already handled 'raw_app'
(discard the local draft + force-reload via loadRawApp), so no runtime change
was needed there.

Adds a unit test asserting deploy_workspace_item(type:'app') notifies the
session handler with { kind: 'raw_app', path }.

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

* fix(sessions): address Claude PR review (3 P1 + 3 P2 + test)

P1:
- Drop the hardcoded placeholder default sessions (u/guilhempw/...). New users
  (empty/cleared/private-browsing localStorage) now start with no sessions and
  see the empty state instead of unresolvable "session not found" rows.
- Scope the preview/deploy tool handlers to the *calling* session. open_preview,
  get_preview_status and the deploy reload handler dispatched via the global
  currentSessionId, so a backgrounded session's tool call mutated the UI-active
  session. The calling session id is now carried in the per-manager tool
  `helpers` (AIChatManager.sessionId, set in createRuntime) and threaded through
  the tool ctx to the handlers, which dispatch to it (falling back to the active
  id only when absent). Keeps backgrounded sessions isolated.
- beforeSend now aborts the send on failure: commitSessionWorkspace throwing used
  to be swallowed, letting the message go out against the wrong workspace
  silently. Now it toasts and returns. Also guarded the unguarded
  listUserWorkspaces refresh in materializeFork's duplicate-key self-heal so a
  second network failure can't rethrow past the toast-and-return contract.

P2:
- disposeRuntime now clears the fork-comparison refresh timers (700ms/2200ms)
  via a new runtime.dispose(), so an evicted/deleted runtime can't fire a stray
  refreshForkComparisonNow/compareWorkspaces after teardown.
- Convert Svelte 4 on:click -> Svelte 5 onclick on the Button components in
  SessionWrapper, SessionForkBar, ForkDiffDrawer, SessionPicker, sessions/+page.
- WorkspaceItemRow's <a href> branch gains role="option" + aria-selected to match
  the <button> branch, for consistent listbox semantics.

Tests:
- core.test.ts: deploy_workspace_item(type:'app') threads the calling session id
  through helpers to the deploy handler ({ sessionId, kind:'raw_app', path }).
- New sessionState.test.ts unit-tests deriveForkStatus + isForkSession across
  all branches (root/fork/unavailable/draft, ahead/behind/diverged/in_sync).

svelte-check 0 errors; 57 frontend unit tests pass.

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

* refactor(copilot): collapse deploy preview-reload dispatch to a type→kind map

Replace the if/else-if that mapped deploy type to preview kind with a single Partial<Record<WorkspaceItemType, ...>> lookup + one if. Non-previewable types map to undefined → no dispatch.

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

* refactor(copilot): use getAiChatManager() instead of inlining the context fallback

Six chat components still inlined
`getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager` even
though aiChatManagerContext.ts already exports getAiChatManager() for exactly
this (the resolve-scoped-instance-or-fall-back-to-singleton pattern, already
used by AIChatDisplay/AIChatInput/AIChatMessage/CodeDisplay). Adopt it in
DatatableCreationPolicy, ChatMode, ToolExecutionDisplay, AIChat,
AskUserQuestionDisplay and flow/FlowAIChat, and drop the now-unused getContext /
AIChatManager / singletonAiChatManager imports (FlowAIChat keeps getContext for
its FlowEditorContext/FlowCopilotContext lookups).

No behavior change — getAiChatManager() is the same resolution.

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

* fix(sessions): consistent script deploy → preview sync; trim session deploy menu

Two related session deploy fixes + clarifying comments.

1. Hide the extra deploy-dropdown options in the session script preview. The
   editor always "stays" and is already scoped to a fork, so Deploy & Stay here,
   Fork, Edit in workspace fork, Exit & See details and Export as YAML/JSON make
   no sense there — only "Show diff" is kept. ScriptBuilder gains
   `inSessionPane = !!getContext('aiChatManager')` (same pattern ScriptEditor
   uses) and gates those items. (They were correctly absent for never-deployed
   session scripts but leaked for deployed ones.)

2. Fire onDeploy on every successful script deploy. ScriptBuilder previously
   skipped onDeploy for "Deploy & Stay here" and lib scripts (it just re-pinned
   parent_hash + toasted), so a session preview wouldn't sync after those. Now
   onDeploy always fires with a `stay` flag; route consumers skip navigation when
   stay (behaviour identical to before — stay → toast only, primary → navigate),
   and the session ignores stay and always syncs. With (1) hiding Deploy & Stay,
   this now covers the lib-script-in-session case.

3. Comments: RawAppEditorHeader / AppEditorHeader note that the
   `if (!inSessionPane) UserDraft.remove` guards are intentional — the editor
   doesn't own the localStorage draft in a session (the runtime does, keyed by
   the fork); the session-side equivalent is the View's onDeploy →
   runtime.syncPreviewWithDeployed (discard fork draft + reload to deployed).

svelte-check 0 errors; session dropdown verified to show only "Show diff" for a
deployed script, route deploy menu unchanged.

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

* fix(sidebar): single Menubar so bottom menus hover-switch (WIN-1993)

The bottom sidebar group split Settings/Workers/Folders/Logs and Help across
two separate <Menubar> components. melt-ui's hover-to-switch (open menu closes
when another trigger in the same Menubar is hovered) only coordinates within a
single Menubar, so hovering between the two groups left both menus open
(stacked) instead of switching. Collapse them into one Menubar, wrapping each
group in its own flex container to preserve spacing.

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

* refactor(editor): gate external code sync behind opt-in syncExternalCode prop

The unconditional `code` prop->Monaco sync effect added for sessions
live-preview ran for every <Editor> caller (14 call sites). Most either
bind:code with their own external-sync (e.g. ScriptEditor) or treat code as
init-only, so a blanket setValue risked clobbering them. Gate the effect on a
new opt-in `syncExternalCode` prop (default off) and enable it only at the two
flow inline-rawscript editors — the case that actually needs external updates
(AI chat editing a flow module's content reflecting live in the preview).

Verified in-browser: AI-driven external edit to a flow step now reflects live
in Monaco, and typing keeps the caret intact (round-trip guard).

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

* fix(sessions): address P1 review findings (commit-abort, render-stuck, workspace sync)

From the cubic/Claude PR review:

1. beforeSend now aborts the send when the workspace isn't committed. The earlier
   fix only caught a *thrown* error, but commitSessionWorkspace returns undefined
   (never throws) when a staged fork fails to materialise — so the first message
   + its tool calls shipped to get(workspaceStore) (the parent). beforeSend now
   throws on undefined so AIChatManager's catch toasts + aborts.

2. The script/flow edit reload effect set renderEditor=false then called
   loadScript()/loadFlow(); a rejected fetch left renderEditor stuck false, so the
   editor pane vanished and never remounted. Both calls now .catch → toast +
   renderEditor=true (token-safe), so the pane always remounts.

3. SessionWrapper.moveAndActivate now syncWorkspaceTo(target) — moving a session
   off an unavailable workspace was leaving the app pointed at the old one
   (mismatch with moveSessionToNewFork / handleConfirmedDelete).

Test: sessionState.test.ts pins commitSessionWorkspace's failure contract
(returns undefined + drops pending_fork when the fork fails) — the invariant the
beforeSend abort relies on. svelte-check 0 errors; 58 frontend unit tests pass.

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

* fix(sessions): address P2 review findings (cubic)

Draft round-trip:
- appDraftCodec: carry custom_path through runtimeRawAppToDraft /
  applyDraftToRuntimeRawApp (+ seed it in loadRawApp) so a session round-trip
  no longer erases a raw-app draft's custom URL.
- sessionRuntime.loadScript "no draft" path: structuredClone the baseline before
  setting parent_hash — it could alias `result` (= savedScript.val) and corrupt
  the pristine deployed baseline the diff drawer reads.
- FlowEditorView: include `summary` in the inbound/outbound dedup sigs so
  summary-only changes propagate/persist.

Workspace-state on navigation:
- SidebarContent (post-delete) and workspace_settings (post-archive): guard the
  listUserWorkspaces() refresh so a transient failure can't strand the user on
  the just-removed workspace, and refresh the list before switching to parent.
- WorkspaceMenu: keep ?workspace=<id> in the session-page workspace href so a
  modifier/middle click (which bypasses onClick) lands in the right workspace.

UI/keyboard:
- WorkspaceItemRow: indent adds to the px-3 base (calc) instead of replacing it.
- ForkDiffDrawer: ArrowLeft maps a 2-segment file path (f/foo) to its scope
  folder (folder:f/foo) instead of a nonexistent folder:f.
- flows/edit: defer flowBuilder setup (primary schedule, draft triggers,
  loadFlowState) until after the builder remounts (renderEditor=true + tick),
  so reload-time state restoration isn't skipped on the unmounted builder.

svelte-check 0 errors; 58 frontend unit tests pass.

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

* test(sessions): unit-test the P1/P2 review fixes (extract pure helpers)

Extract the pure logic touched by the review fixes into small tested helpers
(behaviour-preserving) and add unit tests:

- appDraftCodec.test.ts — custom_path survives the runtime↔draft round-trip (A1).
- forkDiffNav.ts/.test.ts — parentFolderKey (extracted from ForkDiffDrawer):
  ArrowLeft parent resolution incl. the 2-segment-path case (C2).
- workspaceMenuHref.ts/.test.ts — extracted from WorkspaceMenu: session-route
  href keeps ?workspace=<id>; off-session swaps the param (B2).
- flowDraftSig.ts/.test.ts — extracted from FlowEditorView (dedups 3 sig sites):
  the dedup signature includes summary, so summary-only changes propagate (A3).

(commitSessionWorkspace failure-contract test for the beforeSend P1 landed with
the P1 commit.) svelte-check 0 errors; 75 frontend unit tests pass.

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

* fix(sessions): address second-round review (Pi + Codex)

Three findings flagged post-push (cubic was fully addressed in the prior
commits; this commit covers the new ones):

- [P1] commitSessionWorkspace non-fork branch — when a session created
  inside a fork defaults pending_workspace_id to the family root, commit
  set s.workspace_id but never synced workspaceStore. First send's
  logAiChat + tool calls then ran against the wrong (still-fork)
  workspace. Fix: syncWorkspaceTo(ws) after the commit, mirroring the
  pending_fork branch's switchWorkspace(newId).

- [P1] Warm-session live-editor slot hijack — /sessions keeps up to 3
  warm-mounted sessions; UserDraft stores one live editor per
  (workspace, kind). Each editor view unconditionally claimed the slot,
  so a hidden warm session in the same workspace+kind could overwrite
  the visible session's claim — chat actions like discard /
  "the open editor" then resolved to the wrong session. Fix: thread
  isActiveSession from SessionWrapper into Script/Flow/RawAppEditorView
  and gate setLiveEditorDraft on it.

- [P2] ForkDiffDrawer stale per-item raw diff cache — loadedDiffs /
  summaries persist for the drawer's lifetime; fetchComparison refetched
  on each open() but loadDiffFor short-circuited on cached keys, so an
  edit-then-reopen showed fresh counts but stale expanded content. Fix:
  clear both records at the top of fetchComparison.

Tests:
- sessionState.test.ts: 2 tests pinning commitSessionWorkspace's
  workspaceStore sync (mismatch and matching).
- userDraft.test.ts: 3 tests pinning the live-editor slot collision
  (regression), the active-session gate, and cleanup ordering.
- forkDiffCache.test.ts (new): 2 tests for the drawer cache
  invalidation contract via fetchComparison simulation.

Verified end-to-end in browser: P2 (close+reopen drawer triggered an
identical second batch of per-item get fetches), P1#1 (new-session send
from a fork synced localStorage.workspace to root and posted chat to
/api/w/local/...), P1#2 (raw_app slot for workspace=local correctly
follows the visible session across A→B→A switches while both stay
warm-mounted). svelte-check 0 errors; touched test suites green.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-06-01 10:22:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 32b4771f19
commit eadeac248b
69 changed files with 7247 additions and 529 deletions
@@ -13,6 +13,7 @@
import DropdownV2Inner from './DropdownV2Inner.svelte'
import { pointerDownOutside } from '$lib/utils'
import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
@@ -40,7 +41,10 @@
size?: ButtonType.UnifiedSize
btnText?: string
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
// In customMenu mode the snippet receives the melt-ui `item` action
// store so consumers can wrap their own rows in <MenuItem> (or
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
maxHeight?: string | undefined
}
@@ -172,7 +176,7 @@
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.()}
{@render menu?.({ item, close })}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
+31 -1
View File
@@ -147,6 +147,11 @@
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
// To execute preview scripts with the right worker group
customTag?: string
// Opt-in: reflect external `code` prop mutations back into Monaco (see
// the effect below). One-way `code={...}` callers that need live
// external updates — e.g. the inline flow rawscript — set this. Off by
// default so every other caller's behavior is unchanged.
syncExternalCode?: boolean
}
let {
@@ -178,7 +183,8 @@
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined,
preparedAssetsSqlQueries,
customTag
customTag,
syncExternalCode = false
}: Props = $props()
$effect.pre(() => {
@@ -1829,6 +1835,30 @@
$effect(() => {
lang = scriptLangToEditorLang(scriptLang)
})
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
// the inline rawscript in the flow editor — otherwise mutate the prop
// without Monaco ever showing the change (the AI chat editing a flow
// module's content in a session is the motivating case). Gated off by
// default: Editor is sensitive and most callers either bind:code (and
// carry their own external-sync) or treat code as init-only, so a blanket
// setValue would risk clobbering them. The `getValue() !== code` guard
// keeps the caret intact when the change originated from typing inside
// Monaco (which round-trips code back via `$bindable`, re-firing this
// effect with `code === getValue()`).
let lastExternalCodeSync = code
$effect(() => {
if (!syncExternalCode) return
if (code === lastExternalCodeSync) return
lastExternalCodeSync = code
if (!editor) return
untrack(() => {
if (editor!.getValue() !== code) {
editor!.setValue(code ?? '')
}
})
})
$effect(() => {
filePath = computePath(path)
})
@@ -6,13 +6,28 @@
interface Props {
beforeYaml: string
afterYaml: string
/** Side-by-side vs unified. Leave undefined to let
* FlowGraphDiffViewer show its own user-facing toggle (matches the
* pre-fork-diff-drawer behavior). */
inlineDiff?: boolean
/** Forwarded to FlowGraphDiffViewer — render an empty surface
* placeholder for the "before" / "after" pane when the item is
* added / removed. */
beforeMissing?: boolean
afterMissing?: boolean
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
inlineDiff = undefined,
beforeMissing = false,
afterMissing = false
}: Props = $props()
let diffMode: 'yaml' | 'graph' = $state('graph')
</script>
<div class="flex flex-col h-full min-h-[500px] gap-2">
<div class="flex flex-col h-full min-h-[500px]">
<Tabs bind:selected={diffMode}>
<Tab value="graph" label="Graph" />
<Tab value="yaml" label="YAML" />
@@ -30,6 +45,7 @@
defaultLang="yaml"
defaultOriginal={beforeYaml}
defaultModified={afterYaml}
{inlineDiff}
readOnly
/>
{/await}
@@ -37,7 +53,7 @@
{#await import('$lib/components/FlowGraphDiffViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default {beforeYaml} {afterYaml} />
<Module.default {beforeYaml} {afterYaml} {beforeMissing} {afterMissing} {inlineDiff} />
{/await}
{/if}
</div>
@@ -2,12 +2,12 @@
import type { OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { Alert } from './common'
import { computeFlowModuleDiff } from './flows/flowDiff'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import type { Viewport } from '@xyflow/svelte'
const SIDE_BY_SIDE_MIN_WIDTH = 700
@@ -15,13 +15,54 @@
interface Props {
beforeYaml: string
afterYaml: string
/** When true, render an empty surface placeholder for the "before"
* pane in side-by-side mode (use for added items where there's no
* prior flow to show). */
beforeMissing?: boolean
/** Same as `beforeMissing` but for the "after" pane (use for removed
* items). */
afterMissing?: boolean
/** Render the unified single-pane diff when true, side-by-side
* otherwise. When undefined, the component renders its own
* Unified / Side-by-side toggle in the corner (legacy behavior for
* the standalone comparison page). A narrow viewer still falls back
* to unified automatically. */
inlineDiff?: boolean | undefined
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
beforeMissing = false,
afterMissing = false,
inlineDiff = undefined
}: Props = $props()
// Local toggle state, used only when no inlineDiff prop is supplied.
let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside')
const showLocalToggle = $derived(inlineDiff === undefined)
const effectiveInlineDiff = $derived(
inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified'
)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
// Track the content area's rendered height so unified-mode graphs can
// grow to fill the diff box (otherwise FlowGraphV2 sits at its
// content-fit height + small floor, leaving empty space below).
let contentAreaHeight = $state(0)
// Each FlowGraphV2 sizes itself to its own content (clamped to minHeight).
// In side-by-side mode we want both graphs to share the same height, so
// we track each side's reported height and feed back the max as minHeight
// to both. The width-graph then stays at its computed size; the shorter
// graph grows to match.
let beforeContentHeight = $state(0)
let afterContentHeight = $state(0)
const SHARED_MIN_HEIGHT = 400
const sharedMinHeight = $derived(
Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight)
)
// Shared viewport for synchronizing both graphs in side-by-side mode
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
@@ -29,7 +70,10 @@
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
function parseFlow(yaml: string, label: 'before' | 'after'): {
function parseFlow(
yaml: string,
label: 'before' | 'after'
): {
flow: OpenFlow | undefined
error: string | undefined
} {
@@ -49,14 +93,27 @@
}
}
let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before'))
let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after'))
// For added/removed items, the caller passes empty YAML and sets the
// corresponding *Missing flag. We swap in an empty OpenFlow stub on
// that side so the unified diff path still has something to compare
// against (every module on the present side becomes added / removed).
// The side-by-side rendering uses the flag directly to draw a
// placeholder pane instead.
const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } }
let beforeParsed = $derived.by(() =>
beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before')
)
let afterParsed = $derived.by(() =>
afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after')
)
let parseError = $derived(beforeParsed.error ?? afterParsed.error)
let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow)
let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow)
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Side-by-side unless the caller asked for unified, OR the viewer pane
// is too narrow to comfortably split (fallback to unified for legibility).
const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH)
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
@@ -72,14 +129,6 @@
sharedViewport = viewport
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
} else {
viewMode = 'sidebyside'
}
})
</script>
{#if parseError}
@@ -88,10 +137,12 @@
</Alert>
{:else if beforeFlow && afterFlow}
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
<!-- Header with view toggle -->
<div class="flex flex-row items-center justify-end m-2 gap-4">
<div>
<ToggleButtonGroup bind:selected={viewMode}>
{#if showLocalToggle}
<!-- Legacy top banner — only holds the local Unified / Side-by-side
toggle when the parent doesn't pre-set inlineDiff. Zoom
controls live as an overlay below (same as the controlled path). -->
<div class="flex flex-row items-center justify-end m-2">
<ToggleButtonGroup bind:selected={localViewMode} noWFull>
{#snippet children({ item })}
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
<ToggleButton
@@ -103,102 +154,131 @@
{/snippet}
</ToggleButtonGroup>
</div>
<!-- Header with controls and view toggle -->
{/if}
<!-- Main content area -->
<div class="flex-1 overflow-hidden relative" bind:clientHeight={contentAreaHeight}>
{#if isSideBySide}
<!-- Shared controls for both graphs in side-by-side mode -->
<div class="flex">
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
<!-- Shared zoom controls overlay the graph viewport. Uses xy-flow's
own `.svelte-flow__controls` / `.svelte-flow__controls-button`
classes so it inherits the same look as FlowGraphV2's built-in
controls (FlowGraphV2's global override gives the buttons
bg-surface + hover bg-surface-hover with border:0). Local
overrides bump the icon size from 12px to 16px and drop the
xy-flow default shadow. No fit-view button — recenter can't
sync across two graphs. -->
<div
class="svelte-flow__controls horizontal absolute top-[15px] right-[15px] z-10 rounded bg-surface border border-gray-200 dark:border-gray-700 overflow-hidden diff-zoom-controls"
>
<button
type="button"
aria-label="Zoom in"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomIn()
afterGraph?.zoomIn()
}}
iconOnly
startIcon={{ icon: Plus }}
/>
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
>
<Plus />
</button>
<button
type="button"
aria-label="Zoom out"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomOut()
afterGraph?.zoomOut()
}}
iconOnly
startIcon={{ icon: Minus }}
/>
>
<Minus />
</button>
</div>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 overflow-hidden">
{#if isSideBySide}
<!-- Side-by-side view for wide screens -->
<Splitpanes class="!overflow-visible h-full">
<!-- Before (Left) -->
<Pane bind:size={beforePaneSize} minSize={30}>
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700 relative bg-surface-secondary {beforeMissing
? 'hatched-thin'
: ''}"
>
{#if beforeMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
Before <span class="italic">(no prior version)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (beforeContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
<!-- After (Right) - Show merged flow with shadowed removed modules -->
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full relative bg-surface-secondary {afterMissing
? 'hatched-thin'
: ''}"
>
{#if afterMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
After <span class="italic">(flow deleted)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (afterContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">After</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
@@ -219,7 +299,7 @@
editMode={false}
download={false}
scroll={false}
minHeight={400}
minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)}
triggerNode={false}
/>
</div>
@@ -231,3 +311,31 @@
<p class="text-gray-500">Loading graphs...</p>
</div>
{/if}
<style>
/* Thin diagonal hatch used as the empty-pane fill. */
.hatched-thin {
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 6px,
rgba(128, 128, 128, 0.16) 6px,
rgba(128, 128, 128, 0.16) 7.5px
);
}
/* Shared zoom controls overlay: same xy-flow layout as the in-graph
controls (`.svelte-flow__controls.horizontal`) but with bigger Plus/Minus
glyphs (xy-flow caps svg at 12px by default) and no panel shadow. */
.diff-zoom-controls {
box-shadow: none !important;
}
.diff-zoom-controls :global(.svelte-flow__controls-button) {
width: 28px;
height: 28px;
}
.diff-zoom-controls :global(.svelte-flow__controls-button svg) {
max-width: 16px;
max-height: 16px;
}
</style>
@@ -81,7 +81,7 @@
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { onMount, setContext, untrack } from 'svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import LabelsInput from './LabelsInput.svelte'
@@ -134,7 +134,9 @@
onSaveDraftError,
onSaveDraft,
onNavigate,
disableAi
disableAi,
initialTestPanelCollapsed = false,
initialPathChosen = false
}: ScriptBuilderProps = $props()
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
@@ -626,17 +628,23 @@
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
if (
// "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the
// editor in place rather than navigating to the deployed item.
const stayHere =
stay ||
(script.auto_kind === 'lib' &&
script.kind !== 'preprocessor' &&
!isWorkflowAsCode(script.content, script.language))
) {
if (stayHere) {
// Re-pin parent_hash so the next deploy's conflict check is against
// the version we just wrote.
script.parent_hash = newHash
sendUserToast('Deployed')
} else {
onDeploy?.({ path: script.path, hash: newHash })
}
// Always notify on a successful deploy; the consumer decides whether to
// navigate (route) or stay + sync the preview (session). Previously the
// stay/lib branch skipped onDeploy, so session previews didn't sync after
// a "Deploy & Stay here" or lib-script deploy.
onDeploy?.({ path: script.path, hash: newHash, stay: stayHere })
} catch (error) {
onDeployError?.({ path: script.path, error })
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
@@ -793,6 +801,12 @@
loadingDraft = false
}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
// stays put and is already scoped to a fork. Only "Show diff" is kept.
const inSessionPane = !!getContext('aiChatManager')
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
@@ -801,26 +815,30 @@
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
{
label: 'Deploy & Stay here',
onClick: () => {
handleEditScript(true)
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
...(!inSessionPane
? [
{
label: 'Edit in workspace fork',
label: 'Deploy & Stay here',
onClick: () => {
window.open(buildForkEditUrl('script', initialPath))
handleEditScript(true)
}
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
? [
{
label: 'Edit in workspace fork',
onClick: () => {
window.open(buildForkEditUrl('script', initialPath))
}
}
]
: [])
]
: []),
...(customUi?.topBar?.diff !== false && savedScript && diffDrawer
@@ -852,7 +870,10 @@
}
]
: []),
...(!script.draft_only && script.kind === 'script' && !script.auto_kind
...(!inSessionPane &&
!script.draft_only &&
script.kind === 'script' &&
!script.auto_kind
? [
{
label: 'Exit & See details',
@@ -862,7 +883,7 @@
}
]
: []),
...(isWorkflowAsCode(script.content, script.language)
...(!inSessionPane && isWorkflowAsCode(script.content, script.language)
? [
{
label: 'Export as YAML/JSON',
@@ -875,7 +896,11 @@
]
: []
if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) {
if (
!inSessionPane &&
dropdownItems.length === 0 &&
isWorkflowAsCode(script.content, script.language)
) {
dropdownItems = [
{
label: 'Export as YAML/JSON',
@@ -901,7 +926,11 @@
}
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
// Seed "path is already chosen" so the summary→path auto-slug (which only
// runs for new scripts with initialPath == '') doesn't clobber a path the
// caller pre-assigned. The session preview opens AI-created scripts as new
// (empty initialPath) but with a path the AI already picked.
let dirtyPath = $state(initialPathChosen)
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state(
(() => {
@@ -2091,6 +2120,7 @@
bind:assets={script.assets}
bind:modules={script.modules}
enablePreprocessorSnippet
{initialTestPanelCollapsed}
/>
</div>
{:else}
@@ -160,6 +160,11 @@
modules?: { [key: string]: ScriptModule } | null
editorBarRight?: import('svelte').Snippet
enablePreprocessorSnippet?: boolean
// When true the right-hand test/run pane mounts collapsed. The user
// can still expand it via `toggleTestPanel`. Defaults to false so the
// regular /scripts/edit route keeps its current open-by-default UX;
// the session preview opts in to save vertical real estate.
initialTestPanelCollapsed?: boolean
}
let {
@@ -193,7 +198,8 @@
assets = $bindable(),
modules = $bindable(undefined),
editorBarRight,
enablePreprocessorSnippet = false
enablePreprocessorSnippet = false,
initialTestPanelCollapsed = false
}: Props = $props()
let initialArgs = structuredClone($state.snapshot(args))
@@ -1360,8 +1366,11 @@
// dynamic minimum below — so when the editor shrinks, the displayed test
// pane grows to honor the new minimum without needing an effect. The code
// pane's size is purely derived from it (100 - test).
let rawTestPanelSize = $state(30)
let storedTestPanelSize = untrack(() => rawTestPanelSize)
// `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while
// keeping the "remembered" size at 30, so the user's first toggle expands
// the pane to a sensible width rather than 0.
let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30)))
let storedTestPanelSize = 30
const testPanelSize = $derived(
rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent)
)
@@ -0,0 +1,162 @@
<!--
@component
Inline diff renderer for a single workspace item. Mirrors the per-kind
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
- everything else (apps, resources, variables, schedules, triggers…) →
a single Monaco YAML diff over the metadata
`inlineDiff` flips Monaco's `renderSideBySide` to false (unified view).
The component is content-sized — each Monaco block is sized to fit its
diff text (no internal scroll) using `lines * 19 + 24`; for the
Content+Metadata case we use the max of the two so switching tabs
doesn't reflow the parent.
-->
<script lang="ts">
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import FlowDiffViewer from './FlowDiffViewer.svelte'
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { scriptLangToEditorLang } from '$lib/scripts'
interface Props {
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
kind: string
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
* for "added" items (don't exist in the parent). */
originalRaw?: unknown
/** Raw value from `getItemValue(kind, path, forkWorkspace)`. Undefined
* for "removed" items (don't exist in the fork). */
currentRaw?: unknown
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
inlineDiff?: boolean
}
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
type Prepared = { lang?: string; content?: string; metadata: string }
function prepareValue(raw: unknown): Prepared {
if (!raw || typeof raw !== 'object') {
return { metadata: raw == null ? '' : String(raw) }
}
const cleaned = structuredClone(
cleanValueProperties(replaceFalseWithUndefined(raw as Record<string, unknown>))
)
const content = (cleaned as Record<string, unknown>)['content']
if (content !== undefined) {
delete (cleaned as Record<string, unknown>)['content']
}
const language = (raw as Record<string, unknown>).language
return {
lang:
typeof language === 'string'
? scriptLangToEditorLang(language as Parameters<typeof scriptLangToEditorLang>[0])
: undefined,
content: typeof content === 'string' ? content : undefined,
metadata: orderedYamlStringify(cleaned)
}
}
const original = $derived(prepareValue(originalRaw))
const current = $derived(prepareValue(currentRaw))
const hasContent = $derived(original.content !== undefined || current.content !== undefined)
// For added / removed flows, the missing side feeds an empty YAML so
// the YAML-mode editor shows the whole new (or removed) flow as a
// single-sided diff. FlowGraphDiffViewer uses the *Missing flag to
// swap in its own OpenFlow stub for parsing and to draw a placeholder
// pane in side-by-side mode.
const beforeFlowYaml = $derived(originalRaw == null ? '' : original.metadata)
const afterFlowYaml = $derived(currentRaw == null ? '' : current.metadata)
let contentTab: 'content' | 'metadata' = $state('content')
// Per-tab height: each Monaco block sizes to its own content. Switching
// tabs reflows the row, which is the expected tab behavior; we don't
// over-allocate to the larger tab the way the previous max() did.
const LINE_HEIGHT = 19
const EDITOR_CHROME = 24
function linesIn(s?: string): number {
return Math.max((s ?? '').split('\n').length, 1)
}
const contentHeight = $derived(
`${Math.max(linesIn(original.content), linesIn(current.content)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const metadataHeight = $derived(
`${Math.max(linesIn(original.metadata), linesIn(current.metadata)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const activeTabHeight = $derived(contentTab === 'content' ? contentHeight : metadataHeight)
</script>
{#if kind === 'flow'}
<div class="h-[600px]">
<FlowDiffViewer
beforeYaml={beforeFlowYaml}
afterYaml={afterFlowYaml}
beforeMissing={originalRaw == null}
afterMissing={currentRaw == null}
{inlineDiff}
/>
</div>
{:else if hasContent}
<div class="flex flex-col">
<Tabs bind:selected={contentTab}>
<Tab value="content" label="Content" />
<Tab value="metadata" label="Metadata" />
</Tabs>
<div style="height: {activeTabHeight}">
{#if contentTab === 'content'}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang={original.lang ?? current.lang}
defaultOriginal={original.content ?? ''}
defaultModified={current.content ?? ''}
{inlineDiff}
readOnly
/>
{/await}
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
{/await}
{/if}
</div>
</div>
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<div style="height: {metadataHeight}">
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
</div>
{/await}
{/if}
@@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import { onMount, untrack } from 'svelte'
import {
@@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
type Kind = WorkspaceItemKind
type Item = WorkspaceItem
@@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
// Sibling-popover open: melt-ui's `openFocus` runs once during the close→open
// transition; the picker may not be mounted yet. Retry after settle.
// Also kicks off the initial scope's fetch — drill/goUp do the same from
// their respective branches, so `ensureLoaded` is always a callback
// reaction to user navigation, never a reactive consequence.
onMount(() => {
const t = setTimeout(focus, 50)
const initial = untrack(() => scope)
if (initial) {
if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(initial.kind)
}
return () => clearTimeout(t)
})
@@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let scope = $state<Scope>(untrack(() => initialScope))
let filter = $state('')
/**
* Canonical entry point for changing the picker's scope. Triggers the
* fetch for the kind(s) the new scope needs at the same point in time.
* Replaces the older "react to `scope` change via `$effect`" wiring,
* which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the
* effect ended up subscribed to the signal it fills — every fetch
* result re-fired it. With explicit callbacks the fetch is tied to
* the user's action, never to a reactive consequence of that action.
*/
function setScope(next: Scope) {
scope = next
if (!next) return
if (next.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(next.kind)
}
/** Tracks whether the last user action was mouse movement (true) or
* keyboard nav (false). When false, row `mouseenter` events are ignored
* — prevents the cursor from stealing the keyboard-driven highlight as
@@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* mounts under a stationary cursor doesn't clobber `initialHighlight`. */
let mouseActive = $state(false)
// Seed from cache so kinds already fetched in this session render on the
// first frame. Read once at mount: melt-ui mounts a fresh picker per
// popover open, so workspace changes are picked up at the next open
// without needing this seed to be reactive.
// Seed from the last fetched snapshot so kinds already fetched in this
// session render on the first frame. Each entry is replaced once
// `loadKind` returns fresh data — stale-while-revalidate, so deploys and
// AI-created drafts surface on the next open without explicit cache
// busting.
let loaded = $state<Partial<Record<Kind, Item[]>>>(
(() => {
if (!$workspaceStore) return {}
@@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
async function ensureLoaded(kind: Kind) {
if (!$workspaceStore) return
if (loaded[kind]) return
loadingKind[kind] = true
// Always re-fetch. If we have nothing cached, show a spinner; if we do,
// keep displaying it and quietly swap to fresh data when it lands.
// `loaded[kind]` is read inside `untrack(...)` because this function is
// reachable from the search `$effect` below — without the untrack,
// that effect would subscribe to the signal `ensureLoaded` fills, and
// each `loaded[kind] = items` (proxy `set` notifies even when the ref
// is unchanged from cache) would refire it → runaway loop. Drill
// navigation goes through `setScope` directly so it isn't affected.
if (!untrack(() => loaded[kind])) loadingKind[kind] = true
try {
const items = await loadKind($workspaceStore, kind)
loaded[kind] = items
@@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
}
}
// Fetch the scope's kind on entry to a non-root level. The `'all'` scope
// needs every kind loaded since it merges items across them.
$effect(() => {
if (!scope) return
if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(scope.kind)
})
// Chat tools and session editor previews write drafts through
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
// the picker so users can navigate to in-flight items that haven't been
// deployed yet. Filter to kinds the picker actually displays.
//
// Gated on the same dev flag as the rest of the sessions feature: without
// it there are no sessions, so the only UserDrafts present are the
// standalone editors' autosaves — surfacing those in the breadcrumb picker
// would be surprising (they'd appear as navigable items that 404 on the
// backend draft fetch). When the flag is off this is a no-op.
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
function aiDraftsForKind(k: Kind): Item[] {
if (!isGlobalAiEnabled()) return []
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return listGlobalDrafts($workspaceStore)
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
summary: d.summary ?? '',
kind: k,
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
}))
}
// Searching is global → load every kind.
$effect(() => {
@@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
leaves: Item[]
}
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
* scaffolded a script/flow/app via chat tools without the user saving
* yet — those drafts should be navigable from the picker. Existing items
* (same path) win to keep the backend's metadata (summary etc.). */
function withAiDrafts(items: Item[], k: Kind): Item[] {
const ai = aiDraftsForKind(k)
if (ai.length === 0) return items
const known = new Set(items.map((it) => it.path))
return items.concat(ai.filter((d) => !known.has(d.path)))
}
/** Inject the currently-edited item into a kind's list at its live path,
* dropping the saved entry when a draft rename is in progress. Other kinds
* pass through untouched. */
@@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* cached. */
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
if (!kinds.includes(k)) return []
const items = withCurrent(list ?? [], k)
const items = withAiDrafts(withCurrent(list ?? [], k), k)
if (items.length === 0) return []
return buildTreeFromItems(items)
}
@@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* one folder hierarchy. Each leaf still carries its real kind, so the row
* icon and `editPathFor` routing still work; folders contain a mix. */
const allTree = $derived.by(() => {
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
return merged.length === 0 ? [] : buildTreeFromItems(merged)
})
@@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let allItems = $derived<SearchInput[]>(
kinds.flatMap((k) =>
withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` }))
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
...it,
_key: `${k}:${it.path}`
}))
)
)
@@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
function drill(entry: Entry) {
if (entry.type === 'kind') {
scope = { kind: entry.kind }
setScope({ kind: entry.kind })
} else if (entry.type === 'dir') {
scope = { kind: entry.kind, dir: entry.node.fullPath }
setScope({ kind: entry.kind, dir: entry.node.fullPath })
} else {
pick(entry.item)
}
@@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
// just left, so the user sees where they came from.
if (!scope.dir) {
const leaving = kindKey(scope.kind)
scope = undefined
setScope(undefined)
highlightedKey = leaving
return
}
const leaving = dirKey(scope.kind, scope.dir)
const parent = parentDirPath(scope.dir)
scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }
setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind })
highlightedKey = leaving
}
@@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
{#snippet leafRow(it: Item, secondary: string, baseClass: string)}
{@const key = leafKey(it)}
{@const isHl = key === highlightedKey}
{@const isCur = isCurrent(it)}
<button
type="button"
<WorkspaceItemRow
kind={it.kind}
summary={it.summary}
{secondary}
highlighted={key === highlightedKey}
current={isCurrent(it)}
id={idFor(key)}
role="option"
aria-selected={isHl}
data-nav-key={key}
aria-current={isCur ? 'true' : undefined}
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
? 'bg-surface-hover'
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''}"
onmousedown={(e) => e.preventDefault()}
navKey={key}
{baseClass}
onclick={() => pick(it)}
onmouseenter={() => setHoverHighlight(key)}
>
<RowIcon kind={it.kind} size={12} />
<div class="min-w-0 flex-1">
{#if it.summary}
<div class="text-xs text-primary truncate">{it.summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
</button>
/>
{/snippet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -0,0 +1,148 @@
<!--
@component
Visual row for a workspace item (script / flow / app / resource /
schedule / trigger / …). Matches the leaf-row layout used by
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
beneath, or just the mono path when there's no summary.
Pure presentation — the caller controls highlighting / current state via
props, supplies the onclick/onmouseenter handlers, and can pass an
`extras` snippet for right-side adornments (status dots, badges, …).
The button uses `onmousedown={(e) => e.preventDefault()}` so the click
doesn't steal focus from a sibling search input (matches the picker).
-->
<script module lang="ts">
import type { ComponentProps } from 'svelte'
import RowIconType from '$lib/components/common/table/RowIcon.svelte'
export type WorkspaceItemRowKind = ComponentProps<typeof RowIconType>['kind']
</script>
<script lang="ts">
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import type { Snippet } from 'svelte'
interface Props {
kind: WorkspaceItemRowKind
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
* Forwarded to RowIcon. */
triggerKind?: string
/** Optional summary text shown above the path. */
summary?: string
/** Mono path (or any secondary identifier). When summary is empty
* this is the only visible text. */
secondary: string
/** Highlighted via keyboard nav. Used for `aria-selected` +
* surface-hover background. */
highlighted?: boolean
/** "Currently editing this" — the picker uses this to grey out the
* active row and disable its click. */
current?: boolean
/** DOM id, used for `aria-activedescendant`. */
id?: string
/** Stamped on the element as `data-nav-key` so the parent can
* `pickerRoot.querySelector(...)` to scroll into view. */
navKey?: string
/** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */
baseClass?: string
/** Extra left padding (px) for tree-view indentation. Adds to the
* default `px-3` horizontal padding. */
indent?: number
/** Title tooltip shown on hover; defaults to the secondary text. */
title?: string
/** When set, the row renders as an `<a href target="_blank">` link
* instead of a `<button>`. Used by callers that want native
* new-tab / cmd-click behaviour. `onclick` still forwards. */
href?: string
onclick?: () => void
onmouseenter?: () => void
/** Right-side adornments (status dot, badges, …). The `group` class
* is always applied to the root so the snippet can use
* `group-hover:*` utilities to reveal hover-only affordances. */
extras?: Snippet
}
let {
kind,
triggerKind,
summary,
secondary,
highlighted = false,
current = false,
id,
navKey,
baseClass = 'py-1.5',
indent = 0,
title,
href,
onclick,
onmouseenter,
extras
}: Props = $props()
const rootClass = $derived(
`group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}`
)
</script>
{#if href}
<a
{href}
target="_blank"
rel="noopener noreferrer"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</a>
{:else}
<button
type="button"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
onmousedown={(e) => e.preventDefault()}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</button>
{/if}
@@ -3,7 +3,7 @@
const bubble = createBubbler()
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { onMount, setContext, untrack } from 'svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { Pane, Splitpanes } from 'svelte-splitpanes'
@@ -79,20 +79,29 @@
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
unsavedConfirmationModal,
onSavedNewAppPath,
onNavigate,
initialRevs
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
// Inside a session pane the AIChatManager is injected via context. Sessions
// have their own state machinery (sessionRuntime + per-fork backend), and
// the user-facing $workspaceStore stays on the main workspace even when
// the session is editing in a fork — so a UserDraft handle here would
// share its LS key with the regular /apps/edit route and clobber both
// sides' autosaves. Skip UserDraft entirely in that case.
const inSessionPane = !!getContext('aiChatManager')
const appDraftPath = newApp ? '' : (path ?? '')
const appDraftHandle = UserDraft.use<App>('app', appDraftPath)
const appDraftHandle = inSessionPane ? undefined : UserDraft.use<App>('app', appDraftPath)
// Prefer the persisted autosave over the prop when both exist (e.g.
// /apps/add reload: the route always initializes `app` to an empty
// template, but the user's last session is sitting in LS under the
// empty-path entry). The route is responsible for wiping the entry
// (`UserDraft.remove`) when it wants to force a fresh start —
// `?nodraft=true`, template/hub loads, etc.
const stateApp = $state(untrack(() => appDraftHandle.draft ?? app))
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
const appStore = writable<App>(stateApp)
// Captured once on mount: the load-time revs are only used as the
// seed meta on the very first persist of this entry. After that the
@@ -112,6 +121,7 @@
let firstMirror = true
$effect(() => {
readFieldsRecursively(stateApp)
if (!appDraftHandle) return
untrack(() => {
// Resolve the meta to attach BEFORE the wipe — the wipe clears
// in-memory meta and would otherwise force-seed `initialRevs`
@@ -884,6 +894,7 @@
rightPanelHidden={rightPanelSize === 0}
bottomPanelHidden={runnablePanelSize === 0}
{onSavedNewAppPath}
{onNavigate}
onShowLeftPanel={() => showLeftPanel()}
onShowRightPanel={() => showRightPanel()}
onShowBottomPanel={() => showBottomPanel()}
@@ -64,7 +64,7 @@
import DebugPanel from './contextPanel/DebugPanel.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { goto } from '$app/navigation'
import HideButton from './settingsPanel/HideButton.svelte'
@@ -110,6 +110,7 @@
onHideRightPanel?: () => void
onHideLeftPanel?: () => void
onHideBottomPanel?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
}
let {
@@ -130,7 +131,8 @@
onShowBottomPanel,
onHideLeftPanel,
onHideRightPanel,
onHideBottomPanel
onHideBottomPanel,
onNavigate = undefined
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
@@ -170,6 +172,14 @@
const { history, jobsDrawerOpen, refreshComponents } =
getContext<AppEditorContext>('AppEditorContext')
// Sessions inject an AIChatManager via context; AppEditor skips its
// UserDraft handle in that case, so the cleanup calls here must skip too
// (otherwise we'd wipe a non-session tab's autosave at the same path). The
// session-side equivalent is the View's `onDeploy` →
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
// the preview to the deployed version.
const inSessionPane = !!getContext('aiChatManager')
const loading = $state({
publish: false,
save: false,
@@ -229,7 +239,7 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('app', path)
if (!inSessionPane) UserDraft.remove('app', path)
onSavedNewAppPath?.(path)
} catch (e) {
sendUserToast('Error creating app', e)
@@ -313,7 +323,6 @@
preserve_on_behalf_of: preserveOnBehalfOf || undefined
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: $summary,
@@ -330,7 +339,7 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('app', $appPath)
if (!inSessionPane) UserDraft.remove('app', $appPath)
if ($appPath !== npath) {
onSavedNewAppPath?.(npath)
}
@@ -406,7 +415,7 @@
// The initial draft was promoted to a real path on the backend —
// drop the autosave keyed on the prior (possibly empty) path so
// a future "+ App" click opens on a clean slate.
UserDraft.remove('app', $appPath)
if (!inSessionPane) UserDraft.remove('app', $appPath)
onSavedNewAppPath?.(newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
@@ -497,7 +506,7 @@
}
sendUserToast('Draft saved')
UserDraft.remove('app', path)
if (!inSessionPane) UserDraft.remove('app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
onSavedNewAppPath?.(newEditedPath || path)
@@ -1006,7 +1015,7 @@
bind:path={newEditedPath}
savedPath={$appPath || newPath || undefined}
kind="app"
onNavigate={(item) => goto(editPathFor(item))}
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div class="flex gap-2 {compactTopbar ? 'hidden' : ''}">
{#if $app}
@@ -139,7 +139,7 @@
})
$effect(() => {
appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl())
})
</script>
@@ -264,10 +264,10 @@
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
disabled={appPath == ''}
disabled={!savedApp}
/>
</div>
{#if appPath == ''}
{#if !savedApp}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<div class="flex justify-end mb-1">
@@ -164,6 +164,8 @@ export interface AppEditorProps {
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
onSavedNewAppPath?: (path: string) => void
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/**
* Backend revs at the load that produced `app`. Used as the seed
* `UserDraft` meta on the first local autosave: until the handle has
@@ -78,6 +78,15 @@ this component just proposes new values.
})
}
// External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap
// stays active for a brief window after the menu closes — focusing our
// input during that window causes checkFocusIn to slam focus back out, which
// fires onblur=save and instantly closes the edit. A 50ms defer is enough
// for Melt's trap to release.
export function edit() {
setTimeout(startEditing, 50)
}
function save() {
// Re-entry guard: Enter calls `save()` and sets `editing = false`,
// which unmounts the `<input>` and synchronously fires its `blur`
@@ -3,30 +3,61 @@
import { untrack } from 'svelte'
import { type ScriptLang } from '$lib/gen'
import { dbSchemas, userStore, workspaceStore } from '$lib/stores'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import { base } from '$lib/base'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
let {
hideHeader = false,
hideModeSelector = false,
forceDisabled = false,
forceDisabledMessage = '',
wideLayout = false,
emptyHint,
inputPreface
}: {
hideHeader?: boolean
hideModeSelector?: boolean
// External "you can't type here" override. Used by sessions when
// the session's committed workspace was deleted/archived so the
// chat is effectively read-only until the user moves or discards
// the session. Wins over the internal disabled derivation.
forceDisabled?: boolean
forceDisabledMessage?: string
// Forwarded to AIChatDisplay. When true, the messages / input
// columns are centered in a max-w-3xl px-8 box. Sessions opt
// in; the narrow global-chat panel leaves it off.
wideLayout?: boolean
emptyHint?: import('svelte').Snippet
inputPreface?: import('svelte').Snippet
} = $props()
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const hasCopilot = $derived($copilotInfo.enabled)
const disabled = $derived(
!hasCopilot ||
forceDisabled ||
!hasCopilot ||
(aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
)
const disabledMessage = $derived(
!hasCopilot
? isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
forceDisabled
? forceDisabledMessage
: !hasCopilot
? isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -53,6 +84,10 @@
aiChatManager.sendRequest(options)
}
export function focusInput() {
aiChatDisplay?.focusInput()
}
const historyManager = aiChatManager.historyManager
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
@@ -129,4 +164,9 @@
{disabled}
{disabledMessage}
{suggestions}
{hideHeader}
{hideModeSelector}
{wideLayout}
{emptyHint}
{inputPreface}
></AIChatDisplay>
@@ -61,7 +61,7 @@ import { runChatLoop } from './chatLoop'
import type { ReviewChangesOpts } from './monaco-adapter'
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
import type { WorkspaceMutationTarget } from './workspaceTools'
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core'
import { globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core'
import { isGlobalAiEnabled } from './global/gate'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
@@ -208,13 +208,26 @@ export class AIChatManager {
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
// Set by AI sessions. Enables the session-only preview tools (open_preview /
// get_preview_status) and their system-prompt guidance in GLOBAL mode; the
// global side-panel chat leaves it false so those tools aren't offered.
isSessionChat = false
// The session this manager belongs to (session chats only). Carried into the
// tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS
// session rather than the UI-active one — keeps backgrounded sessions isolated.
sessionId: string | undefined = undefined
allowedModes: Record<AIMode, boolean> = $derived({
script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined,
flow: this.flowAiChatHelpers !== undefined,
app: this.appAiChatHelpers !== undefined,
navigator: true,
ask: true,
API: true,
script:
this.flowAiChatHelpers === undefined &&
this.scriptEditorOptions !== undefined &&
!this.disabledModes.script,
flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow,
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app,
navigator: !this.disabledModes.navigator,
ask: !this.disabledModes.ask,
API: !this.disabledModes.API,
// Dev-only gate. See `./global/gate.ts` for how to enable.
global: isAIModeVisible(AIMode.GLOBAL)
})
@@ -495,9 +508,11 @@ export class AIChatManager {
this.helpers = {}
} else if (mode === AIMode.GLOBAL) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareGlobalSystemMessage(customPrompt)
this.tools = [...globalTools]
this.helpers = {}
this.systemMessage = prepareGlobalSystemMessage(customPrompt, {
previewTools: this.isSessionChat
})
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
this.helpers = this.isSessionChat ? { sessionId: this.sessionId } : {}
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAppSystemMessage(customPrompt)
@@ -795,6 +810,12 @@ export class AIChatManager {
}
}
// Optional pre-flight hook called once per send, after validation but
// before any UI state mutates or backend calls go out. Sessions use
// this to commit/materialise the workspace (creating a staged fork via
// the API) so the first message targets the correct workspace.
beforeSend?: () => Promise<void> | void
sendRequest = async (
options: {
removeDiff?: boolean
@@ -819,6 +840,24 @@ export class AIChatManager {
if (!this.instructions.trim()) {
return
}
if (this.beforeSend) {
try {
await this.beforeSend()
} catch (e) {
// beforeSend commits the session's workspace before the first
// message hits the backend. If it throws, sending anyway would
// silently target the wrong workspace (typically the parent), so
// abort and tell the user — their message text stays in the input.
console.error('AIChatManager beforeSend hook failed', e)
sendUserToast(
`Could not prepare the session before sending: ${
e instanceof Error ? e.message : String(e)
}. Your message was not sent please try again.`,
true
)
return
}
}
try {
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
@@ -76,7 +76,7 @@
onClick={() => onMenuOpen?.()}
startIcon={{ icon: Menu }}
iconOnly
></Button>
/>
</div>
<div class="flex-1 min-h-0">
{@render children?.()}
@@ -96,5 +96,13 @@
{/if}
</Splitpanes>
{:else}
{@render children?.()}
<div
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'
)}
>
{@render children?.()}
</div>
{/if}
@@ -3,9 +3,16 @@
import { CircleHelp } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { UserQuestionDisplay } from './shared'
// Sessions inject a per-pane `AIChatManager` via context; outside of
// sessions getAiChatManager falls back to the global singleton. Without
// this, answers clicked inside a session would dispatch to the singleton's
// pending callbacks map (which doesn't have the session manager's question
// callback), and the AI loop would stall.
const aiChatManager = getAiChatManager()
interface Props {
toolCallId: string
userQuestion: UserQuestionDisplay
@@ -2,7 +2,10 @@
import { ChevronDown } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode'
@@ -1,7 +1,9 @@
<script lang="ts">
import { AlertTriangle } from 'lucide-svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
import { workspaceStore } from '$lib/stores'
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
@@ -69,4 +71,4 @@
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
/>
{/if}
</div>
</div>
@@ -11,6 +11,7 @@ interface ChatSchema extends IDBSchema {
displayMessages: DisplayMessage[]
title: string
lastModified: number
sessionId?: string
}
}
}
@@ -26,14 +27,21 @@ export default class HistoryManager {
title: string
id: string
lastModified: number
sessionId?: string
}
> = $state({})
private currentChatId: string = $state(createLongHash())
// When set, this manager is bound to a session: only chats tagged with this id
// are surfaced and new chats are saved with this id. When undefined (singleton),
// session-tagged chats are excluded from history.
private sessionId: string | undefined = $state(undefined)
private pastChats = $derived(
Object.values(this.savedChats)
.filter((c) => c.id !== this.currentChatId)
.filter((c) => (this.sessionId ? c.sessionId === this.sessionId : !c.sessionId))
.sort((a, b) => b.lastModified - a.lastModified)
)
@@ -69,10 +77,33 @@ export default class HistoryManager {
return this.currentChatId
}
setCurrentChatId(id: string) {
this.currentChatId = id
}
setSessionId(id: string | undefined) {
this.sessionId = id
}
async tagChatWithSession(chatId: string, sessionId: string) {
const existing = this.savedChats[chatId]
if (!existing || existing.sessionId === sessionId) return
const snapshot = $state.snapshot(existing)
const updated = { ...snapshot, sessionId }
this.savedChats = { ...this.savedChats, [chatId]: updated }
if (this.indexDB) {
await this.indexDB.put('chats', updated)
}
}
getPastChats() {
return this.pastChats
}
getAllSavedChats() {
return Object.values(this.savedChats)
}
async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
if (displayMessages.length > 0) {
// we don't want to save the snapshot in the history
@@ -84,7 +115,8 @@ export default class HistoryManager {
})),
title: displayMessages[0].content.slice(0, 50),
id: this.currentChatId,
lastModified: Date.now()
lastModified: Date.now(),
...(this.sessionId ? { sessionId: this.sessionId } : {})
}
this.savedChats = {
...this.savedChats,
@@ -1,7 +1,9 @@
<script lang="ts">
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import type { ToolDisplayMessage } from './shared'
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
@@ -52,13 +54,12 @@
{#if activeUserQuestion}
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
{:else}
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<div class="bg-surface border border-border-light rounded-md overflow-hidden font-mono text-xs">
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
'w-full p-2 bg-surface-secondary/30 hover:bg-surface-hover transition-colors flex items-center justify-between text-left',
isExpanded ? 'border-b border-border-light' : '',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
@@ -107,7 +108,7 @@
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
hasParameters ? 'border-t border-border-light' : ''
)}
>
<Button
@@ -6,7 +6,9 @@
import type { FlowAIChatHelpers } from './core'
import { createInlineScriptSession } from './inlineScriptsUtils'
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
import { aiChatManager } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
const aiChatManager = getAiChatManager()
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { FlowCopilotContext } from '../../flow'
import type { ScriptLintResult } from '../shared'
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('monaco-editor', () => ({
editor: {},
@@ -108,7 +108,15 @@ vi.mock('./rawAppBundlerBridge', () => ({
}))
}))
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
import {
globalTools,
globalToolsFor,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
setDeployedInSessionHandler,
setGetPreviewStatusHandler,
setOpenPreviewHandler
} from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
@@ -141,12 +149,13 @@ function getGlobalTool(name: string): Tool<{}> {
async function callGlobalTool(
name: string,
args: Record<string, unknown>,
callbacks: ToolCallbacks = toolCallbacks
callbacks: ToolCallbacks = toolCallbacks,
helpers: Record<string, unknown> = {}
): Promise<string> {
return getGlobalTool(name).fn({
args,
workspace: WORKSPACE,
helpers: {},
helpers,
toolCallbacks: callbacks,
toolId: `test-${name}`
})
@@ -1129,6 +1138,44 @@ describe('global AI tools', () => {
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
const onDeployed = vi.fn()
setDeployedInSessionHandler(onDeployed)
try {
UserDraft.save(
'raw_app',
'f/apps/report',
{
summary: 'AI report',
files: { '/index.tsx': 'console.log("app")' },
runnables: {},
data: { tables: [] }
},
{ workspace: WORKSPACE }
)
await callGlobalTool(
'deploy_workspace_item',
{ type: 'app', path: 'f/apps/report' },
toolCallbacks,
{
sessionId: 'sess-123'
}
)
// A raw app deploys under type 'app' but the preview addresses it as
// 'raw_app'; the calling session id is threaded through so the deploy
// reloads the issuing session's preview, not the UI-active one.
expect(onDeployed).toHaveBeenCalledWith({
sessionId: 'sess-123',
kind: 'raw_app',
path: 'f/apps/report'
})
} finally {
setDeployedInSessionHandler(undefined)
}
})
it('fills an empty rawscript module through set_flow_module_code', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/empty-module',
@@ -1154,7 +1201,7 @@ describe('global AI tools', () => {
module_id: 'empty_step',
code
})
).resolves.toContain('Updated local draft flow')
).resolves.toContain('Updated flow')
await expect(
callGlobalTool('read_flow_module_code', {
@@ -1357,6 +1404,62 @@ describe('prepareGlobalSystemMessage', () => {
expect(discard.requiresConfirmation).toBe(true)
expect(deleteItem.requiresConfirmation).toBe(true)
})
describe('get_preview_status', () => {
afterEach(() => {
setGetPreviewStatusHandler(undefined)
setOpenPreviewHandler(undefined)
})
it('takes no arguments', () => {
const tool = getGlobalTool('get_preview_status')
expect(tool.def.function.parameters).toMatchObject({
type: 'object',
properties: {},
required: []
})
})
it('returns the session-only error when no handler is registered', async () => {
setGetPreviewStatusHandler(undefined)
const result = await callGlobalTool('get_preview_status', {})
expect(result).toBe('Error: get_preview_status is only available inside an AI session.')
})
it('dispatches to the registered session handler', async () => {
setGetPreviewStatusHandler(() => 'The preview is currently open showing script "u/me/foo".')
const result = await callGlobalTool('get_preview_status', {})
expect(result).toBe('The preview is currently open showing script "u/me/foo".')
})
})
})
describe('session-only preview tools gating', () => {
const toolNames = (sessionPreview: boolean) =>
globalToolsFor({ sessionPreview }).map((t) => t.def.function.name)
it('excludes open_preview / get_preview_status outside a session', () => {
const names = toolNames(false)
expect(names).not.toContain('open_preview')
expect(names).not.toContain('get_preview_status')
// other tools are still present
expect(names).toContain('write_script')
})
it('includes open_preview / get_preview_status inside a session', () => {
const names = toolNames(true)
expect(names).toContain('open_preview')
expect(names).toContain('get_preview_status')
// session set is the full globalTools
expect(names.length).toBe(globalTools.length)
})
it('mentions open_preview in the system prompt only when preview tools are enabled', () => {
const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string
const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string
expect(off).not.toContain('open_preview')
expect(on).toContain('open_preview')
})
})
describe('prepareGlobalUserMessage', () => {
@@ -84,6 +84,8 @@ import {
type WorkspaceItemType
} from './workspaceItems'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import { userStore } from '$lib/stores'
import { get } from 'svelte/store'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
clearEphemeralSecretVariableDraftValue,
@@ -480,6 +482,17 @@ const deleteAppRunnableSchema = z.object({
key: z.string().describe('Key of the backend runnable to remove.')
})
const openPreviewSchema = z.object({
kind: z
.enum(['script', 'flow', 'raw_app'])
.describe(
'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
),
path: z.string().describe('Workspace path of the item to preview.')
})
const getPreviewStatusSchema = z.object({})
const FRAMEWORK_KEYS = [
'react19',
'react18',
@@ -514,10 +527,23 @@ const initAppSchema = z.object({
.describe('Optional datatable configuration. Omit unless the user asked to wire one up.')
})
const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant.
const buildGlobalSystemPrompt = (
username: string,
previewTools: boolean
) => `You are Windmill's global workspace assistant.
The current user's workspace username is "${username}".
Use tools to inspect workspace items and create local drafts for scripts, flows, schedules, triggers, resources, variables, and raw apps.
Path conventions:
- Every workspace path has exactly three segments and starts with one of two namespaces:
- \`u/${username}/<name>\` — the current user's personal scope. Default for ad-hoc, exploratory, or scratch work.
- \`f/<folder>/<name>\` — a shared folder scope. The folder must already exist; bare \`f/<name>\` is INVALID and will fail.
- When the user gives a bare name without a namespace prefix (e.g. "create a flow called myflow"), default to \`u/${username}/<name>\`. Do NOT invent \`f/<name>\` — that is a structurally invalid path.
- If the request implies shared / team work but doesn't name a specific folder (e.g. "the marketing flow"), ask which folder to use rather than guessing. Call \`list_workspace_items\` with \`type: ['folder']\` (or rely on the user's hint) before assuming a folder exists.
- Only use an \`f/<folder>/<name>\` path when the user explicitly named the folder or you confirmed it exists.
Rules:
- Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items.
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
@@ -528,7 +554,12 @@ Rules:
- Use search_resource_types before write_resource.
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit.
- Keep context targeted.
- Keep context targeted.${
previewTools
? `
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item don't re-open a preview already showing the item you just edited.`
: ''
}
Flows:
- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>".
@@ -600,7 +631,12 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
}
}
if (item.type === 'app' && item.value && typeof item.value === 'object' && 'files' in item.value) {
if (
item.type === 'app' &&
item.value &&
typeof item.value === 'object' &&
'files' in item.value
) {
return {
type: 'app',
path: item.path,
@@ -1193,7 +1229,7 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.
- A script draft is a workspace item: \`{ type: 'script', path, summary?, language, value, isDraft }\` where \`value\` is the source code string.
- Use workspace paths such as \`f/folder/name\` or \`u/username/name\`. Preserve the current path/language when modifying unless the user asked to change them.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.
- Use \`edit_script\` for small localized changes (provide \`old_string\`/\`new_string\`); use \`write_script\` for full rewrites.${note}
# Windmill script authoring reference (${selected})
@@ -1205,6 +1241,7 @@ function getFlowInstructions(): string {
return `# Global draft flow instructions
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
@@ -1232,7 +1269,7 @@ function getAppInstructions(): string {
return `# Global draft app instructions
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
- App drafts are addressed by workspace path (e.g. \`f/folder/my_app\`). The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, and a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`). Confirm framework + path + summary with the user before calling — do not silently default to \`react19\` even though it is the recommended choice. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
@@ -1532,7 +1569,7 @@ export const globalTools: Tool<{}>[] = [
confirmationMessage: 'Deploy local draft to workspace',
fn: async (ctx) => {
const parsed = deployWorkspaceItemSchema.parse(ctx.args)
return deployDraft(parsed, ctx)
return deployDraft(parsed, { ...ctx, sessionId: sessionIdFromCtx(ctx) })
}
},
{
@@ -1738,13 +1775,123 @@ export const globalTools: Tool<{}>[] = [
const parsed = deleteAppRunnableSchema.parse(ctx.args)
return deleteAppRunnable(parsed, ctx)
}
},
{
def: createToolDef(
openPreviewSchema,
'open_preview',
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
),
fn: async (ctx) => {
const parsed = openPreviewSchema.parse(ctx.args)
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
}
},
{
def: createToolDef(
getPreviewStatusSchema,
'get_preview_status',
'Check whether the side-panel preview is open in this AI session and which item (kind + path) it is showing. Call this before offering or calling open_preview so you do not re-open a preview that is already showing the item you just edited. Only meaningful inside a session.'
),
fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx))
}
]
// Tools that only make sense inside an AI session (they drive the session's
// side-panel preview). The regular global side-panel chat shouldn't even be
// offered them — see `globalToolsFor`.
export const SESSION_PREVIEW_TOOL_NAMES = new Set(['open_preview', 'get_preview_status'])
/**
* The global tool set for a given chat: the full `globalTools` for a session
* chat, or `globalTools` minus the session-only preview tools for the regular
* global side-panel chat.
*/
export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] {
return sessionPreview
? globalTools
: globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name))
}
type WriteDraftCtx = {
workspace: string
toolId: string
toolCallbacks: ToolCallbacks
// Calling session id (session chats only), threaded through so a deploy
// reloads the preview of the session that issued the deploy — not the
// UI-active one. Undefined for the global side-panel chat.
sessionId?: string
}
// Sessions are the only context where `open_preview` makes sense — the global
// singleton chat in the right side panel has nowhere to mount an editor pane.
// The session runtime registers a handler at construction time so the tool
// has somewhere to dispatch. When no session is active the handler is
// undefined and the tool returns a polite error.
// Per-manager tool helpers for a session chat. Each session's AIChatManager
// sets `helpers = { sessionId }`, so a tool call carries the *calling* session's
// id even when a different session is the UI-active one. Without this the
// handlers below would route a backgrounded session's tool call to whatever
// session the user happens to be viewing.
export type SessionToolHelpers = { sessionId?: string }
function sessionIdFromCtx(ctx: { helpers?: unknown }): string | undefined {
return (ctx.helpers as SessionToolHelpers | undefined)?.sessionId
}
export type OpenPreviewHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app'
path: string
}) => string
let openPreviewHandler: OpenPreviewHandler | undefined
export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined): void {
openPreviewHandler = handler
}
function openSessionPreview(
args: { kind: 'script' | 'flow' | 'raw_app'; path: string },
sessionId: string | undefined
) {
if (!openPreviewHandler) {
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
}
return openPreviewHandler({ ...args, sessionId })
}
// Companion to `open_preview`: lets the assistant query the current preview
// state (open? which item?) so it can avoid re-opening a preview already
// showing the item it just edited. Registered by the session runtime
// alongside the open-preview handler.
export type GetPreviewStatusHandler = (sessionId: string | undefined) => string
let getPreviewStatusHandler: GetPreviewStatusHandler | undefined
export function setGetPreviewStatusHandler(handler: GetPreviewStatusHandler | undefined): void {
getPreviewStatusHandler = handler
}
function getSessionPreviewStatus(sessionId: string | undefined): string {
if (!getPreviewStatusHandler) {
return 'Error: get_preview_status is only available inside an AI session.'
}
return getPreviewStatusHandler(sessionId)
}
// Registered by the session runtime to reload the open preview after a chat
// deploy. Undefined outside a session.
export type DeployedInSessionHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app'
path: string
}) => void
let deployedInSessionHandler: DeployedInSessionHandler | undefined
export function setDeployedInSessionHandler(handler: DeployedInSessionHandler | undefined): void {
deployedInSessionHandler = handler
}
type DraftConfig = Record<string, any>
@@ -1865,7 +2012,7 @@ function buildVariableDeployRequestBody(
function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void {
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: `Writing draft ${type} "${path}"...`
content: `Saving ${type} "${path}" to local storage…`
})
}
@@ -1890,13 +2037,13 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra
: stored
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: `${verb} local draft ${stored.type} "${stored.path}"`,
result: `Draft ${verb.toLowerCase()}`
content: `${verb} ${stored.type} "${stored.path}" in local storage`,
result: `Saved to local storage`
})
return JSON.stringify(
{
success: true,
message: `${verb} local draft ${stored.type} "${stored.path}". The workspace was not saved or deployed.`,
message: `${verb} ${stored.type} "${stored.path}" in local storage (a browser-only local draft, not a workspace draft). It was not deployed.`,
item: serializedItem
},
null,
@@ -2345,7 +2492,7 @@ async function initApp(
}
toolCallbacks.setToolStatus(toolId, {
content: `Initializing app draft "${path}" with ${framework} template...`
content: `Saving app "${path}" to local storage (${framework} template)…`
})
const template = FRAMEWORK_TEMPLATES[framework]
@@ -2365,13 +2512,13 @@ async function initApp(
const stored = saveAppDraft(workspace, path, value)
toolCallbacks.setToolStatus(toolId, {
content: `Initialized app draft "${path}" (${framework})`,
result: 'Draft initialized'
content: `Saved app "${path}" to local storage (${framework})`,
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Initialized local draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`,
message: `Initialized app "${path}" in local storage from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (a browser-only local draft, not a workspace draft). Use write_app_file / write_app_runnable to evolve it.`,
item: stored
},
null,
@@ -2428,12 +2575,12 @@ async function writeAppFile(
toolCallbacks.setToolStatus(toolId, {
content: `Updated ${target.filePath} in app "${args.path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Updated local draft app "${args.path}" with frontend file "${target.filePath}".`,
message: `Updated app "${args.path}" in local storage with frontend file "${target.filePath}".`,
item: stored
},
null,
@@ -2468,12 +2615,12 @@ async function deleteAppFile(
toolCallbacks.setToolStatus(toolId, {
content: `Removed ${target.filePath} from app "${args.path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Removed "${target.filePath}" from local draft app "${args.path}".`,
message: `Removed "${target.filePath}" from app "${args.path}" in local storage.`,
item: stored
},
null,
@@ -2551,12 +2698,12 @@ async function patchAppFile(
const stored = saveAppDraft(workspace, path, value, meta)
toolCallbacks.setToolStatus(toolId, {
content: `Patched ${target.filePath} in app "${path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Patched "${target.filePath}" in local draft app "${path}".`,
message: `Patched "${target.filePath}" in app "${path}" in local storage.`,
item: stored
},
null,
@@ -2565,9 +2712,10 @@ async function patchAppFile(
}
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
const policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as NonNullable<
AppDraftValue['policy']
>
const policy = (await updateRawAppPolicy(
value.runnables as any,
value.policy as any
)) as NonNullable<AppDraftValue['policy']>
if (!policy.execution_mode) {
policy.execution_mode = 'publisher'
}
@@ -2593,12 +2741,12 @@ async function writeAppRunnable(
toolCallbacks.setToolStatus(toolId, {
content: `Updated runnable "${key}" in app "${path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Updated local draft app "${path}" with runnable "${key}".`,
message: `Updated app "${path}" in local storage with runnable "${key}".`,
item: stored
},
null,
@@ -2627,12 +2775,12 @@ async function deleteAppRunnable(
toolCallbacks.setToolStatus(toolId, {
content: `Removed runnable "${key}" from app "${path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Removed runnable "${key}" from local draft app "${path}".`,
message: `Removed runnable "${key}" from app "${path}" in local storage.`,
item: stored
},
null,
@@ -2718,13 +2866,13 @@ async function discardLocalDraft(
deleteGlobalDraft(workspace, type, path, triggerKind)
toolCallbacks.setToolStatus(toolId, {
content: `Discarded local draft ${type} "${path}"`,
result: 'Draft discarded'
content: `Discarded ${type} "${path}" from local storage`,
result: 'Discarded from local storage'
})
return JSON.stringify(
{
success: true,
message: `Discarded local draft ${type} "${path}". The deployed workspace item was not changed.`,
message: `Discarded the local-storage draft for ${type} "${path}". The deployed workspace item was not changed.`,
type,
path,
triggerKind
@@ -2743,7 +2891,7 @@ async function deployDraft(
},
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const { workspace, toolId, toolCallbacks, sessionId } = ctx
const { type, path, trigger_kind: triggerKind, deployment_message: deploymentMessage } = args
if (type === 'trigger' && !triggerKind) {
@@ -2932,6 +3080,17 @@ async function deployDraft(
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
// Reload the session preview if it's open on the deployed item. Map the
// deploy type to the preview kind — a raw app deploys under 'app' but the
// preview addresses it as 'raw_app'; non-previewable types map to undefined.
const previewKindByType: Partial<Record<WorkspaceItemType, 'script' | 'flow' | 'raw_app'>> = {
script: 'script',
flow: 'flow',
app: 'raw_app'
}
const kind = previewKindByType[type]
if (kind) deployedInSessionHandler?.({ sessionId, kind, path })
toolCallbacks.setToolStatus(toolId, {
content: `Deployed ${type} "${path}"`,
result: 'Deployed',
@@ -3009,9 +3168,11 @@ async function deleteWorkspaceItem(
}
export function prepareGlobalSystemMessage(
customPrompt?: string
customPrompt?: string,
opts?: { previewTools?: boolean }
): ChatCompletionSystemMessageParam {
let content = GLOBAL_SYSTEM_PROMPT
const username = get(userStore)?.username ?? ''
let content = buildGlobalSystemPrompt(username, opts?.previewTools ?? false)
if (customPrompt?.trim()) {
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
}
@@ -13,7 +13,11 @@
import type { Flow, Job } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
import {
AIChatManager,
aiChatManager as singletonAiChatManager,
AIMode
} from '../copilot/chat/AIChatManager.svelte'
import type { GraphModuleState } from '../graph'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
@@ -22,6 +26,8 @@
import { extractAllModules } from '../copilot/chat/shared'
import type { Snippet } from 'svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
interface Props {
loading: boolean
@@ -132,14 +138,18 @@
})
onMount(() => {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.FLOW)
if (!sessionScopedManager) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.FLOW)
}
})
onDestroy(() => {
aiChatManager.flowOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
if (!sessionScopedManager) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
}
})
</script>
@@ -867,6 +867,7 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -930,6 +931,7 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -200,6 +200,9 @@
markRemovedAsShadowed?: boolean
controlsPosition?: 'top' | 'bottom'
outerDivClass?: string
/** Fires when the computed graph height changes. Diff views can use
* this to equalize heights of side-by-side graphs. */
onHeight?: (height: number) => void
}
let {
@@ -273,7 +276,8 @@
onMoveMultiple = undefined,
movingIds = undefined,
controlsPosition = 'top',
outerDivClass = ''
outerDivClass = '',
onHeight = undefined
}: Props = $props()
// Initialize note manager with fine-grained reactivity
@@ -759,6 +763,7 @@
const computed = maxBottom - minY
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
}
onHeight?.(height)
}
$effect(() => {
@@ -66,6 +66,9 @@
}
| undefined
diffDrawer?: DiffDrawer | undefined
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/** Fired after a successful deploy; the session preview reloads on it. */
onDeploy?: (e: { path: string }) => void
/** Initial collapsed state for the file/runnable sidebar. The user's
* toggled preference is persisted under `sidebarStorageKey`; this prop
* only seeds the very first open. */
@@ -75,6 +78,14 @@
* preference. */
sidebarStorageKey?: string
liveEditorDraftStoragePath?: string
/** Initial value for the "Split with Preview" tab-bar toggle. Defaults
* to `true` (split mode, preview always pinned to the right). Set
* `false` when the editor mounts inside a context that wants single-
* view by default with the Preview tab selected — e.g. session
* previews, where the editor pane is already narrow. The user can
* still toggle the mode after mount; this prop only seeds the
* initial state. */
defaultSplitWithPreview?: boolean
}
let {
@@ -88,9 +99,12 @@
newPath = undefined,
savedApp = $bindable(undefined),
diffDrawer = undefined,
onNavigate,
onDeploy = undefined,
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined
liveEditorDraftStoragePath = undefined,
defaultSplitWithPreview = true
}: Props = $props()
export const version: number | undefined = undefined
@@ -225,7 +239,9 @@
}
let tabs: TabItem[] = $state([previewTab])
let activeTabId: string = $state(PREVIEW_TAB_ID)
let splitWithPreview: boolean = $state(true)
// Seed from the prop, then own the state locally so the user's toggle
// after mount sticks even if the prop reference changes.
let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview))
const activeTabKind = $derived<'file' | 'runnable' | 'preview'>(
activeTabId === PREVIEW_TAB_ID
? 'preview'
@@ -255,11 +271,23 @@
const showRunnable = $derived(activeTabKind === 'runnable')
// Mount the UI Builder iframe the first time a file is shown (paneA has
// width then; mounting it at 0-width breaks the VS Code workbench), and
// keep it mounted so tab switches don't reload it.
// keep it mounted so tab switches don't reload it. Mount it as soon as
// either pane needs it: `showSource` for the source-editor view, OR the
// preview tab is active — the Preview iframe is fed by `preview`
// postMessages bundled by the UI Builder iframe, so it needs to be
// mounted even when the user opens the editor straight on Preview (e.g.
// session previews seeded with `defaultSplitWithPreview=false`).
let iframeShouldMount = $state(false)
$effect(() => {
if (showSource) iframeShouldMount = true
if (showSource || activeTabKind === 'preview') iframeShouldMount = true
})
// Width of the editor area (both inner panes). The UI Builder iframe is
// pre-mounted while it's the inactive tab so the editor is ready instantly;
// but the VS Code workbench inside crashes if it boots at 0 size. So while
// inactive we keep the iframe at this real width and hide it with
// `visibility` instead of collapsing it — Monaco boots correctly and
// revealing a file is just an unhide (no reload, no relayout, no latency).
let editorAreaWidth = $state(0)
// Inner pane sizes are a pure function of mode + active tab → derived.
// `paneARatio` is the user's last manual split drag (set by rememberPaneDrag).
@@ -994,7 +1022,11 @@
ensureFileTab(selectedDocument)
// Don't auto-activate — the user's tab choice wins.
// But if no file tab is currently active, fall in line.
if (activeTabKind === 'preview' && tabs.length === 2) {
// Skip this auto-activation in single-view-with-preview
// mode (the caller seeded `defaultSplitWithPreview=false`
// because Preview is the intended starting tab); the
// iframe's first setActiveDocument shouldn't fight that.
if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) {
activateTab(id)
}
}
@@ -1158,9 +1190,14 @@
})
})
// Open a default file on mount (boots the iframe; avoids a blank preview).
// Layout isn't persisted — each open starts fresh in split mode.
// Open a default file on mount (boots the iframe in split mode and gives
// the user something to edit on the left). When the caller seeded
// `defaultSplitWithPreview=false` we instead want the Preview tab as the
// only-visible / active surface, so skip the file-tab activation — the
// iframe still boots via `populateFiles`/`setFilesInIframe` even without
// a selected document.
onMount(() => {
if (!splitWithPreview) return
if (tabs.length === 1) {
const def = pickDefaultFile(files)
if (def) activateTab(ensureFileTab(def))
@@ -1332,6 +1369,8 @@
{data}
{runnables}
{getBundle}
{onNavigate}
{onDeploy}
canUndo={historyManager.canUndo}
canRedo={historyManager.canRedo}
onUndo={handleUndo}
@@ -1415,6 +1454,7 @@
Preview previously hid every tab.
-->
<div
bind:clientWidth={editorAreaWidth}
class="h-full w-full min-h-0 {splitWithPreview && activeTabKind !== 'preview'
? 'tabs-content-split'
: 'tabs-content-single'}"
@@ -1448,7 +1488,21 @@
{/snippet}
</DraggableTabs>
<div class="flex-1 min-h-0 relative">
<div class="absolute inset-0" style="display: {showSource ? 'block' : 'none'}">
<!--
Keep the UI Builder iframe mounted at a real (non-zero) size even
when it isn't the active tab: a hidden 0×0 mount crashes the VS
Code workbench's layout ("Unable to figure out browser width and
height") and wedges it on "Loading editor" with no recovery. While
inactive we size it to the editor area's width and hide it with
`visibility` (not `display`), so Monaco boots correctly and
revealing a file is an instant unhide.
-->
<div
class="absolute inset-0"
style={showSource
? ''
: `right: auto; width: ${editorAreaWidth || 800}px; visibility: hidden; pointer-events: none;`}
>
{#if iframeShouldMount}
<iframe
bind:this={iframe}
@@ -2,7 +2,7 @@
import { Drawer, DrawerContent } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { isMac, userPathPrefix } from '$lib/utils'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
@@ -66,6 +66,13 @@
// session-pane PR lands. Untyped getContext to avoid coupling to the
// AIChatManager class export (which lives on the chat-visuals PR).
const inSessionPane = !!getContext('aiChatManager')
// In a session pane the editor does NOT own the localStorage draft — the
// session runtime does, keyed by the session's (fork) workspace. So the
// `if (!inSessionPane) UserDraft.remove(...)` guards below skip the editor's
// own removal (it would target the wrong, main-`$workspaceStore` key). The
// session-side equivalent is RawAppEditorView's `onDeploy` →
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
// the preview to the deployed version.
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
import type { RawAppData } from './dataTableRefUtils'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
@@ -125,7 +132,10 @@
onOpenYamlEditor?: () => void
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
liveEditorDraftStoragePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => void
}
let {
@@ -150,13 +160,15 @@
onOpenYamlEditor = undefined,
sidebarCollapsed = false,
onToggleSidebar = undefined,
liveEditorDraftStoragePath = undefined
onNavigate = undefined,
liveEditorDraftStoragePath = undefined,
onDeploy = undefined
}: Props = $props()
let newEditedPath = $state(
untrack(() =>
newApp
? userPathPrefix($userStore?.username) + random_adj() + '_app'
? newPath || userPathPrefix($userStore?.username) + random_adj() + '_app'
: newPath || appPath || ''
)
)
@@ -285,8 +297,9 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', path)
if (!inSessionPane) UserDraft.remove('raw_app', path)
dispatch('savedNewAppPath', path)
onDeploy?.({ path })
} catch (e) {
sendUserToast(`Error creating app: ${e.body ?? e.message}`, true)
}
@@ -380,7 +393,6 @@
css
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: summary,
@@ -397,10 +409,11 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', appPath)
if (!inSessionPane) UserDraft.remove('raw_app', appPath)
if (appPath !== npath) {
dispatch('savedNewAppPath', npath)
}
onDeploy?.({ path: npath })
}
async function setPublishState() {
@@ -479,7 +492,7 @@
// The initial draft was promoted to a real path on the backend —
// drop the autosave keyed on the prior (possibly empty) path so
// a future "+ App" click opens on a clean slate.
UserDraft.remove('raw_app', appPath)
if (!inSessionPane) UserDraft.remove('raw_app', appPath)
dispatch('savedNewAppPath', newEditedPath)
} catch (e) {
sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true)
@@ -580,7 +593,7 @@
}
sendUserToast('Draft saved')
UserDraft.remove('raw_app', path)
if (!inSessionPane) UserDraft.remove('raw_app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
dispatch('savedNewAppPath', newEditedPath || path)
@@ -725,7 +738,13 @@
})
</script>
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
<!-- Inside a session pane the editor's content is continuously persisted to the
UserDraft (localStorage), so tearing the editor down on navigation loses
nothing — skip the unsaved-changes prompt. The standalone /apps_raw editor
keeps it. -->
{#if !inSessionPane}
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
{/if}
<DeployOverrideConfirmationModal
{deployedBy}
@@ -797,7 +816,7 @@
button: {
text: 'Looks good, deploy',
onClick: () => {
if (appPath == '') {
if (newApp || appPath == '') {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
@@ -818,7 +837,7 @@
startIcon={{ icon: Save }}
disabled={pathError != '' || customPathError != '' || app == undefined}
on:click={() => {
if (appPath == '') {
if (newApp || appPath == '') {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
@@ -925,7 +944,7 @@
savedPath={appPath || newPath || undefined}
kind="app"
raw_app
onNavigate={(item) => goto(editPathFor(item))}
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div></div>
</div>
+12 -1
View File
@@ -36,7 +36,10 @@ export interface ScriptBuilderProps {
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
children?: import('svelte').Snippet
onDeploy?: (e: { path: string; hash: string }) => void
// Fires on every successful deploy. `stay` is true for "Deploy & Stay here"
// and for lib scripts (the editor stays in place rather than navigating to
// the deployed item) — consumers should skip post-deploy navigation when set.
onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void
onDeployError?: (e: { path: string; error: any }) => void
onSaveInitial?: (e: { path: string; hash: string }) => void
onHistoryRestore?: () => void
@@ -45,4 +48,12 @@ export interface ScriptBuilderProps {
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
onNavigate?: (item: WorkspaceItem) => void
// Forwarded to the underlying ScriptEditor. When true, the right-hand
// test/run pane opens collapsed. Used by the session preview.
initialTestPanelCollapsed?: boolean
// Treat the path as already chosen (seeds the path "dirty" flag) so the
// summary→path auto-slug for new scripts (initialPath == '') doesn't
// overwrite it. Used by the session preview, which opens AI-created scripts
// as new but with a path the AI already assigned.
initialPathChosen?: boolean
}
@@ -0,0 +1,181 @@
<script lang="ts">
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import type { Flow } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { flowDraftSig } from './flowDraftSig'
import { initFlowState } from '$lib/components/flows/flowState'
import SessionItemNotFound from './SessionItemNotFound.svelte'
import { sendUserToast } from '$lib/toast'
let {
runtime,
path,
workspaceId,
onNavigate,
isActiveSession = true
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/**
* Only the visible session should claim the workspace's live-editor
* slot — without this, a hidden warm-mounted session can overwrite the
* active session's UserDraft live-editor target (one slot per
* (workspace, kind)), so chat actions like discard / "the open editor"
* resolve to the wrong session.
*/
isActiveSession?: boolean
} = $props()
let selectedId = $state('settings-metadata')
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadFlow(workspaceId, path))
}
})
// In a session pane, "restore" just reloads from the current state — the
// session target stays put. The Diff drawer's primary use here is viewing
// the diff; restore is best-effort.
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadFlow(workspaceId, path)
}
// Mark this editor as the "live editor" for the session's workspace so
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
// this path. Same registration the regular /flows/edit page does on
// mount, scoped to the session's (forked) workspace.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
// visible session resolve to the hidden one's path.
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'flow',
storagePath: path,
effectivePath: runtime.flowStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('flow', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<Flow>`.
// We hold a *live* handle (useMany) rather than reading via the static
// `UserDraft.get`. The handle materializes UserDraft's shared reactive
// `$state` cell for (workspace, 'flow', path), and that cell is what lets
// the chat's writes (UserDraft.save, from write_flow / patch_flow_json /
// set_flow_module_code) reach this preview. Without a live entry those
// writes only touch localStorage and the inbound effect below never
// re-fires. A reactive getter is used (not `use()`) because switching
// open_preview to another flow swaps `path` without remounting this view,
// so the handle must re-acquire.
//
// One-way-reactive discipline: inbound tracks only the handle's draft,
// outbound tracks only `flowStore.val`; the read on the "other side"
// inside each effect goes through `untrack()`. Without that asymmetry, a
// user keystroke would re-fire the inbound effect with the pre-keystroke
// stored value and revert the edit.
const draftHandles = UserDraft.useMany<Flow>(() => [
{ itemKind: 'flow', path, workspace: workspaceId }
])
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs when the handle's draft changes (AI write from
// this session's chat or another session). flowStore reads are untracked
// so the editor's own mutations don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const incoming = draftHandles[0]?.draft
if (!incoming) return
const sig = flowDraftSig(incoming)
untrack(() => {
if (runtime.loadedPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.flowStore.val
if (!current) return
lastInboundSig = sig
runtime.flowStore.val = {
...current,
value: incoming.value,
schema: incoming.schema ?? current.schema,
summary: incoming.summary ?? current.summary
}
// flowStateStore is keyed by module_id; after an AI write the set
// of module ids may differ, so rebuild the UI state. This wipes
// per-module test args / preview output for the new flow — a
// known v1 trade-off, see the plan's caveats.
void initFlowState(runtime.flowStore.val, runtime.flowStateStore)
})
})
// Editor → store. Re-runs on any deep mutation of flowStore.val
// (modules, schema, module bodies). The store read is untracked.
// Debounced 150ms so a typing burst inside an inline rawscript editor
// results in one serialise-and-write instead of one per keystroke.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedPath !== path) return
const flow = runtime.flowStore.val
if (!flow) return
const sig = flowDraftSig(flow)
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = UserDraft.get<Flow>('flow', path, { workspace: workspaceId })
if (current && flowDraftSig(current) === sig) return
UserDraft.save('flow', path, flow, { workspace: workspaceId })
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedFlow.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
isFlow
/>
{/if}
{#if runtime.loadingFlow && !runtime.loadedPath}
<div class="p-4 text-secondary text-sm">Loading flow {path}</div>
{:else if runtime.notFound && !runtime.loadedPath}
<SessionItemNotFound kind="flow" {path} {onNavigate} />
{:else}
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
has its own AI chat in the left pane, so the toggle is redundant here. -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={path}
newFlow={!runtime.savedFlow.val}
{selectedId}
loading={runtime.loadingFlow && !runtime.loadedPath}
bind:savedFlow={runtime.savedFlow.val}
{diffDrawer}
{onNavigate}
customUi={{ topBar: { aiBuilder: false } }}
onSaveDraft={() => runtime.scheduleForkComparisonRefresh()}
onDeploy={() => {
// FlowBuilder has no deploy toast and the session stays put, so toast
// here, then sync the preview to deployed (pulls the new locks + version_id).
sendUserToast('Deployed')
runtime.syncPreviewWithDeployed(workspaceId, 'flow', path)
}}
/>
{/if}
@@ -0,0 +1,732 @@
<script lang="ts">
import { parentFolderKey } from './forkDiffNav'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import {
AlertTriangle,
ArrowRight,
ChevronDown,
ChevronRight,
Folder,
GitFork,
GitMerge,
Loader2,
Minus,
Pencil,
Plus,
User
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, ExternalLink, SquareSplitHorizontal } from 'lucide-svelte'
import { WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff } from '$lib/gen'
import { getItemValue } from '$lib/utils_workspace_deploy'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
let {
forkWorkspaceId,
parentWorkspaceId
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
let drawer: Drawer | undefined = $state(undefined)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let loading = $state(false)
let error: string | undefined = $state(undefined)
let searchQuery = $state('')
let diffStyle = $state<'sbs' | 'inline'>('sbs')
const inlineDiff = $derived(diffStyle === 'inline')
const forkWs = $derived($userWorkspaces.find((w) => w.id === forkWorkspaceId))
const parentWs = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
export function open() {
drawer?.openDrawer()
void fetchComparison()
// Pull focus into the filter input so keyboard nav works without an
// extra click — drawer transition needs a tick first.
setTimeout(() => searchInputEl?.focus(), 50)
}
function openReview() {
goto(`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`)
}
async function fetchComparison() {
loading = true
error = undefined
// Per-item raw diffs are cached for the lifetime of the drawer.
// `loadDiffFor` early-returns on cache hit, so without this reset an
// edit-then-reopen would show fresh summary/counts but stale expanded
// raw content for any item the user had already drilled into.
loadedDiffs = {}
summaries = {}
try {
comparison = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId
})
// Diffs are expanded by default, so eagerly populate each row's
// content. Each loadDiffFor is idempotent and per-item, so
// rendering proceeds as values arrive.
if (comparison) {
for (const d of comparison.diffs) {
void loadDiffFor(d)
}
}
} catch (e) {
console.error('Fork diff: comparison failed', e)
error = `Failed to load comparison: ${e}`
comparison = undefined
} finally {
loading = false
}
}
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
function statusOf(d: WorkspaceItemDiff): DiffStatus {
if (d.exists_in_fork && !d.exists_in_source) return 'added'
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
if (d.ahead > 0 && d.behind > 0) return 'conflict'
return 'modified'
}
function itemKey(d: WorkspaceItemDiff): string {
return `${d.kind}/${d.path}`
}
// Editor URL for a diff row, scoped to the fork workspace.
function editUrlFor(d: WorkspaceItemDiff): string | undefined {
return buildEditUrl(d, forkWorkspaceId)
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
app: 'App',
raw_app: 'Raw app',
resource: 'Resource',
variable: 'Variable',
resource_type: 'Resource type',
folder: 'Folder',
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
// Lazily-loaded raw values per item, keyed by itemKey. Shaping (content
// vs metadata, YAML, lang detection) is owned by WorkspaceItemDiffViewer.
type LoadedDiff = {
state: 'loading' | 'ready' | 'error'
error?: string
parentRaw?: unknown
forkRaw?: unknown
}
let loadedDiffs: Record<string, LoadedDiff> = $state({})
// Per-item summary, derived from the fetched raw value so the tree on
// the left can show summary above the mono path (matches the picker).
let summaries: Record<string, string | undefined> = $state({})
async function loadDiffFor(d: WorkspaceItemDiff) {
const key = itemKey(d)
if (loadedDiffs[key]) return
loadedDiffs[key] = { state: 'loading' }
try {
// Source (parent) — empty for items only in fork. Fork — empty for
// items only in source. We swallow per-side errors so an "added"
// item still renders cleanly against an empty original.
const [parentRaw, forkRaw] = await Promise.all([
d.exists_in_source
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
d.exists_in_fork
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
// Prefer the fork's summary (the "current" side); fall back to parent.
const summary =
(forkRaw && typeof forkRaw === 'object' && (forkRaw as any).summary) ||
(parentRaw && typeof parentRaw === 'object' && (parentRaw as any).summary) ||
undefined
if (typeof summary === 'string' && summary.trim().length > 0) {
summaries[key] = summary
}
} catch (e) {
console.error('Fork diff: loadDiff failed', d, e)
loadedDiffs[key] = {
state: 'error',
error: String(e)
}
}
}
function onDetailsToggle(d: WorkspaceItemDiff, e: Event) {
const target = e.currentTarget as HTMLDetailsElement | null
if (target?.open) {
void loadDiffFor(d)
}
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
return 'blue'
}
const statusIcons = {
added: Plus,
removed: Minus,
modified: Pencil,
conflict: AlertTriangle
}
// File tree built from the diff paths. Top-level rows mirror
// WorkspaceItemDrillPicker: `f/foo` and `u/alice` collapse to a single
// "scope" row, then deeper segments split per `/`. Leaves carry their
// diff entry.
type FolderNode = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
children: TreeNode[]
}
type FileNode = { type: 'file'; name: string; diff: WorkspaceItemDiff }
type TreeNode = FolderNode | FileNode
function buildTree(diffs: WorkspaceItemDiff[]): FolderNode {
const root: FolderNode = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
children: []
}
const folderCache = new Map<string, FolderNode>()
for (const d of diffs) {
const parts = d.path.split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: d.path, diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
let scope = folderCache.get(scopeKey)
if (!scope) {
scope = {
type: 'folder',
name: scopeKey,
fullPath: scopeKey,
isScope: true,
children: []
}
folderCache.set(scopeKey, scope)
root.children.push(scope)
}
if (parts.length === 2) {
scope.children.push({ type: 'file', name: scopeKey, diff: d })
continue
}
const rest = parts.slice(2)
let parent = scope
let folderKey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
folderKey = `${folderKey}/${rest[i]}`
let folder = folderCache.get(folderKey)
if (!folder) {
folder = {
type: 'folder',
name: rest[i],
fullPath: folderKey,
isScope: false,
children: []
}
folderCache.set(folderKey, folder)
parent.children.push(folder)
}
parent = folder
}
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
}
const sortRec = (n: FolderNode) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return root
}
// Searchable string per diff: path + summary (when loaded) + kind label.
// SearchItems' uFuzzy runs fuzzy matching over these. Reads `summaries`
// directly so the index re-derives as summaries trickle in from
// loadDiffFor.
function searchableText(d: WorkspaceItemDiff): string {
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)]
if (s) parts.push(s)
return parts.join(' ')
}
let searchedDiffs: (WorkspaceItemDiff & { marked?: string })[] | undefined = $state(undefined)
// Empty query bypasses SearchItems entirely so we don't wait a tick for
// the async filter to run after open.
const filteredDiffs = $derived.by(() => {
const c = comparison
if (!c) return [] as WorkspaceItemDiff[]
const q = searchQuery.trim()
if (!q) return c.diffs
return (searchedDiffs ?? []) as WorkspaceItemDiff[]
})
const tree = $derived.by(() => {
const c = comparison
return c ? buildTree(filteredDiffs) : undefined
})
function rowId(d: WorkspaceItemDiff): string {
return `fork-diff-${itemKey(d)}`
}
function scrollToDiff(d: WorkspaceItemDiff) {
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
if (!el) return
el.open = true
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
// Per-folder open/closed state. Defaults to open; user toggles via the
// <details> summary or via Enter when a folder row is highlighted.
let folderOpen: Record<string, boolean> = $state({})
function isFolderOpen(key: string): boolean {
return folderOpen[key] ?? true
}
function folderKey(node: FolderNode): string {
return `folder:${node.fullPath}`
}
type NavEntry =
| { type: 'folder'; key: string; node: FolderNode }
| { type: 'file'; key: string; diff: WorkspaceItemDiff }
function flattenVisible(node: FolderNode): NavEntry[] {
const out: NavEntry[] = []
const walk = (n: TreeNode) => {
if (n.type === 'file') {
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
return
}
const fkey = folderKey(n)
out.push({ type: 'folder', key: fkey, node: n })
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
}
for (const c of node.children) walk(c)
return out
}
const navEntries = $derived(tree ? flattenVisible(tree) : [])
const navKeys = $derived(navEntries.map((e) => e.key))
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
let highlightedKey: string | undefined = $state(undefined)
let mouseActive = $state(false)
let searchInputEl: HTMLInputElement | undefined = $state()
let sidebarRoot: HTMLElement | undefined = $state()
$effect(() => {
if (navKeys.length === 0) return
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
highlightedKey = navKeys[0]
}
})
function scrollHighlightIntoView() {
if (!sidebarRoot || !highlightedKey) return
const el = sidebarRoot.querySelector<HTMLElement>(
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
function moveHighlight(delta: 1 | -1) {
if (navKeys.length === 0) return
const cur = navKeys.indexOf(highlightedKey ?? '')
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
highlightedKey = navKeys[next]
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function setHoverHighlight(key: string) {
// Same defense as the picker: ignore until the user actually moves the
// mouse, so a cursor parked over a row doesn't clobber the keyboard
// highlight when the layout shifts.
if (mouseActive) highlightedKey = key
}
function activateHighlighted() {
if (!highlightedKey) return
const entry = entryByKey.get(highlightedKey)
if (!entry) return
if (entry.type === 'file') {
scrollToDiff(entry.diff)
} else {
folderOpen[entry.key] = !isFolderOpen(entry.key)
}
}
// The folder containing an entry, as a folder key (or undefined if the
// entry is at the top scope and has no parent folder). Pure logic lives in
// forkDiffNav.parentFolderKey (unit-tested).
function parentFolderKeyFor(entry: NavEntry): string | undefined {
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
return parentFolderKey(entry.type, path)
}
function firstChildKey(node: FolderNode): string | undefined {
const c = node.children[0]
if (!c) return undefined
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
}
function selectKey(key: string) {
highlightedKey = key
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
activateHighlighted()
} else if (e.key === 'ArrowRight') {
// On a closed folder: open it. On an open folder: jump to its first
// child (folder or file). On a file: no-op.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry || entry.type !== 'folder') return
if (!isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = true
return
}
const child = firstChildKey(entry.node)
if (child) {
e.preventDefault()
selectKey(child)
}
} else if (e.key === 'ArrowLeft') {
// On an open folder: collapse it. On a closed folder (or a file):
// jump to the parent folder.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry) return
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = false
return
}
const parent = parentFolderKeyFor(entry)
if (parent && entryByKey.has(parent)) {
e.preventDefault()
selectKey(parent)
}
}
}
</script>
<SearchItems
filter={searchQuery}
items={comparison?.diffs ?? []}
bind:filteredItems={searchedDiffs}
f={(d: WorkspaceItemDiff) => searchableText(d)}
/>
{#snippet renderTreeNode(node: TreeNode, depth: number)}
{#if node.type === 'folder'}
{@const isUserScope = node.isScope && node.name.startsWith('u/')}
{@const fkey = folderKey(node)}
{@const open = isFolderOpen(fkey)}
{@const isHl = fkey === highlightedKey}
<details
{open}
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
class="select-none"
>
<summary
role="option"
aria-selected={isHl}
data-nav-key={fkey}
onmouseenter={() => setHoverHighlight(fkey)}
class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl
? 'bg-surface-hover'
: ''}"
style="padding-left: {depth * 12 + 8}px"
>
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
{#if isUserScope}
<User size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="truncate" title={node.name}>{node.name}</span>
</summary>
<div>
{#each node.children as child}
{@render renderTreeNode(child, depth + 1)}
{/each}
</div>
</details>
{:else}
{@const status = statusOf(node.diff)}
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind}
summary={summaries[key]}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={node.diff.path}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
}}
onmouseenter={() => setHoverHighlight(key)}
>
{#snippet extras()}
<span
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
? 'bg-green-500'
: status === 'removed'
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
{/if}
{/snippet}
<Drawer bind:this={drawer} size="1200px">
<DrawerContent
title="Fork changes"
on:close={() => drawer?.closeDrawer()}
documentationLink={undefined}
noPadding
overflow_y={false}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<GitFork class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{forkWs?.name ?? forkWorkspaceId}</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate">{parentWs?.name ?? parentWorkspaceId}</span>
{#if comparison}
<Badge color="transparent" class="ml-2">
{comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''}
</Badge>
{#if comparison.summary.conflicts > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
{/if}
</div>
{/snippet}
{#snippet actions()}
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
{#snippet children({ item })}
<ToggleButton
value="sbs"
label="Side-by-side"
icon={SquareSplitHorizontal}
tooltip="Side-by-side diff"
iconOnly
{item}
/>
<ToggleButton
value="inline"
label="Unified"
icon={DiffIcon}
tooltip="Unified diff"
iconOnly
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: GitMerge }} onclick={openReview}>
Review
</Button>
{/snippet}
<div class="flex flex-row h-full min-h-0">
{#if comparison && comparison.diffs.length > 0}
<aside
bind:this={sidebarRoot}
onmousemove={() => (mouseActive = true)}
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
>
<div class="px-3 pt-3 pb-2 shrink-0">
<input
bind:this={searchInputEl}
type="search"
bind:value={searchQuery}
placeholder="Filter files..."
onkeydown={handleSearchKeydown}
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
{#if tree && tree.children.length > 0}
{#each tree.children as child}
{@render renderTreeNode(child, 0)}
{/each}
{:else}
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
{/if}
</div>
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-y-auto">
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
{#if loading && !comparison}
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
<Loader2 class="w-4 h-4 animate-spin" />
Loading comparison...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
{:else if comparison?.skipped_comparison}
<div class="text-sm text-secondary py-4">
This fork was created before change tracking was added — diffs are not available.
</div>
{:else if comparison && comparison.diffs.length === 0}
<div class="text-sm text-secondary py-4"
>No changes between this fork and its parent.</div
>
{:else if comparison && filteredDiffs.length === 0}
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
{:else if comparison}
<div class="flex flex-col gap-2">
{#each filteredDiffs as d (itemKey(d))}
{@const key = itemKey(d)}
{@const status = statusOf(d)}
{@const StatusIcon = statusIcons[status]}
{@const loaded = loadedDiffs[key]}
{@const editUrl = editUrlFor(d)}
<details
open
id={rowId(d)}
class="border border-light rounded-md bg-surface scroll-mt-2"
ontoggle={(e) => onDetailsToggle(d, e)}
>
<summary
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
>
<ChevronDown
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
/>
<RowIcon kind={d.kind} size={14} />
<div class="min-w-0 flex-1">
{#if editUrl}
<a
href={editUrl}
target="_blank"
rel="noopener noreferrer"
title={d.path}
onclick={(e) => e.stopPropagation()}
class="group inline-flex items-center gap-1 max-w-full text-xs text-primary font-mono truncate hover:underline"
>
<span class="truncate">{d.path}</span>
<ExternalLink
class="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity"
/>
</a>
{:else}
<div class="text-xs text-primary font-mono truncate" title={d.path}>
{d.path}
</div>
{/if}
</div>
<div class="shrink-0 flex items-center gap-2">
{#if d.ahead > 0}
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
{/if}
{#if d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
</Badge>
</div>
</summary>
<div
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
>
{#if !loaded || loaded.state === 'loading'}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Loading diff…
</div>
{:else if loaded.state === 'error'}
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
{:else if loaded.state === 'ready'}
<WorkspaceItemDiffViewer
kind={d.kind}
originalRaw={loaded.parentRaw}
currentRaw={loaded.forkRaw}
{inlineDiff}
/>
{/if}
</div>
</details>
{/each}
</div>
{/if}
</div></main
></div
>
</DrawerContent>
</Drawer>
<style>
/* Diff rows use a ChevronDown; rotate it back when collapsed. */
details:not([open]) :global(.chevron) {
transform: rotate(-90deg);
}
/* Tree folder rows: swap chevrons based on the folder's open state. */
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
display: none;
}
details[open] > .tree-summary :global(.tree-chevron-closed) {
display: none;
}
</style>
@@ -0,0 +1,158 @@
<script lang="ts">
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import type { RawAppDraft } from './appDraftCodec'
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft } from './appDraftCodec'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
runtime,
path,
workspaceId,
onNavigate,
isActiveSession = true
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/**
* Only the visible session should claim the workspace's live-editor
* slot — without this, a hidden warm-mounted session can overwrite the
* active session's UserDraft live-editor target (one slot per
* (workspace, kind)), so chat actions like discard / "the open editor"
* resolve to the wrong session.
*/
isActiveSession?: boolean
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadRawApp(workspaceId, path))
}
})
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadRawApp(workspaceId, path)
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /apps_raw/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
// visible session resolve to the hidden one's path.
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'raw_app',
storagePath: path,
effectivePath: runtime.rawApp.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('raw_app', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<RawAppDraft>`.
// We hold a *live* handle (useMany) rather than reading via the static
// `UserDraft.get`: the handle materializes UserDraft's shared reactive
// `$state` cell for (workspace, 'raw_app', path), and that cell is what
// lets the chat's writes (UserDraft.save / setDraftAndMeta, from
// write_app_file / patch_app_file / write_app_runnable) reach this preview.
// Without a live entry those writes only touch localStorage and the inbound
// effect below never re-fires. A reactive getter is used (not `use()`)
// because switching open_preview to another app swaps `path` without
// remounting this view, so the handle must re-acquire.
//
// Same one-way-reactive discipline as ScriptEditorView: inbound tracks only
// the handle's draft, outbound tracks only rawApp.val; each side's read of
// the other goes through untrack() to break the keystroke-revert race.
const draftHandles = UserDraft.useMany<RawAppDraft>(() => [
{ itemKind: 'raw_app', path, workspace: workspaceId }
])
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs when the handle's draft changes (chat write,
// other session edit).
$effect(() => {
if (!workspaceId || !path) return
const incoming = draftHandles[0]?.draft
if (!incoming) return
const sig = JSON.stringify(incoming)
untrack(() => {
if (runtime.loadedRawAppPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.rawApp.val
if (!current) return
lastInboundSig = sig
runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming)
})
})
// Editor → store. Debounced 150ms so a typing burst inside a frontend
// file's Monaco editor coalesces into one store write.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedRawAppPath !== path) return
const raw = runtime.rawApp.val
if (!raw) return
const draft = runtimeRawAppToDraft(raw)
const sig = JSON.stringify(draft)
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = UserDraft.get<RawAppDraft>('raw_app', path, { workspace: workspaceId })
if (current && JSON.stringify(current) === sig) return
UserDraft.save('raw_app', path, draft, { workspace: workspaceId })
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedRawApp.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
/>
{/if}
{#if runtime.loadingRawApp && !runtime.loadedRawAppPath}
<div class="p-4 text-secondary text-sm">Loading raw app {path}</div>
{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath}
<SessionItemNotFound kind="raw_app" {path} {onNavigate} />
{:else if runtime.rawApp.val}
<RawAppEditor
bind:files={runtime.rawApp.val.files}
bind:runnables={runtime.rawApp.val.runnables}
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
newPath={runtime.rawApp.val.path}
{path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val}
{diffDrawer}
{onNavigate}
onDeploy={(e) => {
// Sync the preview to deployed (raw apps deploy only from this editor).
runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path)
}}
defaultSidebarCollapsed
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
defaultSplitWithPreview={false}
/>
{/if}
@@ -0,0 +1,224 @@
<script lang="ts">
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { DraftService, ScriptService, type NewScript } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import SessionItemNotFound from './SessionItemNotFound.svelte'
import { sendUserToast } from '$lib/toast'
let {
runtime,
path,
workspaceId,
onNavigate,
initialTestPanelCollapsed = false,
isActiveSession = true
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
initialTestPanelCollapsed?: boolean
/**
* Only the visible session should claim the workspace's live-editor
* slot — without this, a hidden warm-mounted session can overwrite the
* active session's UserDraft live-editor target (one slot per
* (workspace, kind)), so chat actions like discard / "the open editor"
* resolve to the wrong session.
*/
isActiveSession?: boolean
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadScript(workspaceId, path))
}
})
// Restore actions for the diff drawer. The previous shared
// `loadScript`-based handler was a no-op: loadScript early-returns on the
// already-loaded path (and would re-read the local draft anyway). Instead
// reset the live UserDraft handle to the target baseline — the inbound
// effect then syncs the editor preview. Mirrors /scripts/edit's restore.
async function restoreDeployed() {
const saved = runtime.savedScript.val
if (!saved) {
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer?.closeDrawer()
// Drop the backend (DB) draft too, so "deployed" sticks across a reload.
if (saved.draft) {
try {
await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path })
saved.draft = undefined
} catch (e: any) {
sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true)
return
}
}
const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown }
delete deployed.draft
UserDraft.discard<NewScript>('script', path, deployed, { workspace: workspaceId })
}
async function restoreDraft() {
const backendDraft = runtime.savedScript.val?.draft as NewScript | undefined
if (!backendDraft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer?.closeDrawer()
UserDraft.discard<NewScript>('script', path, structuredClone($state.snapshot(backendDraft)), {
workspace: workspaceId
})
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /scripts/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
// visible session resolve to the hidden one's path.
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'script',
storagePath: path,
effectivePath: runtime.scriptStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<NewScript>`.
// The same path under the same workspace is shared with the session's
// chat (read_workspace_item / write_script / edit_script) and any other
// open editor on the same workspace.
//
// We hold a *live* handle (useMany) instead of reading via the static
// `UserDraft.get`. The handle materializes UserDraft's shared reactive
// `$state` cell for (workspace, 'script', path) — and that cell is what
// lets the chat's writes (UserDraft.save, from write_script / edit_script)
// reach this preview. Without a live entry those writes only touch
// localStorage and the inbound effect below never re-fires. A reactive
// getter is used (not `use()`) because switching open_preview to another
// script swaps `path` without remounting this view, so the handle must
// re-acquire.
//
// One-way-reactive discipline: inbound tracks ONLY the handle's `draft`
// (and reads `script.content` via untrack); outbound tracks ONLY
// `script.content` (and reads UserDraft via untrack). Without that
// asymmetry, a user keystroke would re-fire the inbound effect with the
// pre-keystroke stored value and revert the edit.
const draftHandles = UserDraft.useMany<NewScript>(() => [
{ itemKind: 'script', path, workspace: workspaceId }
])
let lastInboundContent: string | undefined = $state(undefined)
// Store → editor. Re-runs when the handle's draft changes (chat write,
// other session edit, …). `script.content` is read inside untrack so user
// keystrokes don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = draftHandles[0]?.draft
if (!draft || typeof draft.content !== 'string') return
const incoming = draft.content
untrack(() => {
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
if (incoming === script.content) return
lastInboundContent = incoming
script.content = incoming
if (draft.language) script.language = draft.language
if (draft.summary !== undefined) script.summary = draft.summary
})
})
// Editor → store. Re-runs on `script.content` mutation (user typing
// or inbound write). UserDraft is read inside untrack so writing here
// doesn't ping-pong the inbound effect. `UserDraft.save` persists
// immediately and, now that the entry is live, updates the same cell the
// inbound effect reads (the content guard there makes it a no-op).
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
const content = script.content
if (content === lastInboundContent) return
untrack(() => {
const current = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
if (current && current.content === content) return
UserDraft.save<NewScript>(
'script',
path,
{ ...(current ?? script), ...script },
{
workspace: workspaceId
}
)
})
})
</script>
{#if runtime.savedScript.val}
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
{/if}
{#if runtime.loadingScript && !runtime.loadedScriptPath}
<div class="p-4 text-secondary text-sm">Loading script {path}</div>
{:else if runtime.notFoundScript && !runtime.loadedScriptPath}
<SessionItemNotFound kind="script" {path} {onNavigate} />
{:else if runtime.scriptStore.val}
<!--
A script with no backend version yet (AI-created, never saved or deployed
→ savedScript undefined) is a *new* script: pass an empty initialPath so
ScriptBuilder behaves exactly like /scripts/add — Save draft is enabled and
creates it on first save. On that save ScriptBuilder writes savedScript back
through the bind and sets its own initialPath to the path, flipping us into
edit mode (Save draft + Show diff) without navigating away.
-->
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={runtime.savedScript.val ? path : ''}
initialPathChosen={true}
neverShowMeta={true}
fullyLoaded={!runtime.loadingScript}
disableHistoryChange={true}
{diffDrawer}
{onNavigate}
{initialTestPanelCollapsed}
onSaveDraft={async (e) => {
runtime.scheduleForkComparisonRefresh()
// Re-pin parent_hash to the latest version so the next Deploy's conflict
// check (which runs before deploy, while the session stays mounted)
// doesn't misfire.
try {
const latest = await ScriptService.getScriptLatestVersion({
workspace: workspaceId,
path: e.path
})
const cur = runtime.scriptStore.val
if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash
} catch (err) {
console.error('Failed to sync parent_hash after save draft', err)
}
}}
onDeploy={(e) => {
// Fires on every deploy (primary, "Deploy & Stay here", and lib — we
// ignore e.stay since the session always stays). Toast, then sync the
// preview to the deployed version.
sendUserToast('Deployed')
runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path)
}}
/>
{/if}
@@ -0,0 +1,213 @@
<script lang="ts">
import {
Archive,
ArrowRight,
GitCompareArrows,
GitFork,
GitMerge,
GitPullRequestArrow,
GitPullRequestClosed,
MoveRight,
Trash2
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { goto } from '$lib/navigation'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { isCloudHosted } from '$lib/cloud'
import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import ForkDiffDrawer from './ForkDiffDrawer.svelte'
let {
session,
onMove,
onCreateForkAndMove,
onArchive,
onDelete
}: {
session: Session
onMove?: (workspaceId: string) => void
onCreateForkAndMove?: (fork: {
parent_workspace_id: string
id: string
name: string
}) => void | Promise<void>
onArchive?: () => void
onDelete?: () => void
} = $props()
// The fork bar surfaces a committed workspace relationship — only
// visible after the session locked its workspace at first send. Drafts
// (workspace_id undefined) get nothing here.
const committedId = $derived(session.workspace_id)
const sessionWorkspace = $derived(
committedId ? $userWorkspaces.find((w) => w.id === committedId) : undefined
)
const parentWorkspaceId = $derived(sessionWorkspace?.parent_workspace_id ?? undefined)
const parentWorkspace = $derived(
parentWorkspaceId ? $userWorkspaces.find((w) => w.id === parentWorkspaceId) : undefined
)
const isFork = $derived(!!parentWorkspaceId)
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
// When forking isn't available the diff/review surface is moot.
const forksAllowed = $derived(
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
)
let diffDrawer: ForkDiffDrawer | undefined = $state(undefined)
// Comparison data lives on the shared SessionRuntime resource so any
// future consumer (e.g. the diff drawer, a merge action) reads the
// same cache and can invalidate it after mutating the fork.
const runtime = $derived(getRuntime(session.id))
const comparison = $derived(runtime?.forkComparison.val)
const totalDiffs = $derived(comparison?.summary?.total_diffs ?? 0)
const forkStatus = $derived(deriveForkStatus(session, $userWorkspaces, comparison))
const isUnavailable = $derived(forkStatus === 'unavailable')
$effect(() => {
if (!runtime || !committedId || !parentWorkspaceId) return
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
})
function refreshComparison() {
if (!runtime || !committedId || !parentWorkspaceId) return
runtime.invalidateForkComparison()
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
}
// Refresh when the AI finishes a turn (loading transitions true →
// false). Tool calls in that turn may have created / edited / deleted
// fork items, so the diff count needs to reflect them immediately.
let wasLoading = $state(false)
$effect(() => {
const isLoading = runtime?.manager.loading ?? false
if (wasLoading && !isLoading) refreshComparison()
wasLoading = isLoading
})
// Refresh when the tab regains visibility — covers edits made in
// another tab or by another user while we were away.
$effect(() => {
if (!runtime || !committedId || !parentWorkspaceId) return
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
if (sessionState.currentSessionId !== session.id) return
refreshComparison()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
})
export const refresh = refreshComparison
function openReview() {
if (!committedId || isUnavailable) return
goto(`/forks/compare?workspace_id=${encodeURIComponent(committedId)}`)
}
</script>
{#if committedId && isUnavailable}
<!-- Fork workspace is no longer in the user's list (deleted, archived,
or access revoked). Surface an actionable banner: move the session
to a still-valid workspace, or discard it (archive / delete). The
chat input is disabled by SessionWrapper while this is shown. -->
<div class="flex flex-col gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary">
<div class="flex flex-row items-start gap-2">
<GitPullRequestClosed class="w-4 h-4 shrink-0 text-tertiary mt-0.5" />
<div class="flex flex-col min-w-0 flex-1">
<span class="text-primary font-medium">The fork has been archived or deleted</span>
<span class="text-2xs text-tertiary">
Move this session to another workspace, or discard it.
<span class="font-mono text-tertiary" title={committedId}>{committedId}</span>
</span>
</div>
</div>
<div class="flex flex-row items-center justify-end gap-1.5">
<WorkspaceFamilyPicker
onPick={(workspaceId) => onMove?.(workspaceId)}
onCreateFork={async (fork) => {
await onCreateForkAndMove?.(fork)
}}
createForkCaption="Created immediately and the session moved into it."
>
{#snippet trigger()}
<Button variant="default" unifiedSize="sm" startIcon={{ icon: MoveRight }}>
Move to workspace
</Button>
{/snippet}
</WorkspaceFamilyPicker>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Archive }}
dropdownItems={[{ label: 'Delete', icon: Trash2, onClick: () => onDelete?.() }]}
onclick={() => onArchive?.()}
>
Archive
</Button>
</div>
</div>
{:else if forksAllowed && isFork && sessionWorkspace && parentWorkspace && parentWorkspaceId && committedId}
{@const StatusIcon =
forkStatus === 'ahead'
? GitPullRequestArrow
: forkStatus === 'diverged'
? GitCompareArrows
: GitFork}
{@const statusColor =
forkStatus === 'ahead'
? 'text-blue-500'
: forkStatus === 'diverged'
? 'text-amber-500'
: 'text-secondary'}
{@const statusTitle =
forkStatus === 'ahead'
? 'Ahead of parent'
: forkStatus === 'diverged'
? 'Diverged from parent'
: forkStatus === 'in_sync'
? 'In sync with parent'
: 'Fork'}
<div
class="flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary"
>
<div class="flex items-center gap-1.5 min-w-0">
<span title={statusTitle} class="inline-flex shrink-0">
<StatusIcon class="w-3.5 h-3.5 {statusColor}" />
</span>
<span class="truncate text-secondary" title={sessionWorkspace.name}>
{sessionWorkspace.name}
</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="truncate text-secondary" title={parentWorkspace.name}>
{parentWorkspace.name}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: GitCompareArrows }}
disabled={totalDiffs === 0}
title="{totalDiffs} modified item{totalDiffs === 1 ? '' : 's'}"
onclick={() => diffDrawer?.open()}
>
{totalDiffs}
</Button>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: GitMerge }}
onclick={openReview}
>
Review
</Button>
</div>
</div>
<ForkDiffDrawer bind:this={diffDrawer} forkWorkspaceId={committedId} {parentWorkspaceId} />
{/if}
@@ -0,0 +1,54 @@
<script lang="ts">
import EditorHeader from '$lib/components/EditorHeader.svelte'
import type { WorkspaceItem, WorkspaceItemKind } from '$lib/components/workspacePicker'
import type { SessionTarget } from './sessionState.svelte'
const KIND_NOT_FOUND_LABEL: Record<SessionTarget['kind'], string> = {
flow: 'Flow',
script: 'Script',
raw_app: 'Raw app'
}
let {
kind,
path,
onNavigate
}: {
kind: SessionTarget['kind']
path: string
onNavigate?: (item: WorkspaceItem) => void
} = $props()
// EditorHeader's `kind` prop is 'flow' | 'script' | 'app'; raw apps are
// flagged via the `raw_app` boolean so the picker routes to /apps_raw/...
const headerKind: WorkspaceItemKind = $derived(kind === 'raw_app' ? 'app' : kind)
const isRawApp = $derived(kind === 'raw_app')
// Local read-only mirrors so EditorHeader's bind:* doesn't write back into
// the parent's session state. The pen popover is disabled below so these
// stay quiet — but bindable references are still required by the API.
let summary = $state('')
let displayPath = $state(path)
$effect(() => {
displayPath = path
})
</script>
<div class="flex flex-col h-full">
<div class="flex h-12 items-center px-4 border-b border-border-light">
<EditorHeader
bind:summary
bind:path={displayPath}
savedPath={path}
kind={headerKind}
raw_app={isRawApp}
summaryEditable={false}
pathEditable={false}
{onNavigate}
/>
</div>
<div class="p-4 text-secondary text-sm">
{KIND_NOT_FOUND_LABEL[kind]} not found at path
<code class="font-mono">{path}</code>.
</div>
</div>
@@ -0,0 +1,629 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import {
Archive,
ArchiveRestore,
ChevronDown,
ChevronRight,
EllipsisVertical,
Filter,
MessageSquare,
Pencil,
PencilLine,
Plus,
Trash2
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import {
createSession,
deriveForkStatus,
getEffectiveWorkspaceId,
isForkSession,
renameSession,
selectSession,
sessionState,
setSessionArchived,
syncWorkspaceTo,
type Session
} from './sessionState.svelte'
import { forgetSessionSeen, unreadCountFor } from './sessionUnread.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
getOrCreateRuntime,
getRuntime,
getSessionChatStatus,
removeSession
} from './sessionRuntime.svelte'
import SessionStatusDot from './SessionStatusDot.svelte'
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { visibleWorkspaceIds } from './sessionScope.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
// Look up the cached fork comparison for a session through its runtime
// (if any). The deriveForkStatus helper handles the "no runtime yet"
// and "comparison not loaded" cases by returning undefined; we render
// a neutral fork icon in that interim, then upgrade to the proper
// status icon once the comparison lands.
function forkStatusFor(session: Session) {
return deriveForkStatus(session, $userWorkspaces, getRuntime(session.id)?.forkComparison.val)
}
function isForkFor(session: Session): boolean {
return isForkSession(session, $userWorkspaces)
}
// Compute the unread count for a session. Driven by the per-runtime
// displayMessages array vs. the localStorage-backed lastSeen map;
// both are reactive so the badge updates without polling.
function unreadFor(session: Session): number {
return unreadCountFor(session.id, getRuntime(session.id))
}
// Whether the composer for a session holds non-whitespace text. We
// read manager.instructions directly (not the derived chat status)
// so the draft cue still shows during streaming/needs-confirmation —
// those override the icon slot but shouldn't hide the fact that the
// user has unsent text in this session.
function hasDraft(session: Session): boolean {
const rt = getRuntime(session.id)
return !!rt && rt.manager.instructions.trim().length > 0
}
// Sessions piggyback on the same dev gate as the global AI chat — when
// 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)
interface Props {
isCollapsed?: boolean
}
let { isCollapsed = false }: Props = $props()
const sectionCollapsed = useLocalStorageValue(
'windmill_sessions_section_collapsed',
false,
'boolean'
)
const showArchived = useLocalStorageValue('windmill_sessions_show_archived', false, 'boolean')
let listRoot: HTMLDivElement | undefined = $state()
// Sessions visible in the current workspace (active workspace + its
// forks). Drafts (no committed workspace) are scoped by their
// pending workspace pick — set at create time to the workspace the
// user was in. Archived sessions are filtered out unless the user
// has opted in via the filter popover.
const visibleSessions = $derived(
sessionState.sessions.filter((s) => {
// Transient (not-yet-sent) sessions live as their own page but
// don't clutter the sidebar list.
if (s.transient) return false
if (s.archived && !showArchived.val) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
// Unavailable sessions (committed workspace was deleted /
// archived / access revoked) stay visible everywhere so the
// user can resolve them — move, archive, or delete. They'd
// otherwise be permanently hidden the moment their workspace
// disappeared.
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
const archivedCount = $derived(
sessionState.sessions.filter((s) => {
if (!s.archived || s.transient) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
}).length
)
// Sum of unread across every visible session — surfaced on the
// collapsed-sidebar chat icon so the user sees there's pending
// AI activity in some session without expanding the sidebar.
const totalUnread = $derived(visibleSessions.reduce((acc, s) => acc + unreadFor(s), 0))
// Clear any persisted collapsed state while the list is empty. The
// empty-state header is a plain label with no toggle, so a collapse
// carried over from a previous session (or another workspace) would
// otherwise hide the user's first new session with no way to expand
// it. Resetting here keeps the section expanded by default whenever
// the first session arrives. Guarded on the current value so it writes
// once (true → false) rather than looping.
$effect(() => {
if (visibleSessions.length === 0 && sectionCollapsed.val) {
sectionCollapsed.val = false
}
})
// Eagerly create a runtime per VISIBLE session so the status dot reflects
// the persisted chat (last message, pending confirmation, etc.) without
// requiring the user to open the session first. Sessions outside the
// current workspace scope are left cold to avoid opening IDB connections
// for unrelated work.
$effect(() => {
for (const session of visibleSessions) {
getOrCreateRuntime(session)
}
})
// Pre-fetch the fork comparison for every visible fork session so the
// sidebar icons reflect the right ahead/diverged state without
// requiring the user to click into each session. Cheap enough at
// typical session counts; falls back to a plain dot until the
// fetch lands.
$effect(() => {
if (sectionCollapsed.val) return
for (const session of visibleSessions) {
if (!session.workspace_id) continue
const ws = $userWorkspaces.find((w) => w.id === session.workspace_id)
if (!ws?.parent_workspace_id) continue
const rt = getRuntime(session.id)
if (!rt) continue
void rt.ensureForkComparison(ws.parent_workspace_id, session.workspace_id)
}
})
function isUnavailableFork(session: Session): boolean {
return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id)
}
async function activate(session: Session, restoreFocus: boolean = false) {
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)
}
// Refresh the fork diff count — users typically click back into a
// session after editing items elsewhere in the SPA, where neither
// the visibility-change nor the AI-loading signal would fire.
void getRuntime(session.id)?.refreshForkComparison()
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
if (restoreFocus) {
// goto() resets focus to <body> — put it back on the active session button
// so subsequent arrow keys keep navigating the list.
requestAnimationFrame(() => {
const selected = listRoot?.querySelector<HTMLButtonElement>(
'button[data-session-button][aria-selected="true"]'
)
selected?.focus()
})
}
}
async function createAndOpen() {
await activate(createSession())
}
let editingId: string | undefined = $state(undefined)
let renameDraft = $state('')
function startRename(session: Session) {
editingId = session.id
renameDraft = session.summary ?? ''
}
function commitRename() {
const id = editingId
if (!id) return
renameSession(id, renameDraft)
editingId = undefined
}
function cancelRename() {
editingId = undefined
}
let pendingDelete: Session | undefined = $state(undefined)
// Default to also deleting the fork: it's tied to this session and would be
// orphaned otherwise. The user can still untick it in the modal.
let deleteAlsoFork = $state(true)
// Fork workspace tied to `pendingDelete`, if any, and still accessible.
const pendingDeleteForkId = $derived.by(() => {
const wsId = pendingDelete?.workspace_id
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
const ws = $userWorkspaces.find((w) => w.id === wsId)
if (!ws || !ws.parent_workspace_id) return undefined
return wsId
})
async function handleConfirmedDelete() {
const session = pendingDelete
const forkToDelete = deleteAlsoFork ? pendingDeleteForkId : undefined
// Capture the fork's parent before the workspace list is refreshed
// below — afterwards the fork is gone from $userWorkspaces and the
// lookup would return undefined.
const forkParentId = forkToDelete
? $userWorkspaces.find((w) => w.id === forkToDelete)?.parent_workspace_id
: undefined
pendingDelete = undefined
deleteAlsoFork = true
if (!session) return
const wasActive = sessionState.currentSessionId === session.id
removeSession(session.id)
forgetSessionSeen(session.id)
if (forkToDelete) {
try {
await WorkspaceService.deleteWorkspace({ workspace: forkToDelete })
sendUserToast(`Deleted forked workspace ${forkToDelete}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e: any) {
sendUserToast(`Failed to delete fork ${forkToDelete}: ${e?.body ?? e}`, true)
}
}
// If the deleted fork was the active workspace, fall back to its parent
// so the user isn't stranded on a workspace that no longer exists.
if (forkToDelete && forkParentId && $workspaceStore === forkToDelete) {
syncWorkspaceTo(forkParentId)
}
if (wasActive) {
const next = sessionState.sessions[0]
if (next) await activate(next)
else await goto('/sessions')
}
}
function focusAt(index: number) {
const buttons = listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
: []
if (buttons.length === 0) return
const wrapped = ((index % buttons.length) + buttons.length) % buttons.length
buttons[wrapped]?.focus()
}
function handleListKeydown(e: KeyboardEvent) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') {
return
}
const buttons = listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
: []
if (buttons.length === 0) return
const current = buttons.indexOf(document.activeElement as HTMLButtonElement)
e.preventDefault()
if (e.key === 'ArrowDown') focusAt(current < 0 ? 0 : current + 1)
else if (e.key === 'ArrowUp') focusAt(current < 0 ? buttons.length - 1 : current - 1)
else if (e.key === 'Home') focusAt(0)
else if (e.key === 'End') focusAt(buttons.length - 1)
}
const menuItemBase = twMerge(
'text-secondary text-left font-normal text-xs',
'flex flex-row items-center gap-2 px-3 py-1.5 w-full',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
</script>
{#if !globalEnabled}
<!-- Sessions hidden until the global-ai dev gate is enabled. -->
{:else if isCollapsed}
<div class="px-2 pt-3 pb-2 border-b border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<div class="relative">
<MenuButton
class="!text-xs"
icon={MessageSquare}
label="AI sessions"
{isCollapsed}
{trigger}
/>
{#if totalUnread > 0}
<span
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-blue-500"
aria-label="{totalUnread} unread message{totalUnread === 1
? ''
: 's'} across all sessions"
></span>
{/if}
</div>
{/snippet}
{#snippet children({ item })}
<div class="divide-y min-w-48" role="none">
<div class="py-1" role="none">
<MenuItem class={menuItemBase} onClick={createAndOpen} {item}>
<Plus size={14} />
New session
</MenuItem>
</div>
<div class="py-1" role="none">
{#each visibleSessions as session (session.id)}
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected =
onSessionsPage && session.id === sessionState.currentSessionId}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<MenuItem
class={twMerge(menuItemBase, isSelected ? 'bg-surface-hover' : '')}
onClick={() => activate(session)}
{item}
>
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<span
class={twMerge(
'truncate flex-1 text-left',
unread > 0 ? 'font-semibold text-primary' : ''
)}
>
{session.summary ?? 'Untitled session'}
</span>
{#if draft || unread > 0}
<span class="ml-auto 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}
</MenuItem>
{/each}
</div>
</div>
{/snippet}
</Menu>
{/snippet}
</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-row items-center justify-between pl-1 pr-0.5">
{#if visibleSessions.length > 0}
<button
type="button"
onclick={() => (sectionCollapsed.val = !sectionCollapsed.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={!sectionCollapsed.val}
>
AI sessions
{#if sectionCollapsed.val}
<ChevronRight size={10} />
{:else}
<ChevronDown size={10} />
{/if}
</button>
{:else}
<!-- No sessions yet: render the label as plain text (no collapse toggle). -->
<span
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 px-1 -mx-1 py-0.5"
>
AI sessions
</span>
{/if}
<div class="flex flex-row items-center gap-0.5">
<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
? 'text-emphasis'
: ''}"
>
<Filter size={12} />
</button>
{/snippet}
{#snippet content()}
<div
class="w-56 p-2 bg-surface-tertiary dark:border rounded-md shadow-lg flex flex-col gap-1"
>
<Toggle
bind:checked={showArchived.val}
size="xs"
options={{ right: 'Show archived' }}
/>
{#if archivedCount > 0}
<span class="text-2xs text-tertiary pl-1">
{archivedCount} archived session{archivedCount === 1 ? '' : 's'}
</span>
{/if}
</div>
{/snippet}
</Popover>
<Button
variant="subtle"
size="xs2"
iconOnly
startIcon={{ icon: Plus }}
onclick={createAndOpen}
title="New session"
/>
</div>
</div>
{#if !sectionCollapsed.val}
<div
bind:this={listRoot}
transition:slide={{ duration: 180 }}
class="flex flex-col gap-0.5 max-h-[40vh] overflow-y-auto"
onkeydown={handleListKeydown}
role="listbox"
tabindex="-1"
>
{#each visibleSessions 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 ? 'italic opacity-60' : ''
)}
>
{#if isEditing}
<span class="flex flex-row items-center gap-2 flex-1 px-2 py-1 min-w-0">
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={renameDraft}
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'
)}
>
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(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
? {
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
: {
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>
{/each}
</div>
{/if}
</div>
{/if}
<ConfirmationModal
open={!!pendingDelete}
title="Delete session"
confirmationText="Delete"
onConfirmed={handleConfirmedDelete}
onCanceled={() => {
pendingDelete = undefined
deleteAlsoFork = true
}}
>
<div class="flex flex-col gap-3">
<p>
Delete session <span class="font-medium text-primary"
>{pendingDelete?.summary ?? pendingDelete?.name}</span
>? This cannot be undone.
</p>
{#if pendingDeleteForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={deleteAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also delete forked workspace <span class="font-mono">{pendingDeleteForkId}</span></span
>
<span class="text-3xs text-tertiary"
>The fork won't be reachable from any other session — leaving it would orphan it.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
@@ -0,0 +1,98 @@
<script lang="ts">
import {
AlertCircle,
AlertTriangle,
Building,
GitCompareArrows,
GitFork,
GitPullRequestArrow,
GitPullRequestClosed
} from 'lucide-svelte'
import type { SessionChatStatus } from './sessionRuntime.svelte'
import type { ForkStatus } from './sessionState.svelte'
let {
status,
isFork,
forkStatus
}: { status: SessionChatStatus; isFork: boolean; forkStatus?: ForkStatus } = $props()
const statusTooltip: Record<SessionChatStatus, string> = {
idle: 'No chat activity',
streaming: 'Generating response…',
'awaiting-user': 'Waiting for your reply',
'needs-confirmation': 'Needs your confirmation',
draft: 'Unsent draft',
error: 'Last message had an error'
}
const forkTooltip: Record<ForkStatus, string> = {
in_sync: 'Fork — in sync with parent',
ahead: 'Fork — ahead of parent',
diverged: 'Fork — diverged from parent',
unavailable: 'Fork — no longer available'
}
// Live chat signals take precedence over the persistent kind/fork
// indicator: streaming, needs-confirmation, and error are time-critical
// and warrant briefly hijacking the icon slot.
const liveOverride = $derived(
status === 'streaming' || status === 'needs-confirmation' || status === 'error'
)
const persistentTitle = $derived(
isFork ? (forkStatus ? forkTooltip[forkStatus] : 'Fork session') : 'Root workspace session'
)
const title = $derived(liveOverride ? statusTooltip[status] : persistentTitle)
</script>
<span class="inline-flex items-center justify-center w-4 h-3 shrink-0" {title}>
{#if status === 'streaming'}
<span class="inline-flex items-end gap-0.5">
<span class="w-1 h-1 rounded-full bg-blue-500 typing-dot"></span>
<span class="w-1 h-1 rounded-full bg-blue-500 typing-dot dot-2"></span>
<span class="w-1 h-1 rounded-full bg-blue-500 typing-dot dot-3"></span>
</span>
{:else if status === 'needs-confirmation'}
<AlertCircle class="w-3 h-3 text-amber-500" />
{:else if status === 'error'}
<AlertTriangle class="w-3 h-3 text-red-500" />
{:else if isFork}
{#if forkStatus === 'ahead'}
<GitPullRequestArrow class="w-3 h-3 text-blue-500" />
{:else if forkStatus === 'diverged'}
<GitCompareArrows class="w-3 h-3 text-amber-500" />
{:else if forkStatus === 'unavailable'}
<GitPullRequestClosed class="w-3 h-3 text-red-500" />
{:else}
<GitFork class="w-3 h-3 text-tertiary" />
{/if}
{:else}
<Building class="w-3 h-3 text-tertiary" />
{/if}
</span>
<style>
.typing-dot {
animation: typing 1.2s ease-in-out infinite;
}
.dot-2 {
animation-delay: 0.15s;
}
.dot-3 {
animation-delay: 0.3s;
}
@keyframes typing {
0%,
60%,
100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-1px);
}
}
</style>
@@ -0,0 +1,75 @@
<script lang="ts">
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
import {
getEffectiveWorkspaceId,
setSessionPendingFork,
setSessionPendingWorkspace,
syncWorkspaceTo,
type Session
} from './sessionState.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { Building, ChevronDown, GitFork } from 'lucide-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.
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>
<WorkspaceFamilyPicker
selectedId={effectiveId}
{pendingFork}
onPick={pick}
onCreateFork={stageFork}
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}
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
{/if}
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary" />
</span>
{/snippet}
</WorkspaceFamilyPicker>
</div>
@@ -0,0 +1,513 @@
<script lang="ts">
import { setContext } from 'svelte'
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 ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Toggle from '$lib/components/Toggle.svelte'
import { copilotInfo, loadCopilot } from '$lib/aiStore'
import {
Archive,
ArchiveRestore,
EllipsisVertical,
PanelRightClose,
PanelRightOpen,
Pencil,
Trash2
} from 'lucide-svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import WorkspaceItemDrillPicker from '$lib/components/WorkspaceItemDrillPicker.svelte'
import FlowEditorView from './FlowEditorView.svelte'
import ScriptEditorView from './ScriptEditorView.svelte'
import RawAppEditorView from './RawAppEditorView.svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import SessionForkBar from './SessionForkBar.svelte'
import {
createSession,
getEffectiveWorkspaceId,
moveSessionToNewFork,
moveSessionToWorkspace,
persistSessions,
selectSession,
sessionState,
setSessionArchived,
setSessionTarget,
syncWorkspaceTo,
type SessionTarget
} from './sessionState.svelte'
import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import { goto } from '$lib/navigation'
import { slide } from 'svelte/transition'
let { sessionId }: { sessionId: string } = $props()
// LRU-warm sessions get their editor pane mounted; others render
// chat-only. Reading from the reactive Set keeps SessionWrapper in
// sync with promoteEditorWarm without an explicit prop round-trip
// through the page route.
const mountEditor = $derived(editorWarmIds.has(sessionId))
// Parent keys by sessionId; this wrapper only mounts when the session exists.
// Captured at script-init so we can synchronously bind context.
const initialSession = sessionState.sessions.find((s) => s.id === sessionId)
const runtime = initialSession ? getOrCreateRuntime(initialSession) : undefined
if (runtime) {
setContext<AIChatManager>('aiChatManager', runtime.manager)
}
// Reactive session reference (mutations to summary/target propagate via the $state proxy)
const session = $derived(sessionState.sessions.find((s) => s.id === sessionId))
$effect(() => {
if ($workspaceStore) {
loadCopilot($workspaceStore)
}
})
let summaryInput: EditableInput | undefined = $state(undefined)
// Drop the user on a fresh new-session page. Used after archiving or
// deleting the open session: the session they were on is no longer
// usable, and routing to a sibling would feel arbitrary.
async function resetToNewSession() {
const fresh = createSession()
selectSession(fresh.id)
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
}
// If the session targets a forked workspace that's still accessible,
// offer to delete / archive the fork alongside the session — otherwise
// the fork lingers as an orphan whose only purpose was this session.
const sessionForkId = $derived.by(() => {
const wsId = session?.workspace_id
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
const ws = $userWorkspaces.find((w) => w.id === wsId)
// Don't offer the option if the fork is gone or not user-accessible.
if (!ws || !ws.parent_workspace_id) return undefined
return wsId
})
let deleteConfirmOpen = $state(false)
let deleteAlsoFork = $state(false)
let archiveConfirmOpen = $state(false)
let archiveAlsoFork = $state(false)
async function refreshWorkspaceList() {
// Match the SidebarContent.deleteFork pattern: replace the in-memory
// list rather than nulling it. See B1 fix.
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
console.error('Failed to refresh workspaces', e)
}
}
async function handleConfirmedDelete() {
deleteConfirmOpen = false
if (!session) return
const forkToDelete = deleteAlsoFork ? sessionForkId : undefined
// Capture the fork's parent before the workspace list is refreshed
// below — afterwards the fork is gone from $userWorkspaces.
const forkParentId = forkToDelete
? $userWorkspaces.find((w) => w.id === forkToDelete)?.parent_workspace_id
: undefined
deleteAlsoFork = false
removeSession(session.id)
if (forkToDelete) {
try {
await WorkspaceService.deleteWorkspace({ workspace: forkToDelete })
sendUserToast(`Deleted forked workspace ${forkToDelete}`)
await refreshWorkspaceList()
} catch (e: any) {
sendUserToast(`Failed to delete fork ${forkToDelete}: ${e?.body ?? e}`, true)
}
}
// If the deleted fork was the active workspace, fall back to its parent
// so the new session (created below) opens against a live workspace
// instead of the one we just removed.
if (forkToDelete && forkParentId && $workspaceStore === forkToDelete) {
syncWorkspaceTo(forkParentId)
}
await resetToNewSession()
}
async function handleConfirmedArchive() {
archiveConfirmOpen = false
if (!session) return
const forkToArchive = archiveAlsoFork ? sessionForkId : undefined
archiveAlsoFork = false
setSessionArchived(session.id, true)
if (forkToArchive) {
try {
await WorkspaceService.archiveWorkspace({ workspace: forkToArchive })
sendUserToast(`Archived forked workspace ${forkToArchive}`)
await refreshWorkspaceList()
} catch (e: any) {
sendUserToast(`Failed to archive fork ${forkToArchive}: ${e?.body ?? e}`, true)
}
}
await resetToNewSession()
}
// Kept for the "Archive" entry that doesn't go through the confirmation
// modal — when the session isn't in a fork, no extra question to ask.
async function archiveAndReset() {
if (!session) return
// If the session is in a fork, route through the confirm modal so the
// user can opt into archiving the fork. Otherwise skip the modal.
if (sessionForkId) {
archiveAlsoFork = false
archiveConfirmOpen = true
return
}
setSessionArchived(session.id, true)
await resetToNewSession()
}
// Workspace bar is shown only before the session sends its first user
// message — after that the session's workspace is immutable. The
// commit itself happens in `AIChatManager.beforeSend` (wired in
// `createRuntime`) so it fires exactly once at send-time. A reactive
// commit here would retry forever on backend failures (e.g. fork-id
// collision after a previously-successful create whose response was
// dropped) — restoration self-heal lives in `initRuntime` instead.
const hasFirstUserMessage = $derived(
runtime?.manager.displayMessages.some((m) => m.role === 'user') ?? false
)
// Effective workspace for routing editor views — committed if set,
// otherwise the pending pick, otherwise the current active workspace.
const effectiveWorkspaceId = $derived(
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
)
// Core mutation: assign a target via the canonical setter, then re-open
// the editor pane. Shared by every code path that swaps the session's
// editor target (drill picker, fork-bar dropdown, …).
function applyEditorTarget(target: SessionTarget, summary?: string) {
if (!session) return
setSessionTarget(session.id, target, summary)
// Picking a target also re-opens the editor pane (the user just chose
// what to view).
editorVisible = true
}
function pickEditorTarget(item: WorkspaceItem) {
// Legacy drag-and-drop apps aren't hosted in the session preview pane —
// open them in the standalone app editor instead. Only code-based raw
// apps (item.raw_app) are previewable here.
if (item.kind === 'app' && !item.raw_app) {
goto(`/apps/edit/${item.path}?workspace=${effectiveWorkspaceId}`)
return
}
// WorkspaceItem.kind is 'flow'|'script'|'app'; any 'app' reaching here is
// a raw app. The diff-API uses 'raw_app' as its kind so we align
// SessionTarget on the same canonical string.
const kind: SessionTarget['kind'] = item.kind === 'app' ? 'raw_app' : item.kind
applyEditorTarget({ kind, path: item.path }, item.summary)
}
// Editor pane visibility. Toggling this just hides/shows the pane via CSS
// — the editor stays mounted, so re-opening doesn't pay a remount cost
// and xy-flow / Monaco keep their viewport state.
let editorVisible = $state(true)
// Focus the chat input whenever this session is the active one.
// The textarea is disabled until copilotInfo loads (otherwise focus is
// a silent no-op), so we wait for that too. Triggers on initial mount,
// warm-session switch via the picker, and the moment copilot finishes
// loading.
let aiChat: AIChat | undefined = $state(undefined)
$effect(() => {
if (sessionState.currentSessionId !== sessionId) return
if (!aiChat) return
if (!$copilotInfo.enabled) return
const chat = aiChat
setTimeout(() => chat.focusInput(), 0)
})
// True when the session committed to a workspace that's no longer in
// the user's list (deleted / archived / access revoked). The chat is
// disabled and SessionForkBar shows a move/discard banner.
const isUnavailable = $derived(
!!session?.workspace_id && !$userWorkspaces.find((w) => w.id === session!.workspace_id)
)
async function moveAndActivate(targetWorkspaceId: string) {
if (!session) return
moveSessionToWorkspace(session.id, targetWorkspaceId)
// Point the app at the moved-to workspace too. Without this the global
// workspace stays on the old (now-unavailable) one, so scope/editor keep
// resolving against a dead workspace — mirrors what moveSessionToNewFork
// does internally and handleConfirmedDelete does explicitly.
syncWorkspaceTo(targetWorkspaceId)
}
async function createForkAndMove(fork: {
parent_workspace_id: string
id: string
name: string
}) {
if (!session) return
await moveSessionToNewFork(session.id, fork)
}
</script>
{#if !session || !runtime}
<div class="p-8 text-secondary text-sm">Session not found</div>
{:else}
{@const hasTarget =
session.target?.kind === 'flow' ||
session.target?.kind === 'script' ||
session.target?.kind === 'raw_app'}
{@const hasEditor = mountEditor && hasTarget && editorVisible}
{#snippet inputPreface()}
{#if !hasFirstUserMessage}
<SessionWorkspaceBar {session} />
{/if}
<SessionForkBar
{session}
onMove={(workspaceId) => moveAndActivate(workspaceId)}
onCreateForkAndMove={(fork) => createForkAndMove(fork)}
onArchive={() => archiveAndReset()}
onDelete={() => (deleteConfirmOpen = true)}
/>
{/snippet}
<!-- Override the chat's default keyboard-shortcut hint with nothing —
sessions have their own empty-state affordances above. -->
{#snippet sessionEmptyHint()}{/snippet}
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane size={hasEditor ? 50 : 100} 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) => {
session.summary = v
persistSessions()
}}
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
? {
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
: {
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
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}
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.'
: ''}
emptyHint={sessionEmptyHint}
{inputPreface}
/>
</div>
</Pane>
{#if hasEditor && session.target}
<Pane size={50} 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}
/>
{/if}
</div>
</Pane>
{/if}
</Splitpanes>
<ConfirmationModal
open={deleteConfirmOpen}
title="Delete session"
confirmationText="Delete"
onConfirmed={handleConfirmedDelete}
onCanceled={() => {
deleteConfirmOpen = false
deleteAlsoFork = false
}}
>
<div class="flex flex-col gap-3">
<p>
Delete session <span class="font-medium text-primary"
>{session?.summary ?? session?.name}</span
>? This cannot be undone.
</p>
{#if sessionForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={deleteAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also delete forked workspace <span class="font-mono">{sessionForkId}</span></span
>
<span class="text-3xs text-tertiary"
>The fork won't be reachable from any other session — leaving it would orphan it.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
<ConfirmationModal
open={archiveConfirmOpen}
title="Archive session"
confirmationText="Archive"
onConfirmed={handleConfirmedArchive}
onCanceled={() => {
archiveConfirmOpen = false
archiveAlsoFork = false
}}
>
<div class="flex flex-col gap-3">
<p>
Archive session <span class="font-medium text-primary"
>{session?.summary ?? session?.name}</span
>? You can restore it later from the archived list.
</p>
{#if sessionForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={archiveAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also archive forked workspace <span class="font-mono">{sessionForkId}</span></span
>
<span class="text-3xs text-tertiary"
>Archived workspaces can be unarchived later from instance settings.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
{/if}
<style>
:global(.splitter-hidden .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
opacity: 0 !important;
}
</style>
@@ -0,0 +1,312 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte'
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { isCloudHosted } from '$lib/cloud'
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 { Building, Check, GitFork, Plus } from 'lucide-svelte'
type PendingFork = { id: string; name: string }
type ForkRequest = { parent_workspace_id: string; id: string; name: string }
let {
// Workspace currently associated with the consumer (for display only —
// drives the family-root resolution and the "selected" highlight).
// Defaults to the active workspace store when not set.
selectedId,
// A staged-but-not-yet-created fork (e.g. SessionWorkspaceBar's
// pre-send draft). Highlighted as the active row when set.
pendingFork,
onPick,
onCreateFork,
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 = '',
trigger
}: {
selectedId?: string
pendingFork?: PendingFork
onPick: (workspaceId: string) => void | Promise<void>
onCreateFork?: (fork: ForkRequest) => void | Promise<void>
allowCreateFork?: boolean
createForkCaption?: 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 forks = $derived(root ? findWorkspaceDescendants(root.id, $userWorkspaces) : [])
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
const forksGateOpen = $derived(
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
)
const showCreateFork = $derived(allowCreateFork && forksGateOpen && !!onCreateFork && !!root)
let dropdownOpen = $state(false)
let creatingFork = $state(false)
let newForkName = $state('')
let forkInput: TextInput | undefined = $state(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: 'root'; id: string } | { kind: 'fork'; id: string }
const navRows = $derived<NavRow[]>([
...(showCreateFork ? [{ kind: 'create' as const }] : []),
...(root ? [{ kind: 'root' as const, id: root.id }] : []),
...forks.map((f) => ({ kind: 'fork' as const, id: f.id }))
])
let keyArrowPos = $state<number | undefined>(undefined)
$effect(() => {
if (!dropdownOpen) keyArrowPos = undefined
})
function activateRow(row: NavRow) {
if (row.kind === 'create') {
void enterCreateMode()
} else if (row.kind === 'root' || row.kind === 'fork') {
void pick(row.id)
}
}
function defaultForkName(): 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 base = `${random_adj()}-fork`
let n = 1
while (taken.has(`${WM_FORK_PREFIX}${base}-${n}`)) n++
return `${base}-${n}`
}
async function pick(id: string) {
dropdownOpen = false
creatingFork = false
await onPick(id)
}
async function enterCreateMode(initialName?: string) {
creatingFork = true
newForkName = initialName ?? defaultForkName()
await tick()
forkInput?.focus()
forkInput?.select()
}
function cancelCreate() {
creatingFork = false
newForkName = ''
}
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()
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}`
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'
return undefined
})
async function stageNewFork() {
const name = newForkName.trim()
if (!root || !name || forkNameError || !onCreateFork) return
const baseId = slugForkBaseId(name)
if (!baseId) return
const prefixed = `${WM_FORK_PREFIX}${baseId}`
// Close optimistically; consumer can re-open + toast on error.
creatingFork = false
newForkName = ''
dropdownOpen = false
await onCreateFork({ parent_workspace_id: root.id, id: prefixed, name })
}
function isSelected(id: string): boolean {
if (pendingFork?.id === id) return true
return !pendingFork && effectiveId === id
}
// Reopening the dropdown while a pending fork is staged drops the user
// directly into edit mode so they can refine the name. 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) {
void enterCreateMode(pendingFork.name)
}
})
</script>
<svelte:window
onkeydown={(e) => {
if (!dropdownOpen) return
if (creatingFork) return
if (navRows.length === 0) return
if (e.key === 'ArrowDown') {
keyArrowPos = keyArrowPos === undefined ? 0 : Math.min(navRows.length - 1, keyArrowPos + 1)
e.preventDefault()
} else if (e.key === 'ArrowUp') {
keyArrowPos = keyArrowPos === undefined ? navRows.length - 1 : Math.max(0, keyArrowPos - 1)
e.preventDefault()
} else if (e.key === 'Enter' && keyArrowPos !== undefined) {
activateRow(navRows[keyArrowPos])
e.preventDefault()
} else if (e.key === 'Escape') {
dropdownOpen = false
e.preventDefault()
}
}}
/>
<DropdownV2
bind:open={dropdownOpen}
customMenu
placement="bottom-start"
fixedHeight={false}
usePointerDownOutside
>
{#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'}
<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"
>
{#if showCreateFork}
{#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()
}
}
}}
/>
<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}
<span class="text-2xs text-tertiary">{createForkCaption}</span>
{/if}
</div>
{/if}
</div>
{:else}
{@const createIdx = 0}
<button
type="button"
class={`${rowBase} ${keyArrowPos === createIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
onmouseenter={() => (keyArrowPos = createIdx)}
onclick={() => enterCreateMode()}
>
<Plus size={14} class="shrink-0 text-tertiary" />
<span>Create new fork…</span>
</button>
{/if}
<div class="my-1 border-t border-border-light shrink-0"></div>
{/if}
{#if root}
{@const rootIdx = showCreateFork ? 1 : 0}
<button
type="button"
class={`${rowBase} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${keyArrowPos === rootIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
onmouseenter={() => (keyArrowPos = rootIdx)}
onclick={() => 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">root</span>
</button>
{/if}
{#each forks as f, fi (f.id)}
{@const forkIdx = (showCreateFork ? 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>
</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>
{/if}
</div>
{/snippet}
</DropdownV2>
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import {
runtimeRawAppToDraft,
applyDraftToRuntimeRawApp,
type RuntimeRawApp,
type RawAppDraft
} from './appDraftCodec'
function runtime(over: Partial<RuntimeRawApp> = {}): RuntimeRawApp {
return {
summary: 'app',
path: 'f/foo/app',
files: { '/index.tsx': 'x' },
runnables: {},
data: { tables: [] } as any,
policy: { execution_mode: 'publisher' },
...over
}
}
describe('appDraftCodec — custom_path round-trip', () => {
it('carries custom_path from runtime → draft', () => {
const draft = runtimeRawAppToDraft(runtime({ custom_path: 'my-url' }))
expect(draft.custom_path).toBe('my-url')
})
it('carries custom_path from draft → runtime', () => {
const base = runtime({ custom_path: undefined })
const dv: RawAppDraft = {
summary: 'app',
files: {},
runnables: {},
data: { tables: [] } as any,
policy: { execution_mode: 'publisher' },
custom_path: 'kept-url'
}
expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('kept-url')
})
it('survives a full runtime → draft → runtime round-trip', () => {
const original = runtime({ custom_path: 'round-trip-url' })
const back = applyDraftToRuntimeRawApp(
runtime({ custom_path: undefined }),
runtimeRawAppToDraft(original)
)
expect(back.custom_path).toBe('round-trip-url')
// runtime-only `path` is preserved from the target, not the draft
expect(back.path).toBe('f/foo/app')
})
it('falls back to the runtime custom_path when the draft omits it', () => {
const base = runtime({ custom_path: 'existing' })
const dv: RawAppDraft = {
summary: 'app',
files: {},
runnables: {},
data: { tables: [] } as any
}
expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('existing')
})
})
@@ -0,0 +1,55 @@
import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils'
// The raw-app draft shape stored under `UserDraft<RawAppDraft>` — matches the
// regular `/apps_raw/edit` route's UserDraft handle exactly. The chat's
// `userDraftAdapter.saveGlobalAppDraft` writes through the same shape, so
// session previews and the chat round-trip identically.
export type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
// makes `policy` required for the editor's live binding.
export type RuntimeRawApp = {
summary: string
path: string
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
custom_path?: string
}
// Strip runtime-only metadata (just `path`, the storage key) when persisting
// to UserDraft. `custom_path` is a real draft field and must round-trip — else
// session sync erases a draft's custom URL.
export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
return {
summary: raw.summary,
files: raw.files,
runnables: raw.runnables,
data: raw.data,
policy: raw.policy,
custom_path: raw.custom_path
}
}
// Overlay a UserDraft-stored raw-app draft onto an existing runtime raw app,
// preserving the runtime-only `path` field.
export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): RuntimeRawApp {
return {
...raw,
summary: dv.summary,
files: dv.files,
runnables: dv.runnables,
data: dv.data,
policy: dv.policy ?? raw.policy,
custom_path: dv.custom_path ?? raw.custom_path
}
}
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { flowDraftSig } from './flowDraftSig'
describe('flowDraftSig', () => {
it('changes when only the summary changes (the regression this guards)', () => {
const base = { value: { modules: [] }, schema: {}, summary: 'a' }
const renamed = { ...base, summary: 'b' }
expect(flowDraftSig(base)).not.toBe(flowDraftSig(renamed))
})
it('is stable for equal value/schema/summary', () => {
const a = { value: { modules: [1] }, schema: { x: 1 }, summary: 's' }
const b = { value: { modules: [1] }, schema: { x: 1 }, summary: 's' }
expect(flowDraftSig(a)).toBe(flowDraftSig(b))
})
it('changes when the value changes', () => {
expect(flowDraftSig({ value: { modules: [] }, summary: 's' })).not.toBe(
flowDraftSig({ value: { modules: [1] }, summary: 's' })
)
})
})
@@ -0,0 +1,9 @@
// Dedup signature for the session flow preview's two-way sync (FlowEditorView).
//
// The inbound (draft → editor) and outbound (editor → draft) effects compare
// this signature to skip no-op work. It MUST include `summary` — otherwise a
// summary-only change produces an identical signature and never propagates or
// persists.
export function flowDraftSig(x: { value?: unknown; schema?: unknown; summary?: unknown }): string {
return JSON.stringify({ value: x.value, schema: x.schema, summary: x.summary })
}
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
// Documents the cache-invalidation contract that ForkDiffDrawer.fetchComparison
// relies on. The bug (cubic P2): per-item raw diffs (`loadedDiffs[key]`) and
// per-item `summaries` are component-local $state that persist for the
// drawer's lifetime. fetchComparison re-fetches the comparison on every
// open(), but loadDiffFor short-circuits on `loadedDiffs[key]` — so an
// edit-then-reopen would show fresh counts but the prior open's cached
// expanded raw content. The fix clears both records at the top of
// fetchComparison before the await. This test pins the contract from the
// outside: nothing is allowed to read a stale per-item value across a
// fetchComparison invocation.
type LoadedDiff = { state: 'ready'; parentRaw: string; forkRaw: string }
function simulateFetchComparison(
state: { loadedDiffs: Record<string, LoadedDiff>; summaries: Record<string, string> },
items: Array<{ key: string; parentRaw: string; forkRaw: string; summary: string }>
) {
// The fix: clear caches *before* re-populating, matching the production
// code's `loadedDiffs = {}; summaries = {}` at the top of fetchComparison.
state.loadedDiffs = {}
state.summaries = {}
for (const it of items) {
// loadDiffFor's cache-hit shortcut: if state.loadedDiffs[key] exists,
// it returns early. With the reset above this is always false here, so
// every item gets fresh content.
if (state.loadedDiffs[it.key]) continue
state.loadedDiffs[it.key] = { state: 'ready', parentRaw: it.parentRaw, forkRaw: it.forkRaw }
state.summaries[it.key] = it.summary
}
}
describe('ForkDiffDrawer.fetchComparison — cache invalidation', () => {
it('reopen after edit replaces stale per-item raw content', () => {
const state = {
loadedDiffs: {} as Record<string, LoadedDiff>,
summaries: {} as Record<string, string>
}
// First open: populate.
simulateFetchComparison(state, [
{ key: 'script/f/foo', parentRaw: 'v1', forkRaw: 'v1-fork', summary: 'summary v1' }
])
expect(state.loadedDiffs['script/f/foo']).toEqual({
state: 'ready',
parentRaw: 'v1',
forkRaw: 'v1-fork'
})
expect(state.summaries['script/f/foo']).toBe('summary v1')
// User edits the script in the session editor, closes drawer, reopens.
// Without the reset, the cache-hit shortcut would keep 'v1-fork'.
simulateFetchComparison(state, [
{ key: 'script/f/foo', parentRaw: 'v1', forkRaw: 'v2-fork', summary: 'summary v2' }
])
expect(state.loadedDiffs['script/f/foo']).toEqual({
state: 'ready',
parentRaw: 'v1',
forkRaw: 'v2-fork'
})
expect(state.summaries['script/f/foo']).toBe('summary v2')
})
it('reopen drops entries for items that vanished from the new comparison', () => {
const state = {
loadedDiffs: {} as Record<string, LoadedDiff>,
summaries: {} as Record<string, string>
}
simulateFetchComparison(state, [
{ key: 'script/f/a', parentRaw: 'x', forkRaw: 'x-fork', summary: 'a' },
{ key: 'flow/f/b', parentRaw: 'y', forkRaw: 'y-fork', summary: 'b' }
])
expect(Object.keys(state.loadedDiffs).sort()).toEqual(['flow/f/b', 'script/f/a'])
// User merges 'flow/f/b' so it's no longer ahead of parent.
simulateFetchComparison(state, [
{ key: 'script/f/a', parentRaw: 'x', forkRaw: 'x-fork', summary: 'a' }
])
// Without the reset, the orphan entry would linger in loadedDiffs and
// the tree could render a row whose raw content references a path no
// longer in the live comparison.
expect(Object.keys(state.loadedDiffs)).toEqual(['script/f/a'])
expect(state.summaries['flow/f/b']).toBeUndefined()
})
})
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { parentFolderKey } from './forkDiffNav'
describe('parentFolderKey', () => {
describe('folders', () => {
it('a scope folder (2 segments) has no parent', () => {
expect(parentFolderKey('folder', 'f/foo')).toBeUndefined()
expect(parentFolderKey('folder', 'u/alice')).toBeUndefined()
})
it('a nested folder belongs to its parent folder', () => {
expect(parentFolderKey('folder', 'f/foo/sub')).toBe('folder:f/foo')
expect(parentFolderKey('folder', 'f/foo/sub/deep')).toBe('folder:f/foo/sub')
})
})
describe('files', () => {
it('a single-segment leaf has no scope folder', () => {
expect(parentFolderKey('file', 'orphan')).toBeUndefined()
})
it('a 2-segment file maps to its scope folder (the cubic fix)', () => {
// Regression: previously returned `folder:f` (nonexistent) → ArrowLeft broke.
expect(parentFolderKey('file', 'f/foo')).toBe('folder:f/foo')
})
it('a 3-segment file (directly under scope) maps to the scope folder', () => {
expect(parentFolderKey('file', 'f/foo/bar')).toBe('folder:f/foo')
expect(parentFolderKey('file', 'u/alice/script')).toBe('folder:u/alice')
})
it('a deeper file belongs to its immediate folder', () => {
expect(parentFolderKey('file', 'f/foo/sub/bar')).toBe('folder:f/foo/sub')
})
})
})
@@ -0,0 +1,25 @@
// Keyboard-navigation helpers for the fork-diff tree (ForkDiffDrawer).
//
// The tree groups diffs by "scope" (the first two path segments, e.g. `f/foo`
// or `u/alice`); deeper segments become nested folders. Folder keys are
// `folder:<fullPath>`. See ForkDiffDrawer's tree builder.
/**
* The folder key that contains an entry (for ArrowLeft "go to parent"), or
* `undefined` when the entry sits at the top with no parent folder.
*
* - A scope folder (2 segments) has no parent.
* - A single-segment file leaf sits at the tree root with no scope folder.
* - A file directly at its scope (`f/foo` or `f/foo/bar`) belongs to the scope
* folder = first two segments (`folder:f/foo`).
* - Anything deeper belongs to its immediate folder.
*/
export function parentFolderKey(kind: 'folder' | 'file', path: string): string | undefined {
const parts = path.split('/')
if (kind === 'folder' && parts.length <= 2) return undefined
if (kind === 'file') {
if (parts.length < 2) return undefined
if (parts.length <= 3) return `folder:${parts.slice(0, 2).join('/')}`
}
return `folder:${parts.slice(0, -1).join('/')}`
}
@@ -0,0 +1,14 @@
import type { WorkspaceItemDiff } from '$lib/gen'
// Editor URL for a workspace-item diff, scoped to a given workspace. Returns
// undefined for kinds we don't have a dedicated editor for (resource,
// variable, schedule, triggers, …).
export function editUrlFor(d: WorkspaceItemDiff, workspaceId: string): string | undefined {
const ws = encodeURIComponent(workspaceId)
const path = d.path
if (d.kind === 'flow') return `/flows/edit/${path}?workspace=${ws}`
if (d.kind === 'script') return `/scripts/edit/${path}?workspace=${ws}`
if (d.kind === 'app') return `/apps/edit/${path}?workspace=${ws}`
if (d.kind === 'raw_app') return `/apps_raw/edit/${path}?workspace=${ws}`
return undefined
}
@@ -0,0 +1,700 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { get } from 'svelte/store'
import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import {
AppService,
FlowService,
ScriptService,
WorkspaceService,
type Flow,
type NewScript,
type NewScriptWithDraft,
type WorkspaceComparison
} from '$lib/gen'
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 { emptySchema, type StateStore } from '$lib/utils'
import {
commitSessionWorkspace,
deleteSession as deleteSessionState,
ensureChatIdsSeeded,
materializeTransient,
sessionState,
setSessionChatId,
setSessionTarget,
type Session,
type SessionTarget
} from './sessionState.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } from './appDraftCodec'
import {
setDeployedInSessionHandler,
setGetPreviewStatusHandler,
setOpenPreviewHandler
} from '$lib/components/copilot/chat/global/core'
export interface SessionRuntime {
readonly sessionId: string
readonly manager: AIChatManager
// Flow target state
readonly flowStore: StateStore<Flow>
readonly flowStateStore: { val: Record<string, any> }
readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined }
readonly loadingFlow: boolean
readonly notFound: boolean
readonly loadedPath: string | undefined
loadFlow(workspace: string, path: string, force?: boolean): Promise<void>
// Script target state (parallel to flow, populated only for script-targeted sessions)
readonly scriptStore: { val: NewScript | undefined }
readonly savedScript: { val: NewScriptWithDraft | undefined }
readonly loadingScript: boolean
readonly notFoundScript: boolean
readonly loadedScriptPath: string | undefined
loadScript(workspace: string, path: string, force?: boolean): Promise<void>
// Note: legacy drag-and-drop apps are intentionally NOT hosted in the
// session preview pane (only code-based raw apps are), so there's no
// app target state here.
// Raw App (HTML-based) target state
readonly rawApp: {
val:
| {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
summary: string
path: string
custom_path?: string
}
| undefined
}
readonly savedRawApp: {
val:
| {
value: {
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
}
readonly loadingRawApp: boolean
readonly notFoundRawApp: boolean
readonly loadedRawAppPath: string | undefined
loadRawApp(workspace: string, path: string, force?: boolean): Promise<void>
// Discard the local draft + refresh the fork diff + force-reload the editor,
// so the preview matches the deployed version. Used by editor onDeploy + the
// chat deploy handler.
syncPreviewWithDeployed(
workspace: string,
kind: 'script' | 'flow' | 'raw_app',
path: string
): void
// Fork comparison cache: shared between SessionForkBar (count + dropdown)
// and any future consumer that needs the parent ↔ fork diff list. Keyed
// implicitly by the (parent, fork) pair last passed to ensureForkComparison;
// invalidateForkComparison() forces a refresh after a known-mutating action.
readonly forkComparison: { val: WorkspaceComparison | undefined }
readonly loadingForkComparison: boolean
ensureForkComparison(parent: string, fork: string): Promise<void>
invalidateForkComparison(): void
// Force-refresh against the last (parent, fork) pair the runtime
// fetched for. No-op if no comparison has ever been loaded. Useful
// for session-activation hooks that need a fresh count regardless of
// the dedupe key match.
refreshForkComparison(): Promise<void>
// Re-fetch the comparison shortly after a local mutation (e.g. an
// editor "Save draft"). The backend tally that registers the change
// lands asynchronously, so an immediate refresh races it — this
// schedules a couple of delayed refreshes so the fork-bar count
// updates without waiting for an AI turn or a tab refocus.
scheduleForkComparisonRefresh(): void
// Cancel pending fork-comparison refresh timers. Called by disposeRuntime so
// a torn-down (e.g. LRU-evicted, deleted) runtime can't fire a stray
// refreshForkComparisonNow / compareWorkspaces after it's gone.
dispose(): void
}
const runtimes = new SvelteMap<string, SessionRuntime>()
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
}
function createRuntime(session: Session): SessionRuntime {
const manager = new AIChatManager()
manager.disabledModes = { navigator: true }
// Sessions always operate in GLOBAL mode (workspace-item tools across
// the session's workspace). The page-level gate already requires the
// global-AI flag, so this is always available here. Mode is locked
// and the dropdown is hidden in the chat UI.
manager.mode = AIMode.GLOBAL
// Session chats drive a side-panel preview, so they get the session-only
// preview tools (open_preview / get_preview_status); the global side-panel
// chat does not.
manager.isSessionChat = true
// 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
// 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
// session targeting the right workspace. Both calls are idempotent.
manager.beforeSend = async () => {
materializeTransient(session.id)
const committed = await commitSessionWorkspace(session.id, get(workspaceStore) ?? undefined)
// commitSessionWorkspace returns undefined only when the session did NOT
// commit to a workspace — most importantly when a staged fork failed to
// materialise (materializeFork is built to toast + return undefined rather
// than throw). Throwing here is what makes AIChatManager.sendRequest abort:
// otherwise the send proceeds against get(workspaceStore) (the parent for a
// staged-new-fork draft), shipping the message + its tool calls to the
// wrong workspace while the pending_fork is silently dropped.
if (!committed) {
throw new Error(
'the session workspace could not be created or committed (fork creation may have failed)'
)
}
}
const flowStore: StateStore<Flow> = $state({ val: emptyFlow() })
const flowStateStore: { val: Record<string, any> } = $state({ val: {} })
const savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } = $state({
val: undefined
})
let loadingFlow = $state(false)
let notFound = $state(false)
let loadedPath = $state<string | undefined>(undefined)
const scriptStore: { val: NewScript | undefined } = $state({ val: undefined })
const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined })
let loadingScript = $state(false)
let notFoundScript = $state(false)
let loadedScriptPath = $state<string | undefined>(undefined)
const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined })
const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined })
let loadingRawApp = $state(false)
let notFoundRawApp = $state(false)
let loadedRawAppPath = $state<string | undefined>(undefined)
const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined })
let loadingForkComparison = $state(false)
let forkComparisonKey: string | undefined = undefined
let forkRefreshTimers: ReturnType<typeof setTimeout>[] = []
// Stale-while-revalidate re-fetch against the last (parent, fork) pair.
// Shared by refreshForkComparison() and scheduleForkComparisonRefresh().
async function refreshForkComparisonNow() {
const key = forkComparisonKey
if (!key) return
const sep = key.indexOf('|')
if (sep < 0) return
const parent = key.slice(0, sep)
const fork = key.slice(sep + 1)
if (loadingForkComparison) return
loadingForkComparison = true
try {
forkComparison.val = await WorkspaceService.compareWorkspaces({
workspace: parent,
targetWorkspaceId: fork
})
} catch (e) {
console.error('SessionRuntime: forkComparison refresh failed', e)
} finally {
loadingForkComparison = false
}
}
return {
sessionId: session.id,
manager,
flowStore,
flowStateStore,
savedFlow,
get loadingFlow() {
return loadingFlow
},
get notFound() {
return notFound
},
get loadedPath() {
return loadedPath
},
async loadFlow(workspace: string, path: string, force = false) {
if (loadedPath === path && !force) return
// See loadScript: forced reload remounts via the render gate.
if (force) loadedPath = undefined
loadingFlow = true
notFound = false
try {
// Draft first. UserDraft is the shared authoritative content
// source — the chat (write_flow / patch_flow_json /
// set_flow_module_code) and the editor's outbound $effect both
// write through it. If a draft exists we render from it, even
// when the path has never been deployed.
const aiDraft = UserDraft.get<Flow>('flow', path, { workspace })
// getFlowByPathWithDraft omits version_id (the diff's deployed side,
// via getFlowByPath, has it) — stamp it onto the editing flow so it
// doesn't always diff. Best-effort (a never-deployed flow has none).
let deployedVersionId: number | undefined
try {
deployedVersionId = (await FlowService.getFlowByPath({ workspace, path }))?.version_id
} catch {
deployedVersionId = undefined
}
if (aiDraft) {
// Best-effort fetch the backend baseline for the diff
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only flows are a valid state.
try {
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
} catch {
savedFlow.val = undefined
}
await initFlow(aiDraft, flowStore, flowStateStore)
if (deployedVersionId != null && flowStore.val)
flowStore.val.version_id = deployedVersionId
loadedPath = path
return
}
// No draft yet. Seed one from the last deploy (or the
// backend-side draft, if one exists).
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
const flow: Flow = (result.draft as Flow | undefined) ?? (result as Flow)
UserDraft.save('flow', path, flow, { workspace })
await initFlow(flow, flowStore, flowStateStore)
if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId
loadedPath = path
} catch (err) {
console.error('Failed to load flow', err)
notFound = true
} finally {
loadingFlow = false
}
},
scriptStore,
savedScript,
get loadingScript() {
return loadingScript
},
get notFoundScript() {
return notFoundScript
},
get loadedScriptPath() {
return loadedScriptPath
},
async loadScript(workspace: string, path: string, force = false) {
if (loadedScriptPath === path && !force) return
// Forced reload: clearing loadedScriptPath drops us into the
// `{#if loading && !loadedScriptPath}` gate, which unmounts then remounts
// the editor — avoids the Monaco init race a synchronous {#key} would hit.
if (force) loadedScriptPath = undefined
loadingScript = true
notFoundScript = false
try {
// Draft first. UserDraft is the shared authoritative content
// source — the chat (write_script / edit_script) and the
// editor's outbound $effect both write through it. If a draft
// exists we render from it, even when the path has never been
// deployed.
const aiDraft = UserDraft.get<NewScript>('script', path, { workspace })
if (aiDraft && typeof aiDraft.content === 'string') {
// Best-effort fetch the backend baseline for the diff
// drawer + parent_hash. 404 means draft-only — leave
// savedScript undefined and skip parent_hash.
try {
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
savedScript.val = result
} catch {
savedScript.val = undefined
}
// Clone the deployed/backend baseline before layering the AI
// draft on top — otherwise we'd mutate `savedScript.val` in
// place (it's the same object) and lose the pristine baseline
// that the diff drawer + the deploy/discard affordance compare
// against.
const baseline: NewScript = savedScript.val
? (structuredClone(
$state.snapshot(
(savedScript.val.draft as NewScript | undefined) ?? (savedScript.val as NewScript)
)
) as NewScript)
: {
path,
summary: aiDraft.summary ?? '',
content: '',
description: '',
schema: emptySchema(),
language: (aiDraft.language ?? 'bun') as any
}
if (savedScript.val?.hash) {
baseline.parent_hash = savedScript.val.hash
}
baseline.content = aiDraft.content
if (aiDraft.language) baseline.language = aiDraft.language
if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary
scriptStore.val = baseline
loadedScriptPath = path
return
}
// No draft yet. Seed from backend.
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
savedScript.val = result
// Clone before mutating: when result.draft is falsy, `baseline` would
// otherwise alias `result` (= savedScript.val), so baseline.parent_hash
// would corrupt the pristine deployed baseline the diff drawer reads.
const baseline = structuredClone(
(result.draft as NewScript | undefined) ?? (result as NewScript)
)
baseline.parent_hash = result.hash
UserDraft.save<NewScript>('script', path, baseline, { workspace })
scriptStore.val = baseline
loadedScriptPath = path
} catch (err) {
console.error('Failed to load script', err)
notFoundScript = true
} finally {
loadingScript = false
}
},
rawApp,
savedRawApp,
get loadingRawApp() {
return loadingRawApp
},
get notFoundRawApp() {
return notFoundRawApp
},
get loadedRawAppPath() {
return loadedRawAppPath
},
async loadRawApp(workspace: string, path: string, force = false) {
if (loadedRawAppPath === path && !force) return
// See loadScript: forced reload remounts via the render gate.
if (force) loadedRawAppPath = undefined
loadingRawApp = true
notFoundRawApp = false
try {
// Draft first. UserDraft is the shared authoritative content
// source — the chat (init_app / write_app_file / ...) and the
// editor's outbound $effect both write through it. If a draft
// exists we render from it, even when the path has never been
// deployed.
const aiDraft = UserDraft.get<RawAppDraft>('raw_app', path, { workspace })
if (aiDraft) {
// Best-effort fetch the backend baseline for the diff
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only apps are a valid state.
try {
const result = await AppService.getAppByPathWithDraft({ workspace, path })
savedRawApp.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft: result.draft,
custom_path: result.custom_path
}
} catch {
savedRawApp.val = undefined
}
rawApp.val = applyDraftToRuntimeRawApp(
{
files: {},
runnables: {},
data: { ...DEFAULT_DATA },
policy: undefined,
summary: aiDraft.summary ?? '',
path
},
aiDraft
)
loadedRawAppPath = path
return
}
// No draft yet. Seed one from the last deploy (or the
// backend-side draft, if one exists — that's the user's
// "Save draft" content from the standalone editor and is
// fresher than `value`).
const result = await AppService.getAppByPathWithDraft({ workspace, path })
savedRawApp.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft: result.draft,
custom_path: result.custom_path
}
const sourceValue: any = result.draft ?? result.value
let data: RawAppData = { ...DEFAULT_DATA }
if (sourceValue?.data) {
const d = sourceValue.data
if (d.creation) {
data = {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (sourceValue?.datatables) {
data = { ...DEFAULT_DATA, tables: sourceValue.datatables }
}
const runtimeValue = {
files: (sourceValue?.files ?? {}) as Record<string, string>,
runnables: (sourceValue?.runnables ?? {}) as Record<string, any>,
data,
policy: result.policy,
summary: result.summary ?? '',
path: result.path,
custom_path: result.custom_path
}
UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace })
rawApp.val = runtimeValue
loadedRawAppPath = path
} catch (err) {
console.error('Failed to load raw app', err)
notFoundRawApp = true
} finally {
loadingRawApp = false
}
},
syncPreviewWithDeployed(workspace, kind, path) {
this.scheduleForkComparisonRefresh()
UserDraft.discard(kind, path, undefined, { workspace })
if (kind === 'script') void this.loadScript(workspace, path, true)
else if (kind === 'flow') void this.loadFlow(workspace, path, true)
else void this.loadRawApp(workspace, path, true)
},
forkComparison,
get loadingForkComparison() {
return loadingForkComparison
},
async ensureForkComparison(parent: string, fork: string) {
const key = `${parent}|${fork}`
if (forkComparisonKey === key && forkComparison.val) return
if (loadingForkComparison && forkComparisonKey === key) return
forkComparisonKey = key
loadingForkComparison = true
try {
forkComparison.val = await WorkspaceService.compareWorkspaces({
workspace: parent,
targetWorkspaceId: fork
})
} catch (e) {
console.error('SessionRuntime: forkComparison fetch failed', e)
forkComparison.val = undefined
// On error, clear the key so the next call retries.
if (forkComparisonKey === key) forkComparisonKey = undefined
} finally {
loadingForkComparison = false
}
},
invalidateForkComparison() {
forkComparisonKey = undefined
forkComparison.val = undefined
},
// Stale-while-revalidate: re-fetch in place so the cached status
// (driving the sidebar dot, fork bar, etc.) stays put until the new
// result lands. Clearing forkComparison.val here flickered the icon
// back to the neutral GitFork on every session-activate refresh.
refreshForkComparison() {
return refreshForkComparisonNow()
},
scheduleForkComparisonRefresh() {
// The backend fork tally registers a draft save (createScript
// draft_only / DraftService.createDraft) asynchronously — ~300ms
// after the API call returns — so an immediate refresh races it.
// Fetch once after the tally typically lands, then again as a
// backstop for slower backends. Coalesce rapid saves by clearing
// any still-pending timers first.
for (const t of forkRefreshTimers) clearTimeout(t)
forkRefreshTimers = [
setTimeout(() => void refreshForkComparisonNow(), 700),
setTimeout(() => void refreshForkComparisonNow(), 2200)
]
},
dispose() {
for (const t of forkRefreshTimers) clearTimeout(t)
forkRefreshTimers = []
}
}
}
async function initRuntime(runtime: SessionRuntime, session: Session) {
const { manager } = runtime
await manager.historyManager.init()
manager.historyManager.setSessionId(session.id)
await ensureChatIdsSeeded(manager.historyManager)
if (session.chatId) {
manager.historyManager.setCurrentChatId(session.chatId)
await manager.historyManager.tagChatWithSession(session.chatId, session.id)
await manager.loadPastChat(session.chatId)
} else {
setSessionChatId(session.id, manager.historyManager.getCurrentChatId())
}
}
export function getOrCreateRuntime(session: Session): SessionRuntime {
let runtime = runtimes.get(session.id)
if (!runtime) {
runtime = createRuntime(session)
runtimes.set(session.id, runtime)
initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e))
}
return runtime
}
export function disposeRuntime(sessionId: string) {
const runtime = runtimes.get(sessionId)
if (!runtime) return
runtime.dispose()
runtime.manager.cancel('runtime disposed')
runtime.manager.historyManager.close()
runtimes.delete(sessionId)
}
export function listRuntimes(): SessionRuntime[] {
return Array.from(runtimes.values())
}
export function getRuntime(sessionId: string): SessionRuntime | undefined {
return runtimes.get(sessionId)
}
export type SessionChatStatus =
| 'idle'
| 'streaming'
| 'awaiting-user'
| 'needs-confirmation'
| 'draft'
| 'error'
// MRU set of session ids whose FlowEditorView is currently mounted. Capped at
// MAX_WARM_EDITORS — sessions outside the set show chat-only. Module-scoped so
// both the page (which mutates) and the sidebar (which reads for the dev clue)
// see the same state.
const MAX_WARM_EDITORS = 3
export const editorWarmIds = new SvelteSet<string>()
// Full session teardown: dispose the runtime, drop the LRU entry, and remove
// from sessionState in one call. Callers (sidebar / header dropdowns) just
// invoke this; navigation away from a deleted active session is the caller's
// responsibility.
export function removeSession(sessionId: string): void {
disposeRuntime(sessionId)
editorWarmIds.delete(sessionId)
deleteSessionState(sessionId)
}
export function promoteEditorWarm(sessionId: string): void {
editorWarmIds.delete(sessionId)
editorWarmIds.add(sessionId)
while (editorWarmIds.size > MAX_WARM_EDITORS) {
const oldest = editorWarmIds.values().next().value
if (oldest === undefined) break
editorWarmIds.delete(oldest)
}
}
// Register the global open_preview tool handler once at module load. It
// dispatches to the *calling* session (sessionId threaded from the tool ctx),
// falling back to the UI-active session only when none was passed — so a
// backgrounded session's tool call opens its OWN preview, not the one the user
// happens to be viewing. Outside a session there is no calling/active id and
// the tool returns a polite error.
setOpenPreviewHandler(({ sessionId: callerSessionId, kind, path }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
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 target: SessionTarget = { kind, path }
setSessionTarget(sessionId, target)
promoteEditorWarm(sessionId)
return `Opened ${kind} preview for ${path} 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.
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}".`
})
// After a chat deploy, reload the calling session's preview — only if it's open
// showing that exact item.
setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
if (!sessionId) return
const session = sessionState.sessions.find((s) => s.id === sessionId)
const runtime = runtimes.get(sessionId)
if (!session?.workspace_id || !runtime) return
const open =
(kind === 'script' && runtime.loadedScriptPath === path) ||
(kind === 'flow' && runtime.loadedPath === path) ||
(kind === 'raw_app' && runtime.loadedRawAppPath === path)
if (!open) return
runtime.syncPreviewWithDeployed(session.workspace_id, kind, path)
})
export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus {
const m = runtime.manager
if (m.loading) return 'streaming'
if (m.instructions.trim().length > 0) return 'draft'
const last = m.displayMessages[m.displayMessages.length - 1]
if (last?.role === 'tool' && last.needsConfirmation) return 'needs-confirmation'
if (last?.role === 'user' && last.error) return 'error'
if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user'
return 'idle'
}
@@ -0,0 +1,32 @@
import { derived, type Readable } from 'svelte/store'
import { workspaceStore, userWorkspaces, type UserWorkspace } from '$lib/stores'
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
// Walk up parent_workspace_id chain to find the root of the fork family
// containing `id`. Falls back to the workspace itself if it has no parent
// (or its parent isn't in the user's list).
function findFamilyRoot(id: string, all: UserWorkspace[]): UserWorkspace | 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
}
// Set of workspace ids a session must belong to for the user to see it in
// the sidebar list. The whole fork family is visible from any node: when
// the user is inside fork A whose root is R, sessions belonging to R or
// any sibling fork of A are listed too. Recomputes when the user switches
// workspace or when the workspace list refreshes.
export const visibleWorkspaceIds: Readable<Set<string>> = derived(
[workspaceStore, userWorkspaces],
([ws, all]) => {
if (!ws) return new Set<string>()
const root = findFamilyRoot(ws, all) ?? ({ id: ws } as UserWorkspace)
const ids = new Set<string>([root.id])
for (const d of findWorkspaceDescendants(root.id, all)) ids.add(d.id)
return ids
}
)
@@ -0,0 +1,501 @@
import { BROWSER } from 'esm-env'
import { get } from 'svelte/store'
import { createLongHash } from '$lib/editorLangUtils'
import { random_adj } from '$lib/components/random_positive_adjetive'
import {
userWorkspaces,
usersWorkspaceStore,
workspaceStore,
type UserWorkspace
} from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
// Switch the global workspace iff the target differs from the active one
// and is non-empty. Centralises the "session needs its workspace in focus"
// rule so picker, deep-link, and workspace-bar paths agree on the same
// semantic. No-op for `undefined` / empty.
export function syncWorkspaceTo(workspaceId: string | undefined): void {
if (!workspaceId) return
if (workspaceId === get(workspaceStore)) return
switchWorkspace(workspaceId)
}
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte'
// Kinds the in-session editor pane can host. Legacy drag-and-drop apps are
// intentionally not previewable — only code-based 'raw_app' apps are.
export type SessionTarget = { kind: 'flow' | 'script' | 'raw_app'; path: string }
// Useful for filtering dropdowns / pickers to "items the side panel can open".
export const EDITOR_TARGET_KINDS: ReadonlySet<SessionTarget['kind']> = new Set([
'flow',
'script',
'raw_app'
])
// Lifecycle status for a fork session. Git-parallel:
// in_sync — fork is up to date with parent (or only behind — treated
// the same since the user has no unmerged work either way).
// ahead — fork has unmerged changes vs parent (branch ahead).
// diverged — fork has unmerged changes AND parent has moved (branch
// diverged from upstream — potential conflicts).
// unavailable — fork workspace is no longer in the user's list (deleted,
// archived, or access revoked). Read-only fallback.
//
// `undefined` is the loading / not-applicable state (root session,
// comparison not yet fetched).
export type ForkStatus = 'in_sync' | 'ahead' | 'diverged' | 'unavailable'
// Whether the session points at a workspace that is itself a fork (i.e.
// has a parent). Independent of comparison-fetch state — used by the
// sidebar to pick between a root (Building) icon and a fork-status icon
// before the comparison has loaded.
//
// Sessions whose committed workspace is no longer in the user's list are
// still treated as forks (the "unavailable" terminal state) so we don't
// flip them back to Building once access is lost.
export function isForkSession(session: Session, allWorkspaces: UserWorkspace[]): boolean {
const wsId = session.workspace_id ?? session.pending_workspace_id
if (!wsId) return false
const ws = allWorkspaces.find((w) => w.id === wsId)
if (!ws) return !!session.workspace_id
return !!ws.parent_workspace_id
}
export function deriveForkStatus(
session: Session,
allWorkspaces: UserWorkspace[],
comparison: WorkspaceComparison | undefined
): ForkStatus | undefined {
const wsId = session.workspace_id ?? session.pending_workspace_id
if (!wsId) return undefined
const ws = allWorkspaces.find((w) => w.id === wsId)
// Committed fork workspaces that disappear from the user's list
// (deleted, archived, or access lost) are flagged unavailable so
// the UI can render a terminal state without trying to switch into
// them. Drafts whose pending workspace also vanished get the same
// treatment.
if (!ws) return session.workspace_id ? 'unavailable' : undefined
if (!ws.parent_workspace_id) return undefined
if (!comparison) return undefined
const ahead = comparison.summary?.total_ahead ?? 0
const behind = comparison.summary?.total_behind ?? 0
if (ahead > 0 && behind > 0) return 'diverged'
if (ahead > 0) return 'ahead'
return 'in_sync'
}
export type PendingFork = {
// Existing workspace to fork from (drives routing/scope pre-send).
parent_workspace_id: string
// Slug the new fork will use, e.g. `wm-fork-foo`.
id: string
// Display name shown in the workspace bar.
name: string
}
export type Session = {
id: string
name: string
// Committed strictly at first user-message send. Undefined for drafts
// that have never been sent — those scope by `pending_workspace_id`
// instead and don't show the fork bar.
workspace_id?: string
// Pre-send draft workspace, picked via SessionWorkspaceBar. Drives
// scope/editor/display while workspace_id is undefined; gets copied
// into workspace_id at first send and then becomes irrelevant.
pending_workspace_id?: string
// Pre-send intent to create a new fork. The actual API call is
// deferred to first send (via commitSessionWorkspace) so cancelling
// the draft doesn't leave an orphan fork behind.
pending_fork?: PendingFork
chatId?: string
target?: SessionTarget
summary?: string
createdAt: number
// User-archived sessions are hidden from the sidebar by default
// (toggleable via the picker filter). Archive is reversible — distinct
// from delete, which removes the session entirely.
archived?: boolean
// In-memory-only flag: the session exists but isn't written to
// localStorage until the user sends their first message. Avoids
// piling abandoned drafts across `+` clicks — createSession reuses
// the existing transient if one is already open.
transient?: boolean
}
const STORAGE_KEY = 'windmill_sessions'
// New users (empty/cleared/private-browsing localStorage) start with no
// sessions — the sidebar + /sessions page render their empty states and the
// user creates the first session with `+`. Do NOT seed placeholder sessions
// here: hardcoded example paths won't resolve for other users and render as
// "session not found".
const defaultSessions: Session[] = []
function loadSessions(): Session[] {
if (!BROWSER) return defaultSessions
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
// Drop empty-string workspace_id (older sessions used '' as a
// missing-value marker) so the undefined-until-first-send invariant
// holds for legacy drafts. Also migrate the deprecated
// 'rawapp' target.kind to the canonical 'raw_app'.
let mutated = false
for (const s of parsed) {
if (s.workspace_id === '') {
delete s.workspace_id
mutated = true
}
if (s.target?.kind === 'rawapp') {
s.target.kind = 'raw_app'
mutated = true
}
}
if (mutated) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed))
} catch (e) {
console.error('Failed to persist normalised sessions', e)
}
}
return parsed as Session[]
}
}
} catch (e) {
console.error('Failed to load sessions from localStorage', e)
}
return defaultSessions
}
export const sessionState = $state<{
sessions: Session[]
currentSessionId: string | undefined
}>({
sessions: loadSessions(),
currentSessionId: undefined
})
export function persistSessions() {
if (!BROWSER) return
try {
// Transient (unsent) sessions stay in memory only. They get
// materialised — and from then on written to storage — when the
// user sends their first message.
const toPersist = $state.snapshot(sessionState.sessions).filter((s) => !s.transient)
localStorage.setItem(STORAGE_KEY, JSON.stringify(toPersist))
} catch (e) {
console.error('Failed to persist sessions', e)
}
}
export function findSessionByName(name: string): Session | undefined {
return sessionState.sessions.find((s) => s.name === name)
}
// Walk up parent_workspace_id to the family root, given a starting
// workspace id. Returns the input id if no parent chain is visible.
function familyRootId(id: string | undefined, all: UserWorkspace[]): string | undefined {
if (!id) return undefined
let cur = all.find((w) => w.id === id)
while (cur?.parent_workspace_id) {
const parent = all.find((w) => w.id === cur!.parent_workspace_id)
if (!parent) break
cur = parent
}
return cur?.id ?? id
}
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.
const existingTransient = sessionState.sessions.find((s) => s.transient)
if (existingTransient) {
sessionState.currentSessionId = existingTransient.id
return existingTransient
}
const existingNumbers = sessionState.sessions
.map((s) => /^session-(\d+)$/.exec(s.name)?.[1])
.map((n) => (n ? parseInt(n, 10) : 0))
const next = (existingNumbers.length ? Math.max(...existingNumbers) : 0) + 1
// Default to the family root rather than wherever the user happens
// to be — sessions usually start from "the canonical workspace" and
// the picker lets them switch to a fork later.
const currentWs = get(workspaceStore)
const root = familyRootId(currentWs ?? undefined, get(userWorkspaces))
const pending = root ?? currentWs
// Friendly default summary so the header reads like "Zippy session"
// rather than "Untitled session" — assigned at create time, the user
// can still rename it (or it gets overwritten by an editor target).
const adj = random_adj()
const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session`
const session: Session = {
id: createLongHash(),
name: `session-${next}`,
summary,
pending_workspace_id: pending && pending.length > 0 ? pending : undefined,
createdAt: Date.now(),
transient: true
}
sessionState.sessions = [session, ...sessionState.sessions]
sessionState.currentSessionId = session.id
// persistSessions() filters out transients — this call is a no-op for
// the new draft, but kept so any other session mutations get flushed.
persistSessions()
return session
}
// Promote an in-memory transient session to a persisted one. No-op when
// the session isn't transient. Called by the chat manager's beforeSend
// hook so the session is only written to localStorage once the user
// commits to it by sending their first message.
export function materializeTransient(id: string): void {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s || !s.transient) return
delete s.transient
persistSessions()
}
export function setSessionPendingWorkspace(id: string, workspace_id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
const changed = s.pending_workspace_id !== workspace_id || s.pending_fork !== undefined
s.pending_workspace_id = workspace_id
// Picking an existing workspace cancels any pending fork intent.
s.pending_fork = undefined
if (changed) persistSessions()
}
// Records the user's intent to create a new fork without firing the API
// call yet. Routing/scope stay on the parent workspace until commit.
export function setSessionPendingFork(id: string, fork: PendingFork) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.pending_fork = { ...fork }
s.pending_workspace_id = fork.parent_workspace_id
persistSessions()
}
// One-shot commit: locks in workspace_id at first user-message send.
// If a pending fork is set, materialises it via the API first, then
// switches the global workspace to the freshly created fork. Falls back
// to the pending pick, then the active workspace. Clears pending so it
// doesn't shadow later reads.
export async function commitSessionWorkspace(
id: string,
fallback?: string
): Promise<string | undefined> {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return undefined
if (s.workspace_id) return s.workspace_id
if (s.pending_fork) {
const fork = s.pending_fork
const newId = await materializeFork(fork)
if (!newId) {
// Real failure (not a recovered duplicate). Drop the pending
// fork so the session falls through to the workspace-pick
// fallback on the next call and the unavailable-banner UX
// can take over instead of looping on the same broken intent.
s.pending_fork = undefined
persistSessions()
return undefined
}
if (get(workspaceStore) !== newId) switchWorkspace(newId)
s.workspace_id = newId
s.pending_fork = undefined
s.pending_workspace_id = undefined
persistSessions()
return newId
}
const ws = s.pending_workspace_id ?? fallback
if (!ws) return undefined
s.workspace_id = ws
s.pending_workspace_id = undefined
// `pending_workspace_id` defaults to the family root 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)
persistSessions()
return ws
}
// Effective workspace for scope/routing — committed if set, otherwise the
// pre-send pending pick (which defaults to the workspace at create time).
// Pending forks route via their parent until creation lands.
export function getEffectiveWorkspaceId(session: Session): string | undefined {
return session.workspace_id ?? session.pending_workspace_id
}
// Canonical mutation for session.target. Persists, optionally seeds the
// session summary, and centralises the path so callers don't reach into
// session.target directly.
export function setSessionTarget(id: string, target: SessionTarget, summary?: string): void {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.target = target
if (!s.summary && summary) s.summary = summary
persistSessions()
}
export function selectSession(id: string) {
sessionState.currentSessionId = id
}
export function renameSession(id: string, newSummary: string) {
const trimmed = newSummary.trim()
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.summary = trimmed.length > 0 ? trimmed : undefined
persistSessions()
}
// Create a new fork workspace via the API, refresh the user-workspaces
// store, and return the new fork id. Used by both the first-send commit
// path (commitSessionWorkspace) and the move-session-to-a-new-fork path
// in the unavailable-session banner. Returns undefined on failure (a
// user-facing toast is already emitted).
//
// Self-heal: if `fork.id` is already present in the user-workspaces
// store, the previous create succeeded (whose response we apparently
// lost). Adopt it silently instead of re-POSTing — the API would
// otherwise reject with workspace_pkey. Likewise, if the API returns a
// duplicate-key error we refresh the store and adopt the existing row.
export async function materializeFork(fork: PendingFork): Promise<string | undefined> {
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
try {
await WorkspaceService.createWorkspaceFork({
workspace: fork.parent_workspace_id,
requestBody: { id: fork.id, name: fork.name }
})
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
sendUserToast(`Created fork ${fork.name}`)
return fork.id
} catch (e: any) {
const msg = String(e?.body ?? e?.message ?? e)
if (/workspace_pkey|duplicate key/i.test(msg)) {
// Self-heal: the create likely already succeeded. Refresh + adopt the
// existing row. Guard this refresh — a second network failure here must
// NOT rethrow out of materializeFork (callers rely on the
// toast-and-return-undefined contract; an uncaught throw would bypass
// it and propagate up through commitSessionWorkspace/beforeSend).
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
} catch (refreshErr) {
console.error('Failed to refresh workspaces during fork self-heal', refreshErr)
}
}
sendUserToast(`Could not create fork: ${msg}`, true)
return undefined
}
}
// Re-assign a committed session to a different workspace. Used to rescue
// sessions whose original workspace was deleted / archived / had access
// revoked — the chat history (stored in IndexedDB keyed by session id) is
// preserved; only the workspace pointer changes. Drops pending fields
// since the session is already past the draft stage by definition.
export function moveSessionToWorkspace(id: string, newWorkspaceId: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
if (s.workspace_id === newWorkspaceId) return
s.workspace_id = newWorkspaceId
delete s.pending_workspace_id
delete s.pending_fork
persistSessions()
}
// Create a brand-new fork and re-assign a committed session to it. Used
// by the unavailable-session banner's "Create new fork" path in the
// move dropdown. On success the global workspace is switched to the
// freshly created fork.
export async function moveSessionToNewFork(
id: string,
fork: PendingFork
): Promise<string | undefined> {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return undefined
const newId = await materializeFork(fork)
if (!newId) return undefined
if (get(workspaceStore) !== newId) switchWorkspace(newId)
moveSessionToWorkspace(id, newId)
return newId
}
export function setSessionArchived(id: string, archived: boolean) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
const next = archived ? true : undefined
if (s.archived === next) return
if (archived) s.archived = true
else delete s.archived
persistSessions()
}
export function deleteSession(id: string) {
const idx = sessionState.sessions.findIndex((s) => s.id === id)
if (idx < 0) return
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== id)
if (sessionState.currentSessionId === id) {
sessionState.currentSessionId = sessionState.sessions[0]?.id
}
persistSessions()
}
export function setSessionChatId(sessionId: string, chatId: string) {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (s && s.chatId !== chatId) {
s.chatId = chatId
persistSessions()
}
}
let seedPromise: Promise<void> | undefined
// One-shot pairing of the user's two most-recently-modified saved chats with
// the two seeded sessions. Idempotent across all callers / SessionWrappers.
export function ensureChatIdsSeeded(historyManager: HistoryManager): Promise<void> {
if (!seedPromise) {
seedPromise = (async () => {
try {
await historyManager.init()
// Read directly from storage so we see chats regardless of this manager's
// own session-scope filter (getPastChats would hide already-tagged ones).
const pastChats = historyManager.getAllSavedChats()
const untagged = pastChats
.filter((c) => !c.sessionId)
.sort((a, b) => b.lastModified - a.lastModified)
// Only seed pre-existing (persisted) sessions. Transient
// sessions are freshly created via the "+" button and must
// start with an empty chat — if the seed catches one (e.g. the
// user clicks "New session" before this one-shot runs), it
// would graft a previous discussion onto the new session.
const seedable = sessionState.sessions.filter((s) => !s.transient)
let mutated = false
for (let i = 0; i < Math.min(seedable.length, untagged.length); i++) {
if (!seedable[i].chatId) {
const chatId = untagged[i].id
const sessionId = seedable[i].id
seedable[i].chatId = chatId
await historyManager.tagChatWithSession(chatId, sessionId)
mutated = true
}
}
if (mutated) persistSessions()
} catch (e) {
console.error('Failed to seed chat ids from history', e)
}
})()
}
return seedPromise
}
@@ -0,0 +1,196 @@
import { describe, it, expect, vi } from 'vitest'
import { get } from 'svelte/store'
import {
commitSessionWorkspace,
deriveForkStatus,
isForkSession,
sessionState,
type Session
} from './sessionState.svelte'
import { workspaceStore, type UserWorkspace } from '$lib/stores'
import type { WorkspaceComparison } from '$lib/gen'
// Force createWorkspaceFork to fail so we can pin commitSessionWorkspace's
// failure contract (the invariant the beforeSend abort fix relies on).
vi.mock('$lib/gen', async (orig) => {
const actual = await orig<typeof import('$lib/gen')>()
return {
...actual,
WorkspaceService: {
...actual.WorkspaceService,
createWorkspaceFork: vi.fn().mockRejectedValue(new Error('fork creation failed')),
listUserWorkspaces: vi.fn().mockResolvedValue([])
}
}
})
// Minimal fixtures — only the fields these pure helpers read matter; the rest
// is filled via cast so we don't track unrelated schema churn.
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
}
function comparison(total_ahead: number, total_behind: number): WorkspaceComparison {
return { summary: { total_ahead, total_behind } } as unknown as WorkspaceComparison
}
describe('isForkSession', () => {
it('is false for a draft with no workspace at all', () => {
expect(isForkSession(session(), [])).toBe(false)
})
it('is false when the workspace is a (non-fork) root', () => {
expect(isForkSession(session({ workspace_id: 'root' }), [ws('root')])).toBe(false)
})
it('is true when the workspace has a parent (is a fork)', () => {
expect(isForkSession(session({ workspace_id: 'fork' }), [ws('fork', 'root')])).toBe(true)
})
it('treats a committed-but-missing workspace as a fork (terminal unavailable state)', () => {
expect(isForkSession(session({ workspace_id: 'gone' }), [])).toBe(true)
})
it('is false for a draft whose pending workspace is missing', () => {
expect(isForkSession(session({ pending_workspace_id: 'gone' }), [])).toBe(false)
})
it('resolves a draft via its pending workspace', () => {
expect(isForkSession(session({ pending_workspace_id: 'fork' }), [ws('fork', 'root')])).toBe(
true
)
})
})
describe('deriveForkStatus', () => {
it('is undefined for a draft with no workspace', () => {
expect(deriveForkStatus(session(), [], undefined)).toBeUndefined()
})
it('is unavailable when a committed workspace is no longer in the list', () => {
expect(deriveForkStatus(session({ workspace_id: 'gone' }), [], comparison(0, 0))).toBe(
'unavailable'
)
})
it('is undefined when a draft pending workspace is missing (not yet committed)', () => {
expect(
deriveForkStatus(session({ pending_workspace_id: 'gone' }), [], undefined)
).toBeUndefined()
})
it('is undefined for a non-fork (root) workspace', () => {
expect(
deriveForkStatus(session({ workspace_id: 'root' }), [ws('root')], comparison(3, 3))
).toBeUndefined()
})
it('is undefined for a fork before the comparison has loaded', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], undefined)
).toBeUndefined()
})
it('is diverged when the fork is both ahead and behind', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(2, 1))
).toBe('diverged')
})
it('is ahead when the fork has unmerged changes and the parent has not moved', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(2, 0))
).toBe('ahead')
})
it('is in_sync when neither side is ahead', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(0, 0))
).toBe('in_sync')
})
it('is in_sync when only the parent moved (behind-only, fork has no local changes)', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(0, 2))
).toBe('in_sync')
})
})
describe('commitSessionWorkspace — fork-creation failure', () => {
it('returns undefined and drops pending_fork (so beforeSend aborts the send)', async () => {
const id = 'test-commit-fork-fail'
sessionState.sessions.push({
id,
name: 'fork-fail',
createdAt: 0,
pending_fork: { parent_workspace_id: 'parent_ws', id: 'wm-fork-nope', name: 'nope' }
} as Session)
try {
const committed = await commitSessionWorkspace(id, 'parent_ws')
// Not committed → undefined. This is what makes beforeSend throw rather
// than letting the first message ship to the parent workspace.
expect(committed).toBeUndefined()
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.workspace_id).toBeUndefined()
expect(s?.pending_fork).toBeUndefined()
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
}
})
})
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 family root.
// 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.
const id = 'test-commit-ws-sync'
const prev = get(workspaceStore)
workspaceStore.set('wm-fork-x')
sessionState.sessions.push({
id,
name: 'ws-sync',
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_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')
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)
}
})
})
@@ -0,0 +1,60 @@
import type { SessionRuntime } from './sessionRuntime.svelte'
// Per-user, per-session "last seen" marker — count of displayMessages the
// last time the user was actually on that session's page. Compared against
// the runtime's current message count to derive an unread badge.
//
// Stored as a single localStorage entry holding Record<sessionId, count>.
// Module-level $state for cross-session reactivity; can't use
// `useLocalStorageValue` here because its internal $effect requires a
// component-initialization context (we run at import time).
const LS_KEY = 'windmill_sessions_last_seen_counts'
function readInitial(): Record<string, number> {
if (typeof window === 'undefined') return {}
try {
const raw = localStorage.getItem(LS_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
const lastSeen = $state<{ val: Record<string, number> }>({ val: readInitial() })
function persist(): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(LS_KEY, JSON.stringify(lastSeen.val))
} catch (e) {
console.error('sessionUnread: localStorage write failed', e)
}
}
// Mark the session as seen up to `count` messages. No-op when already at
// or past that count (idempotent — call freely from $effects).
export function markSessionSeen(sessionId: string, count: number) {
const current = lastSeen.val[sessionId] ?? 0
if (current >= count) return
lastSeen.val = { ...lastSeen.val, [sessionId]: count }
persist()
}
// Drop the session entry entirely (used on delete so we don't leak
// stale ids into localStorage indefinitely).
export function forgetSessionSeen(sessionId: string) {
if (!(sessionId in lastSeen.val)) return
const next = { ...lastSeen.val }
delete next[sessionId]
lastSeen.val = next
persist()
}
// Number of unread messages for a session. Undefined / unloaded runtime
// returns 0 — until messages are hydrated we don't know what's new.
export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number {
if (!runtime) return 0
const seen = lastSeen.val[sessionId] ?? 0
const total = runtime.manager.displayMessages.length
return Math.max(0, total - seen)
}
@@ -122,7 +122,12 @@
{:else}
<SvelteComponent
size={16}
class={twMerge('flex-shrink-0', sidebarClasses.iconText, 'transition-colors', iconClasses)}
class={twMerge(
'flex-shrink-0',
sidebarClasses.iconText,
'transition-colors',
iconClasses
)}
{...iconProps}
/>
{/if}
@@ -140,9 +145,7 @@
title={label}
>
{label}
<span
class="pl-2 text-xs text-secondary font-semibold"
>
<span class="pl-2 text-xs text-secondary font-semibold">
{shortcut}
</span>
</div>
@@ -162,7 +165,7 @@
</div>
{#if isCollapsed && notificationsCount > 0}
<div class="absolute translate-x-1/2 translate-y-1/2 -top-2 right-1 flex h-fit w-fit">
<div class="absolute top-1 right-1 flex h-fit w-fit">
<SideBarNotification notificationCount={notificationsCount} small={true} />
</div>
{:else if notificationsCount > 0}
@@ -2,15 +2,15 @@
import Notification from '$lib/components/common/alert/Notification.svelte'
interface Props {
notificationCount?: number;
small?: boolean;
notificationCount?: number
small?: boolean
}
let { notificationCount = 0, small = false }: Props = $props();
let { notificationCount = 0, small = false }: Props = $props()
</script>
{#if !small}
<Notification {notificationCount} notificationLimit={9} />
{:else}
<div class="bg-red-500 rounded-md w-3 h-3 flex items-center justify-center"></div>
<div class="bg-red-500 rounded-full w-2 h-2 flex items-center justify-center"></div>
{/if}
@@ -4,6 +4,7 @@
superadmin,
usedTriggerKinds,
userStore,
usersWorkspaceStore,
userWorkspaces,
workspaceStore,
isCriticalAlertsUIOpen,
@@ -41,13 +42,17 @@
Database,
Pyramid,
Trash2,
MailIcon
MailIcon,
ChevronDown,
ChevronRight
} from 'lucide-svelte'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
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 } from '$lib/storeUtils'
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'
@@ -113,6 +118,11 @@
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) {
@@ -138,8 +148,27 @@
await WorkspaceService.deleteWorkspace({ workspace })
sendUserToast('You deleted the workspace')
clearStores()
goto('/user/workspaces')
if (parentStillAccessible && parentId) {
// Refresh the workspace list before landing on the parent.
// `clearStores()` would null `usersWorkspaceStore`, which the
// sidebar's `visibleSessions` filter reads via `$userWorkspaces`
// — with an empty list, every committed session falls into the
// "workspace_id set but not in user's list" branch and renders
// as "Fork — no longer available" until a hard reload.
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
// A transient list-refresh failure must not strand the user on the
// just-deleted workspace — still switch + navigate (the list reloads
// on the next page load).
console.error('Failed to refresh workspaces after delete', e)
}
switchWorkspace(parentId)
await goto('/')
} else {
clearStores()
await goto('/user/workspaces')
}
}
let deleteForkedChildren = $state(false)
@@ -173,6 +202,12 @@
loadAvailableNativeTriggers()
const triggersCollapsed = useLocalStorageValue(
'windmill_triggers_section_collapsed',
false,
'boolean'
)
onMount(async () => {
if (lastOpened) {
// @ts-ignore
@@ -612,188 +647,214 @@
'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between gap-2'
)}
>
<div class={twMerge('pt-4 mb-6 md:mb-10')}>
<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">
<div
class="text-secondary text-[0.5rem] uppercase transition-opacity"
class:opacity-0={isCollapsed}>Triggers</div
>
<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>
</div>
<div class="flex flex-col h-full justify-end">
<Menubar class="flex flex-col">
{#snippet children({ createMenu })}
<div class="flex flex-col gap-1 mb-6 md:mb-10">
<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}
{#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>
</nav>
{/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}
</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}
>
<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>
</div></nav
>
<ConfirmationModal
open={leaveWorkspaceModal}
@@ -1,4 +1,5 @@
<script lang="ts">
import { workspaceMenuHref } from './workspaceMenuHref'
import {
isPremiumStore,
superadmin,
@@ -58,22 +59,30 @@
'/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
if (!isOnEditPage) {
switchWorkspace(id)
if (page.url.searchParams.get('workspace')) {
page.url.searchParams.set('workspace', id)
}
} else {
switchWorkspace(id)
switchWorkspace(id)
if (isOnEditPage || isOnSessionPage) {
await goto('/')
} else if (page.url.searchParams.get('workspace')) {
page.url.searchParams.set('workspace', 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).
function workspaceHref(id: string): string {
const params = new URLSearchParams(page.url.searchParams)
params.set('workspace', id)
return `${page.url.pathname}?${params.toString()}`
return workspaceMenuHref({
routeId: page.route.id,
base,
pathname: page.url.pathname,
searchParams: page.url.searchParams,
id
})
}
function onWorkspaceItemClick(e: MouseEvent, workspace: { id: string; disabled?: boolean }) {
@@ -0,0 +1,52 @@
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)', () => {
expect(
workspaceMenuHref({
routeId: '/(root)/(logged)/sessions',
base: '',
pathname: '/sessions',
searchParams: new URLSearchParams('session_name=foo'),
id: 'wm-fork-bar'
})
).toBe('/?workspace=wm-fork-bar')
})
it('respects the base prefix on a session route', () => {
expect(
workspaceMenuHref({
routeId: '/(root)/(logged)/sessions',
base: '/wm',
pathname: '/wm/sessions',
searchParams: new URLSearchParams(),
id: 'ws2'
})
).toBe('/wm/?workspace=ws2')
})
it('off a session route, 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'
})
).toBe('/scripts/edit/u/me/x?workspace=new_ws&foo=1')
})
it('adds the workspace param when none was present', () => {
expect(
workspaceMenuHref({
routeId: '/(root)/(logged)/runs',
base: '',
pathname: '/runs',
searchParams: new URLSearchParams(),
id: 'w'
})
).toBe('/runs?workspace=w')
})
})
@@ -0,0 +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.
export function workspaceMenuHref(args: {
routeId: string | null | undefined
base: string
pathname: string
searchParams: URLSearchParams
id: string
}): string {
if (args.routeId?.includes('/sessions')) {
return `${args.base}/?workspace=${args.id}`
}
const params = new URLSearchParams(args.searchParams)
params.set('workspace', args.id)
return `${args.pathname}?${params.toString()}`
}
+87
View File
@@ -172,6 +172,93 @@ describe('UserDraft live editor draft registry', () => {
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined()
})
// Pins the slot-collision contract behind the SessionWrapper
// `isActiveSession` gate. Without the gate, two warm-mounted session
// editors on the same (workspace, kind) — e.g. `/sessions` keeping 3
// warm sessions and two of them have script editors open in the same
// workspace — both call setLiveEditorDraft and the hidden one can
// clobber the visible one's claim. The fix in
// {Script,Flow,RawApp}EditorView returns early when `isActiveSession`
// is false, so only the visible session writes to the slot.
it('collides per (workspace, kind) when two callers both set — the hidden session can hijack', () => {
UserDraft.setLiveEditorDraft({
workspace: 'ws_collide',
itemKind: 'script',
storagePath: 'session_a_path',
effectivePath: 'u/me/a'
})
UserDraft.setLiveEditorDraft({
workspace: 'ws_collide',
itemKind: 'script',
storagePath: 'session_b_path',
effectivePath: 'u/me/b'
})
// Last write wins — exactly the bug Codex flagged: a hidden warm
// session B mounted after the active A overwrites A's claim.
expect(UserDraft.getLiveEditorDraft('script', { workspace: 'ws_collide' })).toMatchObject({
storagePath: 'session_b_path',
effectivePath: 'u/me/b'
})
})
it('with the active-session gate, only the visible session claims the slot', () => {
// Simulate the effect bodies in {Script,Flow,RawApp}EditorView: each
// returns early when isActiveSession is false. Session A is active,
// Session B is warm-mounted but hidden.
function registerIfActive(opts: {
isActive: boolean
workspace: string
storagePath: string
effectivePath: string
}) {
if (!opts.isActive) return
UserDraft.setLiveEditorDraft({
workspace: opts.workspace,
itemKind: 'flow',
storagePath: opts.storagePath,
effectivePath: opts.effectivePath
})
}
registerIfActive({
isActive: true,
workspace: 'ws_gate',
storagePath: 'session_a_path',
effectivePath: 'u/me/a'
})
registerIfActive({
isActive: false,
workspace: 'ws_gate',
storagePath: 'session_b_path',
effectivePath: 'u/me/b'
})
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_gate' })).toMatchObject({
storagePath: 'session_a_path',
effectivePath: 'u/me/a'
})
})
it('cleanup on the deactivating session is a no-op once the new active session has claimed the slot', () => {
// Active-session swap: A becomes hidden (cleanup runs), B becomes
// active and registers. Even if Svelte flushes B's effect before
// A's cleanup, A's cleanup is keyed on its own storagePath and is
// guarded so it doesn't clobber B's claim. Verified here by running
// the cleanups in reverse order.
UserDraft.setLiveEditorDraft({
workspace: 'ws_swap',
itemKind: 'raw_app',
storagePath: 'session_b_path',
effectivePath: 'u/me/b'
})
// A's cleanup runs after — should be a no-op.
UserDraft.clearLiveEditorDraft('raw_app', {
workspace: 'ws_swap',
storagePath: 'session_a_path'
})
expect(UserDraft.getLiveEditorDraft('raw_app', { workspace: 'ws_swap' })).toMatchObject({
storagePath: 'session_b_path'
})
})
it('can remove persisted global draft storage without blanking the live editor', () => {
const draft = { path: 'u/me/live_script', content: 'export async function main() {}' }
localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft))
@@ -65,6 +65,7 @@
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 { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
@@ -330,6 +331,9 @@
}
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) {
@@ -677,6 +681,8 @@
/>
</div>
<SessionPicker {isCollapsed} />
<SidebarContent
{isCollapsed}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
@@ -838,6 +844,7 @@
<AiChatLayout
{children}
noPadding={devOnly}
disableAi={inSessionRoute}
{isCollapsed}
isMobile={innerWidth < 768}
onMenuOpen={() => {
@@ -20,10 +20,15 @@
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import {
UserDraft,
checkStaleness,
type UserDraftMeta,
type UserDraftHandle
} from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
let version: undefined | number = $state(undefined)
@@ -48,7 +53,8 @@
})
| undefined = $state(undefined)
const flowDraftPath = page.params.path ?? ''
// Derived so client-side nav (breadcrumb) re-keys the handle to the new path.
let flowDraftPath = $derived(page.params.path ?? '')
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
@@ -61,7 +67,27 @@
window.history.replaceState(window.history.state, '', url.toString())
}
const flowHandle = UserDraft.use<Flow>('flow', flowDraftPath)
// `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav;
// `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref.
const flowHandles = UserDraft.useMany<Flow>(() => [{ itemKind: 'flow', path: flowDraftPath }])
const flowHandle: UserDraftHandle<Flow> = {
get draft() {
return flowHandles[0]?.draft
},
set draft(value) {
const handle = flowHandles[0]
if (handle) handle.draft = value
},
get meta() {
return flowHandles[0]?.meta ?? {}
},
setDraftAndMeta(value, meta) {
flowHandles[0]?.setDraftAndMeta(value, meta)
},
setMeta(meta, opts) {
flowHandles[0]?.setMeta(meta, opts)
}
}
function emptyFlow(): Flow {
return {
@@ -88,6 +114,10 @@
let loading = $state(false)
// Remounts FlowBuilder on nav: false while a reload runs, true once data is
// ready, so it mounts fresh instead of reusing the previous flow's state.
let renderEditor = $state(false)
let selectedId: string = $state('settings-metadata')
let nobackenddraft = false
@@ -138,6 +168,11 @@
const tok = ++loadFlowToken
loading = true
let flow: Flow
// Builder-dependent setup is captured here and applied AFTER the builder
// remounts (see end of loadFlow): during a reload renderEditor is false,
// so flowBuilder is unmounted and direct calls would no-op.
let draftTriggersToApply: Trigger[] | undefined = undefined
let applyPrimarySchedule = false
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
@@ -231,8 +266,8 @@
if (flowWithDraft.draft != undefined && !nobackenddraft) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
applyPrimarySchedule = true
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
if (!flowWithDraft.draft_only && localDraft == undefined) {
const deployed = cleanValueProperties(flowWithDraft)
@@ -276,13 +311,21 @@
])
}
} else {
flowBuilder?.setDraftTriggers(undefined)
draftTriggersToApply = undefined
}
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
loading = false
selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata'
// Remount the builder first, then apply builder-dependent setup once it
// has mounted — otherwise (during a reload) these would no-op on the
// unmounted builder and editor state restoration would be skipped.
renderEditor = true
await tick()
if (tok !== loadFlowToken) return
if (applyPrimarySchedule) flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(draftTriggersToApply)
flowBuilder?.loadFlowState()
}
@@ -291,7 +334,18 @@
// to another (e.g. via the workspace picker) reloads the new flow.
page.params.path
if ($workspaceStore) {
untrack(() => loadFlow())
untrack(() => {
nobackenddraft = false // fresh nav reconsiders the backend draft
renderEditor = false // remount the builder for the navigated-to flow
loadFlow().catch((e: any) => {
// A failed load must NOT leave renderEditor stuck false — otherwise
// the editor pane disappears and never remounts. Surface the error
// and remount so the user isn't stranded on a blank pane.
console.error('Failed to load flow', e)
sendUserToast(`Failed to load flow: ${e?.body ?? e?.message ?? e}`, true)
renderEditor = true
})
})
}
})
@@ -347,7 +401,7 @@
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
<p class="text-gray-500">The flow you are looking for does not exist.</p>
</div>
{:else}
{:else if renderEditor}
<FlowBuilder
onDeploy={(e) => {
UserDraft.remove('flow', flowDraftPath)
@@ -2,10 +2,16 @@
import CompareWorkspaces from '$lib/components/CompareWorkspaces.svelte'
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { page } from '$app/state'
import { userWorkspaces } from '$lib/stores'
import { userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { Archive, Trash2 } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { switchWorkspace } from '$lib/storeUtils'
import { goto } from '$lib/navigation'
let comparison: WorkspaceComparison | undefined = $state(undefined)
@@ -21,25 +27,15 @@
return
}
// loading = true
// error = undefined
try {
// Compare with parent workspace
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: currentWorkspaceId
})
comparison = result
// isVisible = result.summary.total_diffs > 0
} catch (e) {
console.error('Failed to compare workspaces:', e)
// error = 'Failed to check for changes'
// Still show banner if there's an error, but with error message
// isVisible = true
} finally {
// loading = false
}
}
@@ -48,19 +44,93 @@
untrack(() => checkForChanges())
})
// Fork lifecycle actions — placed in the page header so they're available
// regardless of merge state. Both go through a confirmation modal because
// archive is reversible-ish but delete is irreversible, and either way the
// user is about to navigate away from this page.
let archiveConfirmOpen = $state(false)
let deleteConfirmOpen = $state(false)
let acting = $state(false)
async function afterForkGone() {
// Mirror SidebarContent.deleteFork (B1): refresh the workspace list
// rather than letting `clearStores()` null it, then land the user on
// the parent if still accessible.
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
console.error('Failed to refresh workspaces', e)
}
if (parentWorkspaceId && $userWorkspaces.find((w) => w.id === parentWorkspaceId)) {
switchWorkspace(parentWorkspaceId)
await goto('/')
} else {
await goto('/user/workspaces')
}
}
async function confirmArchive() {
archiveConfirmOpen = false
if (!currentWorkspaceId) return
acting = true
try {
await WorkspaceService.archiveWorkspace({ workspace: currentWorkspaceId })
sendUserToast(`Archived fork ${currentWorkspaceId}`)
await afterForkGone()
} catch (e: any) {
sendUserToast(`Failed to archive fork: ${e?.body ?? e}`, true)
} finally {
acting = false
}
}
async function confirmDelete() {
deleteConfirmOpen = false
if (!currentWorkspaceId) return
acting = true
try {
await WorkspaceService.deleteWorkspace({ workspace: currentWorkspaceId })
sendUserToast(`Deleted fork ${currentWorkspaceId}`)
await afterForkGone()
} catch (e: any) {
sendUserToast(`Failed to delete fork: ${e?.body ?? e}`, true)
} finally {
acting = false
}
}
const isFork = $derived(!!parentWorkspaceId && currentWorkspaceId?.startsWith('wm-fork-'))
</script>
<CenteredPage>
<PageHeader title="Merge workspaces" />
<PageHeader title="Merge workspaces">
{#if isFork}
<div class="flex flex-row gap-2 items-center">
<Button
variant="default"
color="light"
size="xs"
startIcon={{ icon: Archive }}
disabled={acting}
on:click={() => (archiveConfirmOpen = true)}
>
Archive fork
</Button>
<Button
variant="default"
color="red"
size="xs"
startIcon={{ icon: Trash2 }}
disabled={acting}
on:click={() => (deleteConfirmOpen = true)}
>
Delete fork
</Button>
</div>
{/if}
</PageHeader>
{#if currentWorkspaceId && parentWorkspaceId}
<!-- <WorkspaceComparisonDrawer -->
<!-- {comparison} -->
<!-- sourceWorkspace={currentWorkspaceId} -->
<!-- targetWorkspace={parentWorkspaceId} -->
<!-- on:deployed={() => { -->
<!-- sendUserToast('Changes deployed successfully') -->
<!-- }} -->
<!-- /> -->
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison} />
{/if}
{#if !currentWorkspaceId}
@@ -69,3 +139,33 @@
workspace {currentWorkspaceId} has no parent workspace
{/if}
</CenteredPage>
<ConfirmationModal
open={archiveConfirmOpen}
title="Archive fork"
confirmationText="Archive"
onConfirmed={confirmArchive}
onCanceled={() => (archiveConfirmOpen = false)}
>
<p>
Archive forked workspace <span class="font-mono font-medium text-primary"
>{currentWorkspaceId}</span
>? It will be hidden from the workspace picker; a superadmin can restore it from instance
settings later.
</p>
</ConfirmationModal>
<ConfirmationModal
open={deleteConfirmOpen}
title="Delete fork"
confirmationText="Delete"
onConfirmed={confirmDelete}
onCanceled={() => (deleteConfirmOpen = false)}
>
<p>
Permanently delete forked workspace <span class="font-mono font-medium text-primary"
>{currentWorkspaceId}</span
>? This cannot be undone. Any sessions still bound to this fork will show as "Fork — no longer
available" in the sidebar.
</p>
</ConfirmationModal>
@@ -4,7 +4,7 @@
import { page } from '$app/state'
import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import type { Schema } from '$lib/common'
import {
cleanValueProperties,
@@ -277,11 +277,14 @@
? 'wac_typescript'
: 'script')}
onDeploy={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
// "Deploy & Stay here" / lib: stay on the editor (just confirm).
if (e.stay) {
sendUserToast('Deployed')
return
}
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/edit/${e.path}`)
}}
onNavigate={(item) => goto(editPathFor(item))}
@@ -20,7 +20,12 @@
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import {
UserDraft,
checkStaleness,
type UserDraftMeta,
type UserDraftHandle
} from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
@@ -30,14 +35,38 @@
let initialArgs = get(initialArgsStore) ?? {}
if (get(initialArgsStore)) $initialArgsStore = undefined
let topHash = page.url.searchParams.get('topHash') ?? undefined
// Derived so client-side nav (breadcrumb) re-reads the URL, not mount-time values.
let topHash = $derived(page.url.searchParams.get('topHash') ?? undefined)
let hash = page.url.searchParams.get('hash') ?? undefined
let hash = $derived(page.url.searchParams.get('hash') ?? undefined)
// When viewing a specific historical hash we don't want to load or write a
// local draft — that view is read-only relative to drafts.
const draftPath = hash ? '' : (page.params.path ?? '')
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
let draftPath = $derived(hash ? '' : (page.params.path ?? ''))
// `useMany` keyed off the reactive `draftPath` re-keys the handle on nav;
// `scriptHandle` proxies the current handle so `bind:script` stays a fixed lvalue.
const scriptHandles = UserDraft.useMany<EditableScript>(() => [
{ itemKind: 'script', path: draftPath }
])
const scriptHandle: UserDraftHandle<EditableScript> = {
get draft() {
return scriptHandles[0]?.draft
},
set draft(value) {
const handle = scriptHandles[0]
if (handle) handle.draft = value
},
get meta() {
return scriptHandles[0]?.meta ?? {}
},
setDraftAndMeta(value, meta) {
scriptHandles[0]?.setDraftAndMeta(value, meta)
},
setMeta(meta, opts) {
scriptHandles[0]?.setMeta(meta, opts)
}
}
$effect(() => {
if (hash || !$workspaceStore) return
@@ -85,6 +114,11 @@
let savedScript: NewScriptWithDraft | undefined = $state(undefined)
let fullyLoaded = $state(false)
// Remounts ScriptBuilder on nav: false while a reload runs, true once data is
// ready. A synchronous `{#key}` swap instead races Monaco's init against the
// torn-down container (mirrors how the raw-app editor clears `files`).
let renderEditor = $state(false)
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
// Local-draft staleness modal: opened when the remote (deployed or DB
@@ -336,6 +370,7 @@
scriptBuilder?.setCode(scriptHandle.draft.content)
}
fullyLoaded = true
renderEditor = true
}
$effect(() => {
@@ -343,7 +378,17 @@
// to another (e.g. via the workspace picker) reloads the new script.
page.params.path
if ($workspaceStore) {
untrack(() => loadScript())
untrack(() => {
renderEditor = false // remount the builder for the navigated-to script
loadScript().catch((e: any) => {
// A failed load must NOT leave renderEditor stuck false — otherwise
// the editor pane disappears and never remounts. Surface the error
// and remount so the user isn't stranded on a blank pane.
console.error('Failed to load script', e)
sendUserToast(`Failed to load script: ${e?.body ?? e?.message ?? e}`, true)
renderEditor = true
})
})
}
})
@@ -424,7 +469,7 @@
onLoadLatest={onUrlConflictUseUrl}
onKeepDraft={onUrlConflictKeepLocal}
/>
{#if scriptHandle.draft}
{#if scriptHandle.draft && renderEditor}
<ScriptBuilder
bind:this={scriptBuilder}
{initialPath}
@@ -436,12 +481,16 @@
{savedPrimarySchedule}
searchParams={page.url.searchParams}
onDeploy={(e) => {
// "Deploy & Stay here" / lib: stay on the editor (just confirm).
if (e.stay) {
sendUserToast('Deployed')
return
}
UserDraft.remove('script', draftPath)
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/edit/${e.path}`)
}}
onSeeDetails={(e) => {
@@ -0,0 +1,176 @@
<script lang="ts">
import { untrack } from 'svelte'
import { page } from '$app/state'
import { Plus } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { goto } from '$lib/navigation'
import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte'
import {
createSession,
getEffectiveWorkspaceId,
selectSession,
sessionState,
syncWorkspaceTo
} from '$lib/components/sessions/sessionState.svelte'
import {
getOrCreateRuntime,
getRuntime,
listRuntimes,
promoteEditorWarm
} from '$lib/components/sessions/sessionRuntime.svelte'
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
import { visibleWorkspaceIds } from '$lib/components/sessions/sessionScope.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces } from '$lib/stores'
const globalEnabled = isGlobalAiEnabled()
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.
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))
})
// Resolve the active session if its effective workspace is in scope
// (active workspace + its forks). Unavailable sessions — committed to
// a workspace that no longer exists — also resolve so the user can
// land on the move/discard banner instead of hitting "Session not
// found".
const activeSession = $derived(
sessionState.sessions.find((s) => {
if (s.name !== sessionName) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
// 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().
// Also refresh the fork diff count: deep-link / back-button navigation
// changes the URL but doesn't fire the picker.activate path nor the
// visibility-change signal, so this is the only hook that catches a
// user returning from another route in the same tab.
//
// Gate on session identity (id) rather than the full activeSession
// derived — sessionState.sessions mutates on every persisted change
// (including token-by-token last_message updates during AI streaming),
// so a value-trigger would re-fetch compareWorkspaces dozens of times
// per turn. We only want to refresh when the user actually arrives at
// a new session.
let lastArrivedSessionId: string | undefined
$effect(() => {
const session = activeSession
if (!session) {
lastArrivedSessionId = undefined
return
}
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.
selectSession(session.id)
const rt = getOrCreateRuntime(session)
void rt.refreshForkComparison()
})
})
// Warm = has a live runtime (module-scoped) AND its workspace is in
// scope (or its workspace is unavailable — those sessions still need
// to render the move/discard banner instead of vanishing on us).
const warmSessions = $derived(
listRuntimes()
.map((r) => sessionState.sessions.find((s) => s.id === r.sessionId))
.filter((s): s is NonNullable<typeof s> => s != null)
.filter((s) => {
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
// Promote the active session in the LRU. Mutations untracked so the effect
// only re-runs when activeSession changes, not on its own writes.
$effect(() => {
const id = activeSession?.id
if (!id) return
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.
$effect(() => {
const id = activeSession?.id
if (!id) return
const rt = getRuntime(id)
if (!rt) return
const count = rt.manager.displayMessages.length
untrack(() => markSessionSeen(id, count))
})
async function startNewSession() {
const fresh = createSession()
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
}
</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}
>
<SessionWrapper sessionId={s.id} />
</div>
{/each}
</div>
{/if}
@@ -30,10 +30,12 @@
enterpriseLicense,
superadmin,
userStore,
userWorkspaces,
usersWorkspaceStore,
workspaceStore,
isCriticalAlertsUIOpen
} from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { sendUserToast } from '$lib/toast'
import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
@@ -1542,11 +1544,32 @@
unifiedSize="md"
btnClasses="mt-2"
on:click={async () => {
await WorkspaceService.archiveWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Archived workspace ${$workspaceStore}`)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
goto('/user/workspaces')
const ws = $workspaceStore ?? ''
// Land on the parent workspace if this is a fork and the
// parent is still accessible — otherwise fall back to the
// workspace picker.
const parentId = $userWorkspaces.find((w) => w.id === ws)?.parent_workspace_id
const parentStillAccessible = !!(
parentId && $userWorkspaces.find((w) => w.id === parentId)
)
await WorkspaceService.archiveWorkspace({ workspace: ws })
sendUserToast(`Archived workspace ${ws}`)
if (parentStillAccessible && parentId) {
// Refresh the list so the just-archived workspace drops out before
// we land on the parent. Guarded: a refresh failure must not block
// the switch (the list reloads on next page load).
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
console.error('Failed to refresh workspaces after archive', e)
}
switchWorkspace(parentId)
await goto('/')
} else {
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
await goto('/user/workspaces')
}
}}
>
Archive workspace