mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
* 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>
1146 lines
40 KiB
TypeScript
1146 lines
40 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
|
|
// Capture onDestroy callbacks so we can simulate component teardown without
|
|
// a real component context.
|
|
const onDestroyCallbacks: Array<() => void> = []
|
|
|
|
vi.mock('svelte', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>
|
|
return {
|
|
...actual,
|
|
onDestroy: (fn: () => void) => {
|
|
onDestroyCallbacks.push(fn)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Imported AFTER vi.mock so the module sees the mocked onDestroy.
|
|
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
|
|
await import('./userDraft.svelte')
|
|
const { workspaceStore } = await import('./stores')
|
|
const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter')
|
|
|
|
function flushDestroyCallbacks(): void {
|
|
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
|
|
for (const cb of callbacks) cb()
|
|
}
|
|
|
|
// UserDraft.use debounces localStorage writes by 500 ms via
|
|
// useLocalStorageValue. Tests assert localStorage state synchronously after
|
|
// writes, so we use fake timers and call this helper to fast-forward past
|
|
// the debounce window before each assertion.
|
|
function flushPersist(): void {
|
|
vi.runAllTimers()
|
|
}
|
|
|
|
// Helper: localStorage payloads are always wrapped as { value: <draft> } so
|
|
// future metadata fields can be added without breaking existing entries.
|
|
function wrapped<V>(value: V): string {
|
|
return JSON.stringify({ value })
|
|
}
|
|
|
|
// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so
|
|
// assertions can stay focused on value + rev metadata. Real entries always
|
|
// carry `lastWrittenAt` once written; the GC tests below assert on it
|
|
// directly via `localStorage.getItem`.
|
|
function storedShape(key: string): string | null {
|
|
const raw = localStorage.getItem(key)
|
|
if (raw == null) return null
|
|
const parsed = JSON.parse(raw)
|
|
delete parsed.lastWrittenAt
|
|
return JSON.stringify(parsed)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
__resetUserDraftForTesting()
|
|
onDestroyCallbacks.length = 0
|
|
localStorage.clear()
|
|
workspaceStore.set('test_ws')
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
describe('UserDraft.save / get / remove (no observers)', () => {
|
|
it('save writes a wrapped { value } payload under the workspace-scoped key', () => {
|
|
UserDraft.save('flow', 'u/me/myflow', { hello: 'world' })
|
|
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' }))
|
|
})
|
|
|
|
it('get reads from a wrapped localStorage payload when no observer is registered', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code'))
|
|
|
|
expect(UserDraft.get('script', 'u/me/script1')).toBe('code')
|
|
})
|
|
|
|
it('get returns undefined when nothing is stored', () => {
|
|
expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined()
|
|
})
|
|
|
|
it('get returns undefined when the stored payload is malformed', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json')
|
|
expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined()
|
|
})
|
|
|
|
it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => {
|
|
// Drafts written before the wrapping was introduced look like the raw
|
|
// value rather than { value: ... }. They must be ignored rather than
|
|
// surface as undefined-shaped drafts.
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' }))
|
|
expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined()
|
|
expect(UserDraft.has('flow', 'u/me/raw')).toBe(false)
|
|
})
|
|
|
|
it('remove clears the localStorage entry', () => {
|
|
UserDraft.save('app', 'u/me/app1', { grid: [] })
|
|
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull()
|
|
|
|
UserDraft.remove('app', 'u/me/app1')
|
|
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull()
|
|
})
|
|
|
|
it('uses the workspace from opts when provided', () => {
|
|
UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' })
|
|
|
|
expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1))
|
|
// Default workspace key must remain empty.
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull()
|
|
})
|
|
|
|
it('supports trigger kinds as item kinds', () => {
|
|
UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] })
|
|
|
|
expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe(
|
|
wrapped({ brokers: ['localhost:9092'] })
|
|
)
|
|
})
|
|
|
|
it('throws when neither opts.workspace nor $workspaceStore is set', () => {
|
|
workspaceStore.set(undefined)
|
|
expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/)
|
|
})
|
|
})
|
|
|
|
describe('UserDraft live editor draft registry', () => {
|
|
it('stores the live editor storage path and effective path per workspace and kind', () => {
|
|
UserDraft.setLiveEditorDraft({
|
|
itemKind: 'script',
|
|
storagePath: '',
|
|
effectivePath: 'u/me/generated_script'
|
|
})
|
|
|
|
expect(UserDraft.getLiveEditorDraft('script')).toEqual({
|
|
workspace: 'test_ws',
|
|
itemKind: 'script',
|
|
storagePath: '',
|
|
effectivePath: 'u/me/generated_script'
|
|
})
|
|
})
|
|
|
|
it('keeps live editor registrations isolated by workspace', () => {
|
|
UserDraft.setLiveEditorDraft({
|
|
workspace: 'ws_a',
|
|
itemKind: 'flow',
|
|
storagePath: '',
|
|
effectivePath: 'u/me/a'
|
|
})
|
|
UserDraft.setLiveEditorDraft({
|
|
workspace: 'ws_b',
|
|
itemKind: 'flow',
|
|
storagePath: '',
|
|
effectivePath: 'u/me/b'
|
|
})
|
|
|
|
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe(
|
|
'u/me/a'
|
|
)
|
|
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe(
|
|
'u/me/b'
|
|
)
|
|
})
|
|
|
|
it('clears only the matching live editor storage path when provided', () => {
|
|
UserDraft.setLiveEditorDraft({
|
|
itemKind: 'raw_app',
|
|
storagePath: '',
|
|
effectivePath: 'u/me/live_app'
|
|
})
|
|
|
|
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' })
|
|
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined()
|
|
|
|
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' })
|
|
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))
|
|
const handle = UserDraft.use<typeof draft>('script', '')
|
|
UserDraft.setLiveEditorDraft({
|
|
itemKind: 'script',
|
|
storagePath: '',
|
|
effectivePath: 'u/me/live_script'
|
|
})
|
|
|
|
deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, {
|
|
preserveLiveDraft: true
|
|
})
|
|
flushPersist()
|
|
|
|
expect(handle.draft).toEqual(draft)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.use() — observer sync', () => {
|
|
it('loads the existing localStorage value on first use', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded'))
|
|
|
|
const handle = UserDraft.use<string>('flow', 'u/me/loaded')
|
|
expect(handle.draft).toBe('preloaded')
|
|
})
|
|
|
|
it('two handles on the same key share the same underlying state', () => {
|
|
const a = UserDraft.use<number>('flow', 'u/me/shared')
|
|
const b = UserDraft.use<number>('flow', 'u/me/shared')
|
|
|
|
a.draft = 42
|
|
expect(b.draft).toBe(42)
|
|
|
|
b.draft = 99
|
|
expect(a.draft).toBe(99)
|
|
})
|
|
|
|
it('save() propagates to live use() handles and persists immediately', () => {
|
|
const handle = UserDraft.use<number>('flow', 'u/me/observed')
|
|
expect(handle.draft).toBeUndefined()
|
|
|
|
UserDraft.save('flow', 'u/me/observed', 7)
|
|
expect(handle.draft).toBe(7)
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7))
|
|
|
|
UserDraft.save('flow', 'u/me/observed', 9)
|
|
expect(handle.draft).toBe(9)
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
|
|
})
|
|
|
|
it('get() returns a cloneable snapshot of live handle values', () => {
|
|
const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '')
|
|
handle.draft = { path: 'u/me/live', nested: { value: 1 } }
|
|
|
|
const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '')
|
|
expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } })
|
|
expect(draft).not.toBe(handle.draft)
|
|
expect(() => structuredClone(draft)).not.toThrow()
|
|
})
|
|
|
|
it('remove() clears localStorage without touching the in-memory handle', () => {
|
|
// Seed localStorage so the live handle initialises from it.
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1))
|
|
const handle = UserDraft.use<number>('flow', 'u/me/removed')
|
|
expect(handle.draft).toBe(1)
|
|
|
|
UserDraft.remove('flow', 'u/me/removed')
|
|
// Live handle keeps its current value — remove() only wipes the
|
|
// persisted side. This is what lets callers run UserDraft.remove
|
|
// during navigation without flickering the editor UI.
|
|
expect(handle.draft).toBe(1)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull()
|
|
})
|
|
|
|
it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => {
|
|
// Seed: handle holds a divergent local autosave.
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit'))
|
|
const handle = UserDraft.use<string>('flow', 'u/me/discard')
|
|
expect(handle.draft).toBe('local-edit')
|
|
|
|
// Reset to a known backend baseline.
|
|
UserDraft.discard('flow', 'u/me/discard', 'backend-baseline')
|
|
flushPersist()
|
|
|
|
// In-memory handle reflects the fallback immediately.
|
|
expect(handle.draft).toBe('backend-baseline')
|
|
// LS is cleared and stays cleared — the fallback must NOT round-trip
|
|
// back into storage (that would make the next reload "restore" the
|
|
// fallback as if it were a real autosave).
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull()
|
|
})
|
|
|
|
it('discard() with undefined fallback clears both LS and in-memory state', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit'))
|
|
const handle = UserDraft.use<string>('flow', 'u/me/wipe')
|
|
expect(handle.draft).toBe('local-edit')
|
|
|
|
UserDraft.discard('flow', 'u/me/wipe', undefined)
|
|
flushPersist()
|
|
|
|
expect(handle.draft).toBeUndefined()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull()
|
|
})
|
|
|
|
it('the second write through the handle setter persists to localStorage', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/setter')
|
|
|
|
// First write is the baseline — not persisted.
|
|
handle.draft = 'initial'
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull()
|
|
|
|
// Second (and onwards) persists.
|
|
handle.draft = 'persisted'
|
|
flushPersist()
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted'))
|
|
})
|
|
|
|
it('setting handle.draft = undefined after edits removes the localStorage entry', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/clear')
|
|
handle.draft = 'initial' // baseline, not persisted
|
|
handle.draft = 'edited' // persisted
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull()
|
|
|
|
handle.draft = undefined
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull()
|
|
expect(handle.draft).toBeUndefined()
|
|
})
|
|
|
|
it('two handles in different workspaces are isolated', () => {
|
|
const a = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_a' })
|
|
const b = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_b' })
|
|
|
|
a.draft = 1
|
|
b.draft = 2
|
|
|
|
expect(a.draft).toBe(1)
|
|
expect(b.draft).toBe(2)
|
|
})
|
|
|
|
it('save() falls back to localStorage when no handle is registered', () => {
|
|
UserDraft.save('flow', 'u/me/noobs', 'fallback')
|
|
// First use() afterwards loads the persisted value.
|
|
const handle = UserDraft.use<string>('flow', 'u/me/noobs')
|
|
expect(handle.draft).toBe('fallback')
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.use() — defaultValue', () => {
|
|
it('returns defaultValue when localStorage has no entry', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/withdefault', { defaultValue: 'fallback' })
|
|
|
|
expect(handle.draft).toBe('fallback')
|
|
})
|
|
|
|
it('does not persist the defaultValue on first read', () => {
|
|
UserDraft.use<string>('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' })
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull()
|
|
})
|
|
|
|
it('localStorage value wins over defaultValue', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted'))
|
|
|
|
const handle = UserDraft.use<string>('flow', 'u/me/overridden', {
|
|
defaultValue: 'fallback'
|
|
})
|
|
|
|
expect(handle.draft).toBe('persisted')
|
|
})
|
|
|
|
it('second write through the setter persists even though defaultValue was set', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/writeDefault', {
|
|
defaultValue: 'fallback'
|
|
})
|
|
|
|
// First write is the initial-value baseline.
|
|
handle.draft = 'initial'
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull()
|
|
|
|
handle.draft = 'modified'
|
|
flushPersist()
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified'))
|
|
})
|
|
})
|
|
|
|
describe('UserDraft — empty path (new-item drafts persist across reloads)', () => {
|
|
it('use() with empty path persists subsequent edits to localStorage', () => {
|
|
const handle = UserDraft.use<number>('flow', '', { defaultValue: 0 })
|
|
|
|
// First write under saveInitialValue=false counts as the baseline and
|
|
// is skipped — only the user's subsequent edits persist.
|
|
handle.draft = 99
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
|
handle.draft = 100
|
|
flushPersist()
|
|
// The "+ Flow / + Script / …" buttons are expected to call
|
|
// `UserDraft.remove(kind, '')` to wipe before navigating; an
|
|
// unguarded /add reload therefore restores the previous session.
|
|
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
|
|
})
|
|
|
|
it('two handles with empty path share state per workspace', () => {
|
|
const a = UserDraft.use<number>('flow', '')
|
|
const b = UserDraft.use<number>('flow', '')
|
|
|
|
a.draft = 1
|
|
expect(b.draft).toBe(1)
|
|
|
|
b.draft = 2
|
|
expect(a.draft).toBe(2)
|
|
})
|
|
|
|
it('save() with empty path writes to localStorage when no handle is live', () => {
|
|
UserDraft.save('flow', '', 5)
|
|
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
|
|
})
|
|
|
|
it('get() with empty path falls back to localStorage when no handle is live', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11))
|
|
expect(UserDraft.get('flow', '')).toBe(11)
|
|
})
|
|
|
|
it('remove() with empty path clears localStorage', () => {
|
|
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1))
|
|
UserDraft.remove('flow', '')
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('UserDraft — rev metadata for staleness checks', () => {
|
|
it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/atomic')
|
|
|
|
// Single atomic write — under saveInitialValue=false this counts as the
|
|
// initial baseline and shouldn't hit localStorage yet.
|
|
handle.setDraftAndMeta('backendValue', {
|
|
remoteRev: 42,
|
|
remoteDraftRev: '2026-01-01T00:00:00Z'
|
|
})
|
|
expect(handle.draft).toBe('backendValue')
|
|
expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' })
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull()
|
|
|
|
// A subsequent user edit persists *with* the rev metadata.
|
|
handle.draft = 'userEdit'
|
|
flushPersist()
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe(
|
|
JSON.stringify({
|
|
value: 'userEdit',
|
|
remoteRev: 42,
|
|
remoteDraftRev: '2026-01-01T00:00:00Z'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('setMeta updates only the rev fields, preserving the value', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/setmeta')
|
|
handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted
|
|
handle.draft = 'edited' // persisted with remoteRev: 1
|
|
|
|
handle.setMeta({ remoteRev: 2 })
|
|
expect(handle.draft).toBe('edited')
|
|
expect(handle.meta).toEqual({ remoteRev: 2 })
|
|
flushPersist()
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe(
|
|
JSON.stringify({ value: 'edited', remoteRev: 2 })
|
|
)
|
|
})
|
|
|
|
it('handle.draft setter preserves rev metadata across user edits', () => {
|
|
const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve')
|
|
handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' })
|
|
handle.draft = { count: 1 } // first edit, persisted
|
|
handle.draft = { count: 2 } // another edit
|
|
|
|
expect(handle.meta).toEqual({ remoteRev: 'v1' })
|
|
flushPersist()
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe(
|
|
JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' })
|
|
)
|
|
})
|
|
|
|
it('UserDraft.getMeta reads from localStorage when no live handle exists', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/getmeta',
|
|
JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' })
|
|
)
|
|
expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({
|
|
remoteRev: 7,
|
|
remoteDraftRev: '2026-01-02'
|
|
})
|
|
})
|
|
|
|
it('UserDraft.getMeta returns empty object when there is no entry', () => {
|
|
expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({})
|
|
})
|
|
|
|
it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/savepreserve',
|
|
JSON.stringify({ value: 'old', remoteRev: 5 })
|
|
)
|
|
UserDraft.save('flow', 'u/me/savepreserve', 'new')
|
|
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe(
|
|
JSON.stringify({ value: 'new', remoteRev: 5 })
|
|
)
|
|
})
|
|
|
|
it('UserDraft.save persists immediately when a live handle exists', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/live-save')
|
|
|
|
UserDraft.save('flow', 'u/me/live-save', 'external')
|
|
|
|
expect(handle.draft).toBe('external')
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external'))
|
|
})
|
|
|
|
it('UserDraft.save preserves live rev metadata while forcing persistence', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/live-save-meta')
|
|
handle.setDraftAndMeta('baseline', { remoteRev: 5 })
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull()
|
|
|
|
UserDraft.save('flow', 'u/me/live-save-meta', 'external')
|
|
|
|
expect(handle.draft).toBe('external')
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe(
|
|
JSON.stringify({ value: 'external', remoteRev: 5 })
|
|
)
|
|
})
|
|
|
|
it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/legacy',
|
|
JSON.stringify({ value: 'no-rev' })
|
|
)
|
|
const handle = UserDraft.use<string>('flow', 'u/me/legacy')
|
|
expect(handle.draft).toBe('no-rev')
|
|
expect(handle.meta).toEqual({})
|
|
})
|
|
|
|
it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => {
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/forceack',
|
|
JSON.stringify({ value: 'edited', remoteRev: 'v1' })
|
|
)
|
|
const handle = UserDraft.use<string>('flow', 'u/me/forceack')
|
|
|
|
// Without force, this is the entry's first state mutation and gets
|
|
// swallowed by saveInitialValue=false — localStorage would still
|
|
// hold the old remoteRev.
|
|
handle.setMeta({ remoteRev: 'v2' }, { force: true })
|
|
|
|
expect(handle.meta).toEqual({ remoteRev: 'v2' })
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
|
|
JSON.stringify({ value: 'edited', remoteRev: 'v2' })
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('checkStaleness', () => {
|
|
let checkStaleness: (
|
|
meta: { remoteRev?: string | number; remoteDraftRev?: string | number },
|
|
currentRev: string | number | undefined,
|
|
currentDraftRev?: string | number | undefined
|
|
) => 'draft' | 'version' | null
|
|
|
|
beforeEach(async () => {
|
|
// Re-import to dodge ESM caching surprises across test files.
|
|
;({ checkStaleness } = await import('./userDraft.svelte'))
|
|
})
|
|
|
|
it('returns null for legacy entries with no recorded rev', () => {
|
|
expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull()
|
|
})
|
|
|
|
it('returns null when meta matches current revs exactly', () => {
|
|
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull()
|
|
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull()
|
|
})
|
|
|
|
it('returns "draft" when a newer DB draft was pushed on the remote', () => {
|
|
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft')
|
|
})
|
|
|
|
it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => {
|
|
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft')
|
|
})
|
|
|
|
it('returns "version" when the deployed rev moved and draft revs match', () => {
|
|
expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version')
|
|
})
|
|
|
|
it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => {
|
|
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe(
|
|
'version'
|
|
)
|
|
})
|
|
|
|
it('prefers "draft" over "version" when both have changed', () => {
|
|
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft')
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.use() — reference counting & cleanup', () => {
|
|
it('destroys the entry when the last handle is released', () => {
|
|
// First handle acquires the entry.
|
|
const a = UserDraft.use<number>('flow', 'u/me/ref')
|
|
a.draft = 1 // baseline write — not persisted
|
|
|
|
// Second handle increments the count.
|
|
const b = UserDraft.use<number>('flow', 'u/me/ref')
|
|
expect(b.draft).toBe(1)
|
|
|
|
// onDestroy for both handles got registered.
|
|
expect(onDestroyCallbacks.length).toBe(2)
|
|
|
|
// Releasing one handle keeps the entry alive — save() still updates handle a.
|
|
const firstCb = onDestroyCallbacks.shift()!
|
|
firstCb()
|
|
|
|
UserDraft.save('flow', 'u/me/ref', 2)
|
|
expect(a.draft).toBe(2)
|
|
// External save() calls persist immediately, even with a live handle.
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
|
|
|
|
// Releasing the second handle drops the entry; subsequent save()
|
|
// must go straight to localStorage rather than mutating in-memory
|
|
// state (which no longer exists).
|
|
const secondCb = onDestroyCallbacks.shift()!
|
|
secondCb()
|
|
|
|
UserDraft.save('flow', 'u/me/ref', 3)
|
|
// UserDraft.save without a live entry writes synchronously.
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3))
|
|
})
|
|
|
|
it('a fresh use() after cleanup re-reads the latest persisted value', () => {
|
|
const a = UserDraft.use<string>('flow', 'u/me/cycle')
|
|
a.draft = 'initial' // baseline — not persisted
|
|
a.draft = 'edited' // persisted (after debounce)
|
|
flushPersist()
|
|
flushDestroyCallbacks()
|
|
|
|
// After all handles release, a brand-new use() must pick up the
|
|
// value persisted to localStorage from the previous round.
|
|
const b = UserDraft.use<string>('flow', 'u/me/cycle')
|
|
expect(b.draft).toBe('edited')
|
|
})
|
|
|
|
it('coalesces a typing storm into a single localStorage write per 500 ms window', () => {
|
|
const handle = UserDraft.use<string>('flow', 'u/me/debounce')
|
|
handle.draft = 'baseline' // first write — skipped under saveInitialValue=false
|
|
|
|
// Three quick edits inside the 500 ms window: in-memory updates every
|
|
// time, but localStorage stays untouched until the timer fires.
|
|
handle.draft = 'one'
|
|
vi.advanceTimersByTime(100)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
|
|
handle.draft = 'two'
|
|
vi.advanceTimersByTime(100)
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
|
|
handle.draft = 'three'
|
|
expect(handle.draft).toBe('three')
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
|
|
|
|
// After the window elapses, only the latest value lands.
|
|
vi.advanceTimersByTime(500)
|
|
expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three'))
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.useMany()', () => {
|
|
it('acquires one handle per spec in the synchronous initial reconcile', () => {
|
|
// `useMany`'s sync reconcile populates handles[0..] before returning,
|
|
// so callers (and `use()`'s 1-len wrapper) can use them immediately
|
|
// without waiting for an `$effect` tick.
|
|
const handles = UserDraft.useMany<number>(() => [
|
|
{ itemKind: 'flow', path: 'u/me/many', workspace: 'a' },
|
|
{ itemKind: 'flow', path: 'u/me/many', workspace: 'b' }
|
|
])
|
|
expect(handles.length).toBe(2)
|
|
|
|
// Each spec gets its own entry in the workspace-keyed store.
|
|
handles[0].draft = 0 // baseline
|
|
handles[0].draft = 1 // persisted
|
|
handles[1].draft = 0
|
|
handles[1].draft = 9
|
|
flushPersist()
|
|
expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1))
|
|
expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9))
|
|
|
|
// One component-level onDestroy releases every acquired entry.
|
|
expect(onDestroyCallbacks.length).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('gcUserDrafts', () => {
|
|
let gcUserDrafts: (maxAgeMs?: number) => void
|
|
let USER_DRAFT_GC_MAX_AGE_MS: number
|
|
const DAY = 24 * 60 * 60 * 1000
|
|
|
|
beforeEach(async () => {
|
|
;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte'))
|
|
})
|
|
|
|
it('sweeps entries whose lastWrittenAt is older than the cutoff', () => {
|
|
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
|
|
const old = Date.now() - 31 * DAY
|
|
const fresh = Date.now() - 1 * DAY
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/old',
|
|
JSON.stringify({ value: 1, lastWrittenAt: old })
|
|
)
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/fresh',
|
|
JSON.stringify({ value: 2, lastWrittenAt: fresh })
|
|
)
|
|
// Unrelated keys are left alone.
|
|
localStorage.setItem('some_other_key', 'unrelated')
|
|
|
|
gcUserDrafts()
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull()
|
|
expect(localStorage.getItem('some_other_key')).toBe('unrelated')
|
|
})
|
|
|
|
it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => {
|
|
// Pre-GC-feature entry (legacy migration output, or just an old entry
|
|
// from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC
|
|
// pass should stamp it as "now" rather than wipe it on sight.
|
|
localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' }))
|
|
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
|
|
|
|
gcUserDrafts()
|
|
|
|
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy')
|
|
expect(raw).not.toBeNull()
|
|
const parsed = JSON.parse(raw!)
|
|
expect(parsed.lastWrittenAt).toBe(Date.now())
|
|
expect(parsed.value).toBe('data')
|
|
})
|
|
|
|
it('exposes a 30-day default retention window', () => {
|
|
expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000)
|
|
})
|
|
|
|
it('respects a custom maxAgeMs', () => {
|
|
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
|
|
localStorage.setItem(
|
|
'userdraft/w/test_ws/flow/u/me/two_hours_ago',
|
|
JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 })
|
|
)
|
|
|
|
gcUserDrafts(60 * 60 * 1000) // 1h cutoff
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('normalizeForCompare', () => {
|
|
it('returns undefined for undefined input', () => {
|
|
expect(normalizeForCompare(undefined)).toBeUndefined()
|
|
})
|
|
|
|
it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => {
|
|
const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
|
|
expect(out).toEqual({ a: 1, c: { e: 2 } })
|
|
expect(Object.keys(out as object)).not.toContain('b')
|
|
expect(Object.keys((out as any).c)).not.toContain('d')
|
|
})
|
|
|
|
it('falls back to the original value when not serializable (cyclic)', () => {
|
|
const cyclic: any = { a: 1 }
|
|
cyclic.self = cyclic
|
|
expect(normalizeForCompare(cyclic)).toBe(cyclic)
|
|
})
|
|
})
|
|
|
|
describe('localDraftDiffers', () => {
|
|
it('returns false when there is no local draft', () => {
|
|
expect(localDraftDiffers(undefined, { a: 1 })).toBe(false)
|
|
expect(localDraftDiffers(null, { a: 1 })).toBe(false)
|
|
})
|
|
|
|
it('treats a draft that round-trips equal to the config as NOT differing', () => {
|
|
// The Schedule bug: getXCfg() emits conditionally-undefined keys, but
|
|
// the persisted draft went through JSON.stringify which dropped them.
|
|
const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
|
|
const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule }
|
|
expect(localDraftDiffers(persisted, freshCfg)).toBe(false)
|
|
})
|
|
|
|
it('returns true for a genuine difference', () => {
|
|
expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true)
|
|
expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.saveIfChanged', () => {
|
|
const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s'
|
|
|
|
it('does not persist a draft equal to the deployed baseline', () => {
|
|
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
|
|
// value is the post-load reactive cfg — same shape, undefined keys present
|
|
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
|
|
expect(localStorage.getItem(KEY)).toBeNull()
|
|
})
|
|
|
|
it('treats a value that round-trips equal to deployed as unchanged', () => {
|
|
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
|
|
const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule }
|
|
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
|
|
expect(localStorage.getItem(KEY)).toBeNull()
|
|
})
|
|
|
|
it('persists when the value differs from the deployed baseline', () => {
|
|
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
|
|
const value = { path: 'u/me/s', schedule: '5 0 * * *' }
|
|
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
|
|
expect(storedShape(KEY)).toBe(wrapped(value))
|
|
})
|
|
|
|
it('removes a pre-existing draft once the value reverts to deployed', () => {
|
|
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
|
|
UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' })
|
|
expect(localStorage.getItem(KEY)).not.toBeNull()
|
|
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
|
|
expect(localStorage.getItem(KEY)).toBeNull()
|
|
})
|
|
|
|
it('persists when there is no deployed baseline (undefined)', () => {
|
|
const value = { path: 'u/me/s', schedule: '0 0 * * *' }
|
|
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined)
|
|
expect(storedShape(KEY)).toBe(wrapped(value))
|
|
})
|
|
})
|
|
|
|
describe('UserDraft.list / clear / setDraftAndMeta', () => {
|
|
it('enumerates persisted-only drafts for the requested workspace and kinds', () => {
|
|
UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' })
|
|
UserDraft.setDraftAndMeta(
|
|
'flow',
|
|
'f/b',
|
|
{ path: 'f/b', value: { modules: [] } },
|
|
{ remoteRev: 2 },
|
|
{ workspace: 'other_ws' }
|
|
)
|
|
UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {})
|
|
|
|
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
|
{
|
|
workspace: 'test_ws',
|
|
itemKind: 'script',
|
|
path: 'f/a',
|
|
value: { path: 'f/a', content: 'a' },
|
|
meta: { remoteRev: 'h1' },
|
|
persisted: true,
|
|
live: false
|
|
}
|
|
])
|
|
expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([
|
|
expect.objectContaining({
|
|
workspace: 'other_ws',
|
|
itemKind: 'flow',
|
|
path: 'f/b',
|
|
persisted: true,
|
|
live: false
|
|
})
|
|
])
|
|
})
|
|
|
|
it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => {
|
|
UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {})
|
|
UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {})
|
|
UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {})
|
|
|
|
const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] })
|
|
|
|
expect(entries).toHaveLength(3)
|
|
expect(entries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
itemKind: 'script',
|
|
path: '',
|
|
value: { path: '', content: 'scratch' }
|
|
}),
|
|
expect.objectContaining({
|
|
itemKind: 'script',
|
|
path: 'f/new-a',
|
|
value: { path: 'f/new-a', content: 'a' }
|
|
}),
|
|
expect.objectContaining({
|
|
itemKind: 'script',
|
|
path: 'f/new-b',
|
|
value: { path: 'f/new-b', content: 'b' }
|
|
})
|
|
])
|
|
)
|
|
})
|
|
|
|
it('enumerates live-only drafts before the debounce persists them', () => {
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live')
|
|
handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' })
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull()
|
|
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
|
{
|
|
workspace: 'test_ws',
|
|
itemKind: 'script',
|
|
path: 'f/live',
|
|
value: { path: 'f/live', content: 'live' },
|
|
meta: { remoteRev: 'h1' },
|
|
persisted: false,
|
|
live: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('dedupes entries that are both persisted and live', () => {
|
|
UserDraft.setDraftAndMeta(
|
|
'script',
|
|
'f/both',
|
|
{ path: 'f/both', content: 'persisted' },
|
|
{
|
|
remoteRev: 'h1'
|
|
}
|
|
)
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both')
|
|
handle.draft = { path: 'f/both', content: 'live' }
|
|
|
|
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
|
{
|
|
workspace: 'test_ws',
|
|
itemKind: 'script',
|
|
path: 'f/both',
|
|
value: { path: 'f/both', content: 'live' },
|
|
meta: { remoteRev: 'h1' },
|
|
persisted: true,
|
|
live: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('clear removes persisted storage and live state without re-persisting', () => {
|
|
UserDraft.setDraftAndMeta(
|
|
'script',
|
|
'f/clear',
|
|
{ path: 'f/clear', content: 'x' },
|
|
{
|
|
remoteRev: 'h1'
|
|
}
|
|
)
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear')
|
|
expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' })
|
|
|
|
UserDraft.clear('script', 'f/clear')
|
|
flushPersist()
|
|
|
|
expect(handle.draft).toBeUndefined()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull()
|
|
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
|
|
})
|
|
|
|
it('clear cancels pending debounced live writes', () => {
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear')
|
|
handle.draft = { path: 'f/pending-clear', content: 'initial' }
|
|
handle.draft = { path: 'f/pending-clear', content: 'pending' }
|
|
|
|
UserDraft.clear('script', 'f/pending-clear')
|
|
expect(handle.draft).toBeUndefined()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
|
|
|
|
flushPersist()
|
|
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
|
|
})
|
|
|
|
it('clear does not let an old debounced remove delete a later direct write', () => {
|
|
const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear'
|
|
const handle = UserDraft.use<{ path: string; content: string }>(
|
|
'script',
|
|
'f/rewrite-after-clear'
|
|
)
|
|
handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' }
|
|
handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' }
|
|
|
|
UserDraft.clear('script', 'f/rewrite-after-clear')
|
|
flushDestroyCallbacks()
|
|
UserDraft.setDraftAndMeta(
|
|
'script',
|
|
'f/rewrite-after-clear',
|
|
{ path: 'f/rewrite-after-clear', content: 'new' },
|
|
{}
|
|
)
|
|
|
|
flushPersist()
|
|
expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' }))
|
|
})
|
|
|
|
it('list hides persisted drafts when a live handle has cleared the value', () => {
|
|
UserDraft.setDraftAndMeta(
|
|
'script',
|
|
'f/live-clear',
|
|
{ path: 'f/live-clear', content: 'persisted' },
|
|
{}
|
|
)
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear')
|
|
handle.draft = { path: 'f/live-clear', content: 'edited' }
|
|
handle.draft = undefined
|
|
|
|
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull()
|
|
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
|
|
})
|
|
|
|
it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => {
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta')
|
|
|
|
UserDraft.setDraftAndMeta(
|
|
'script',
|
|
'f/meta',
|
|
{ path: 'f/meta', content: 'first' },
|
|
{
|
|
remoteRev: 'h1',
|
|
remoteDraftRev: 'd1'
|
|
}
|
|
)
|
|
handle.draft = { path: 'f/meta', content: 'second' }
|
|
|
|
expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' })
|
|
expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' })
|
|
expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual(
|
|
expect.objectContaining({
|
|
value: { path: 'f/meta', content: 'second' },
|
|
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
|
|
})
|
|
)
|
|
})
|
|
|
|
it('static setDraftAndMeta persists first writes even when a live handle exists', () => {
|
|
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live')
|
|
|
|
UserDraft.setDraftAndMeta(
|
|
'script',
|
|
'f/static-live',
|
|
{ path: 'f/static-live', content: 'first' },
|
|
{ remoteRev: 'h1' }
|
|
)
|
|
|
|
expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' })
|
|
expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe(
|
|
JSON.stringify({
|
|
value: { path: 'f/static-live', content: 'first' },
|
|
remoteRev: 'h1'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('lists live drafts with runtime-only values without throwing', () => {
|
|
const handle = UserDraft.use<Record<string, unknown>>('script', 'f/runtime')
|
|
handle.draft = {
|
|
path: 'f/runtime',
|
|
content: 'x',
|
|
callback: () => 'not serializable'
|
|
}
|
|
|
|
expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow()
|
|
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
|
expect.objectContaining({
|
|
itemKind: 'script',
|
|
path: 'f/runtime',
|
|
value: { path: 'f/runtime', content: 'x' }
|
|
})
|
|
])
|
|
})
|
|
})
|