mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
eadeac248b
* 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>
163 lines
5.7 KiB
Svelte
163 lines
5.7 KiB
Svelte
<!--
|
|
@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}
|