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>
This commit is contained in:
Guilhem Lemouel
2026-05-20 15:58:20 +02:00
parent 9c28bbfd69
commit 7e38c01e64
59 changed files with 5838 additions and 534 deletions
@@ -13,6 +13,7 @@
import DropdownV2Inner from './DropdownV2Inner.svelte'
import { pointerDownOutside } from '$lib/utils'
import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
@@ -40,7 +41,10 @@
size?: ButtonType.UnifiedSize
btnText?: string
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
// In customMenu mode the snippet receives the melt-ui `item` action
// store so consumers can wrap their own rows in <MenuItem> (or
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
maxHeight?: string | undefined
}
@@ -172,7 +176,7 @@
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.()}
{@render menu?.({ item, close })}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
+19
View File
@@ -1829,6 +1829,25 @@
$effect(() => {
lang = scriptLangToEditorLang(scriptLang)
})
// Sync external `code` prop mutations into Monaco's model. Without
// this, parents that pass `code={...}` (no bind) — e.g. each inline
// rawscript in the flow editor — can mutate the prop and never see
// the change reflected in Monaco. The `getValue() !== code` guard
// keeps the user's caret intact when the change actually originated
// from typing inside Monaco (which propagates `code` back via the
// `$bindable` and re-fires this effect with `code === getValue()`).
let lastExternalCodeSync = code
$effect(() => {
if (code === lastExternalCodeSync) return
lastExternalCodeSync = code
if (!editor) return
untrack(() => {
if (editor!.getValue() !== code) {
editor!.setValue(code ?? '')
}
})
})
$effect(() => {
filePath = computePath(path)
})
@@ -6,13 +6,28 @@
interface Props {
beforeYaml: string
afterYaml: string
/** Side-by-side vs unified. Leave undefined to let
* FlowGraphDiffViewer show its own user-facing toggle (matches the
* pre-fork-diff-drawer behavior). */
inlineDiff?: boolean
/** Forwarded to FlowGraphDiffViewer — render an empty surface
* placeholder for the "before" / "after" pane when the item is
* added / removed. */
beforeMissing?: boolean
afterMissing?: boolean
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
inlineDiff = undefined,
beforeMissing = false,
afterMissing = false
}: Props = $props()
let diffMode: 'yaml' | 'graph' = $state('graph')
</script>
<div class="flex flex-col h-full min-h-[500px] gap-2">
<div class="flex flex-col h-full min-h-[500px]">
<Tabs bind:selected={diffMode}>
<Tab value="graph" label="Graph" />
<Tab value="yaml" label="YAML" />
@@ -30,6 +45,7 @@
defaultLang="yaml"
defaultOriginal={beforeYaml}
defaultModified={afterYaml}
{inlineDiff}
readOnly
/>
{/await}
@@ -37,7 +53,7 @@
{#await import('$lib/components/FlowGraphDiffViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default {beforeYaml} {afterYaml} />
<Module.default {beforeYaml} {afterYaml} {beforeMissing} {afterMissing} {inlineDiff} />
{/await}
{/if}
</div>
@@ -2,12 +2,12 @@
import type { OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { Alert } from './common'
import { computeFlowModuleDiff } from './flows/flowDiff'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import type { Viewport } from '@xyflow/svelte'
const SIDE_BY_SIDE_MIN_WIDTH = 700
@@ -15,13 +15,54 @@
interface Props {
beforeYaml: string
afterYaml: string
/** When true, render an empty surface placeholder for the "before"
* pane in side-by-side mode (use for added items where there's no
* prior flow to show). */
beforeMissing?: boolean
/** Same as `beforeMissing` but for the "after" pane (use for removed
* items). */
afterMissing?: boolean
/** Render the unified single-pane diff when true, side-by-side
* otherwise. When undefined, the component renders its own
* Unified / Side-by-side toggle in the corner (legacy behavior for
* the standalone comparison page). A narrow viewer still falls back
* to unified automatically. */
inlineDiff?: boolean | undefined
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
beforeMissing = false,
afterMissing = false,
inlineDiff = undefined
}: Props = $props()
// Local toggle state, used only when no inlineDiff prop is supplied.
let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside')
const showLocalToggle = $derived(inlineDiff === undefined)
const effectiveInlineDiff = $derived(
inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified'
)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
// Track the content area's rendered height so unified-mode graphs can
// grow to fill the diff box (otherwise FlowGraphV2 sits at its
// content-fit height + small floor, leaving empty space below).
let contentAreaHeight = $state(0)
// Each FlowGraphV2 sizes itself to its own content (clamped to minHeight).
// In side-by-side mode we want both graphs to share the same height, so
// we track each side's reported height and feed back the max as minHeight
// to both. The width-graph then stays at its computed size; the shorter
// graph grows to match.
let beforeContentHeight = $state(0)
let afterContentHeight = $state(0)
const SHARED_MIN_HEIGHT = 400
const sharedMinHeight = $derived(
Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight)
)
// Shared viewport for synchronizing both graphs in side-by-side mode
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
@@ -29,7 +70,10 @@
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
function parseFlow(yaml: string, label: 'before' | 'after'): {
function parseFlow(
yaml: string,
label: 'before' | 'after'
): {
flow: OpenFlow | undefined
error: string | undefined
} {
@@ -49,14 +93,27 @@
}
}
let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before'))
let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after'))
// For added/removed items, the caller passes empty YAML and sets the
// corresponding *Missing flag. We swap in an empty OpenFlow stub on
// that side so the unified diff path still has something to compare
// against (every module on the present side becomes added / removed).
// The side-by-side rendering uses the flag directly to draw a
// placeholder pane instead.
const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } }
let beforeParsed = $derived.by(() =>
beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before')
)
let afterParsed = $derived.by(() =>
afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after')
)
let parseError = $derived(beforeParsed.error ?? afterParsed.error)
let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow)
let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow)
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Side-by-side unless the caller asked for unified, OR the viewer pane
// is too narrow to comfortably split (fallback to unified for legibility).
const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH)
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
@@ -72,14 +129,6 @@
sharedViewport = viewport
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
} else {
viewMode = 'sidebyside'
}
})
</script>
{#if parseError}
@@ -88,10 +137,12 @@
</Alert>
{:else if beforeFlow && afterFlow}
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
<!-- Header with view toggle -->
<div class="flex flex-row items-center justify-end m-2 gap-4">
<div>
<ToggleButtonGroup bind:selected={viewMode}>
{#if showLocalToggle}
<!-- Legacy top banner — only holds the local Unified / Side-by-side
toggle when the parent doesn't pre-set inlineDiff. Zoom
controls live as an overlay below (same as the controlled path). -->
<div class="flex flex-row items-center justify-end m-2">
<ToggleButtonGroup bind:selected={localViewMode} noWFull>
{#snippet children({ item })}
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
<ToggleButton
@@ -103,102 +154,131 @@
{/snippet}
</ToggleButtonGroup>
</div>
<!-- Header with controls and view toggle -->
{/if}
<!-- Main content area -->
<div class="flex-1 overflow-hidden relative" bind:clientHeight={contentAreaHeight}>
{#if isSideBySide}
<!-- Shared controls for both graphs in side-by-side mode -->
<div class="flex">
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
<!-- Shared zoom controls overlay the graph viewport. Uses xy-flow's
own `.svelte-flow__controls` / `.svelte-flow__controls-button`
classes so it inherits the same look as FlowGraphV2's built-in
controls (FlowGraphV2's global override gives the buttons
bg-surface + hover bg-surface-hover with border:0). Local
overrides bump the icon size from 12px to 16px and drop the
xy-flow default shadow. No fit-view button — recenter can't
sync across two graphs. -->
<div
class="svelte-flow__controls horizontal absolute top-[15px] right-[15px] z-10 rounded bg-surface border border-gray-200 dark:border-gray-700 overflow-hidden diff-zoom-controls"
>
<button
type="button"
aria-label="Zoom in"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomIn()
afterGraph?.zoomIn()
}}
iconOnly
startIcon={{ icon: Plus }}
/>
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
>
<Plus />
</button>
<button
type="button"
aria-label="Zoom out"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomOut()
afterGraph?.zoomOut()
}}
iconOnly
startIcon={{ icon: Minus }}
/>
>
<Minus />
</button>
</div>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 overflow-hidden">
{#if isSideBySide}
<!-- Side-by-side view for wide screens -->
<Splitpanes class="!overflow-visible h-full">
<!-- Before (Left) -->
<Pane bind:size={beforePaneSize} minSize={30}>
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700 relative bg-surface-secondary {beforeMissing
? 'hatched-thin'
: ''}"
>
{#if beforeMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
Before <span class="italic">(no prior version)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (beforeContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
<!-- After (Right) - Show merged flow with shadowed removed modules -->
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full relative bg-surface-secondary {afterMissing
? 'hatched-thin'
: ''}"
>
{#if afterMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
After <span class="italic">(flow deleted)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (afterContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">After</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
@@ -219,7 +299,7 @@
editMode={false}
download={false}
scroll={false}
minHeight={400}
minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)}
triggerNode={false}
/>
</div>
@@ -231,3 +311,31 @@
<p class="text-gray-500">Loading graphs...</p>
</div>
{/if}
<style>
/* Thin diagonal hatch used as the empty-pane fill. */
.hatched-thin {
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 6px,
rgba(128, 128, 128, 0.16) 6px,
rgba(128, 128, 128, 0.16) 7.5px
);
}
/* Shared zoom controls overlay: same xy-flow layout as the in-graph
controls (`.svelte-flow__controls.horizontal`) but with bigger Plus/Minus
glyphs (xy-flow caps svg at 12px by default) and no panel shadow. */
.diff-zoom-controls {
box-shadow: none !important;
}
.diff-zoom-controls :global(.svelte-flow__controls-button) {
width: 28px;
height: 28px;
}
.diff-zoom-controls :global(.svelte-flow__controls-button svg) {
max-width: 16px;
max-height: 16px;
}
</style>
@@ -0,0 +1,162 @@
<!--
@component
Inline diff renderer for a single workspace item. Mirrors the per-kind
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
- everything else (apps, resources, variables, schedules, triggers…) →
a single Monaco YAML diff over the metadata
`inlineDiff` flips Monaco's `renderSideBySide` to false (unified view).
The component is content-sized — each Monaco block is sized to fit its
diff text (no internal scroll) using `lines * 19 + 24`; for the
Content+Metadata case we use the max of the two so switching tabs
doesn't reflow the parent.
-->
<script lang="ts">
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import FlowDiffViewer from './FlowDiffViewer.svelte'
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { scriptLangToEditorLang } from '$lib/scripts'
interface Props {
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
kind: string
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
* for "added" items (don't exist in the parent). */
originalRaw?: unknown
/** Raw value from `getItemValue(kind, path, forkWorkspace)`. Undefined
* for "removed" items (don't exist in the fork). */
currentRaw?: unknown
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
inlineDiff?: boolean
}
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
type Prepared = { lang?: string; content?: string; metadata: string }
function prepareValue(raw: unknown): Prepared {
if (!raw || typeof raw !== 'object') {
return { metadata: raw == null ? '' : String(raw) }
}
const cleaned = structuredClone(
cleanValueProperties(replaceFalseWithUndefined(raw as Record<string, unknown>))
)
const content = (cleaned as Record<string, unknown>)['content']
if (content !== undefined) {
delete (cleaned as Record<string, unknown>)['content']
}
const language = (raw as Record<string, unknown>).language
return {
lang:
typeof language === 'string'
? scriptLangToEditorLang(language as Parameters<typeof scriptLangToEditorLang>[0])
: undefined,
content: typeof content === 'string' ? content : undefined,
metadata: orderedYamlStringify(cleaned)
}
}
const original = $derived(prepareValue(originalRaw))
const current = $derived(prepareValue(currentRaw))
const hasContent = $derived(original.content !== undefined || current.content !== undefined)
// For added / removed flows, the missing side feeds an empty YAML so
// the YAML-mode editor shows the whole new (or removed) flow as a
// single-sided diff. FlowGraphDiffViewer uses the *Missing flag to
// swap in its own OpenFlow stub for parsing and to draw a placeholder
// pane in side-by-side mode.
const beforeFlowYaml = $derived(originalRaw == null ? '' : original.metadata)
const afterFlowYaml = $derived(currentRaw == null ? '' : current.metadata)
let contentTab: 'content' | 'metadata' = $state('content')
// Per-tab height: each Monaco block sizes to its own content. Switching
// tabs reflows the row, which is the expected tab behavior; we don't
// over-allocate to the larger tab the way the previous max() did.
const LINE_HEIGHT = 19
const EDITOR_CHROME = 24
function linesIn(s?: string): number {
return Math.max((s ?? '').split('\n').length, 1)
}
const contentHeight = $derived(
`${Math.max(linesIn(original.content), linesIn(current.content)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const metadataHeight = $derived(
`${Math.max(linesIn(original.metadata), linesIn(current.metadata)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const activeTabHeight = $derived(contentTab === 'content' ? contentHeight : metadataHeight)
</script>
{#if kind === 'flow'}
<div class="h-[600px]">
<FlowDiffViewer
beforeYaml={beforeFlowYaml}
afterYaml={afterFlowYaml}
beforeMissing={originalRaw == null}
afterMissing={currentRaw == null}
{inlineDiff}
/>
</div>
{:else if hasContent}
<div class="flex flex-col">
<Tabs bind:selected={contentTab}>
<Tab value="content" label="Content" />
<Tab value="metadata" label="Metadata" />
</Tabs>
<div style="height: {activeTabHeight}">
{#if contentTab === 'content'}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang={original.lang ?? current.lang}
defaultOriginal={original.content ?? ''}
defaultModified={current.content ?? ''}
{inlineDiff}
readOnly
/>
{/await}
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
{/await}
{/if}
</div>
</div>
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<div style="height: {metadataHeight}">
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
</div>
{/await}
{/if}
@@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import { onMount, untrack } from 'svelte'
import {
@@ -30,6 +31,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { globalDraftStore } from '$lib/components/copilot/chat/global/draftStore.svelte'
type Kind = WorkspaceItemKind
type Item = WorkspaceItem
@@ -90,10 +92,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* mounts under a stationary cursor doesn't clobber `initialHighlight`. */
let mouseActive = $state(false)
// Seed from cache so kinds already fetched in this session render on the
// first frame. Read once at mount: melt-ui mounts a fresh picker per
// popover open, so workspace changes are picked up at the next open
// without needing this seed to be reactive.
// Seed from the last fetched snapshot so kinds already fetched in this
// session render on the first frame. Each entry is replaced once
// `loadKind` returns fresh data — stale-while-revalidate, so deploys and
// AI-created drafts surface on the next open without explicit cache
// busting.
let loaded = $state<Partial<Record<Kind, Item[]>>>(
(() => {
if (!$workspaceStore) return {}
@@ -109,8 +112,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
async function ensureLoaded(kind: Kind) {
if (!$workspaceStore) return
if (loaded[kind]) return
loadingKind[kind] = true
// Always re-fetch. If we have nothing cached, show a spinner; if we do,
// keep displaying it and quietly swap to fresh data when it lands.
if (!loaded[kind]) loadingKind[kind] = true
try {
const items = await loadKind($workspaceStore, kind)
loaded[kind] = items
@@ -119,6 +123,26 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
}
}
// AI tools populate `globalDraftStore` with in-memory drafts (the AI may
// have edited a flow that hasn't been persisted to the backend yet).
// Merge those into the picker so users can navigate to them. Filter to
// kinds the picker actually displays.
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
function aiDraftsForKind(k: Kind): Item[] {
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return globalDraftStore
.listDrafts($workspaceStore)
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
summary: d.summary ?? '',
kind: k,
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
}))
}
// Fetch the scope's kind on entry to a non-root level. The `'all'` scope
// needs every kind loaded since it merges items across them.
$effect(() => {
@@ -140,6 +164,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
leaves: Item[]
}
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
* scaffolded a script/flow/app via chat tools without the user saving
* yet — those drafts should be navigable from the picker. Existing items
* (same path) win to keep the backend's metadata (summary etc.). */
function withAiDrafts(items: Item[], k: Kind): Item[] {
const ai = aiDraftsForKind(k)
if (ai.length === 0) return items
const known = new Set(items.map((it) => it.path))
return items.concat(ai.filter((d) => !known.has(d.path)))
}
/** Inject the currently-edited item into a kind's list at its live path,
* dropping the saved entry when a draft rename is in progress. Other kinds
* pass through untouched. */
@@ -207,7 +242,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* cached. */
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
if (!kinds.includes(k)) return []
const items = withCurrent(list ?? [], k)
const items = withAiDrafts(withCurrent(list ?? [], k), k)
if (items.length === 0) return []
return buildTreeFromItems(items)
}
@@ -219,7 +254,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* one folder hierarchy. Each leaf still carries its real kind, so the row
* icon and `editPathFor` routing still work; folders contain a mix. */
const allTree = $derived.by(() => {
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
return merged.length === 0 ? [] : buildTreeFromItems(merged)
})
@@ -255,7 +290,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let allItems = $derived<SearchInput[]>(
kinds.flatMap((k) =>
withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` }))
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
...it,
_key: `${k}:${it.path}`
}))
)
)
@@ -528,32 +566,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
{#snippet leafRow(it: Item, secondary: string, baseClass: string)}
{@const key = leafKey(it)}
{@const isHl = key === highlightedKey}
{@const isCur = isCurrent(it)}
<button
type="button"
<WorkspaceItemRow
kind={it.kind}
summary={it.summary}
{secondary}
highlighted={key === highlightedKey}
current={isCurrent(it)}
id={idFor(key)}
role="option"
aria-selected={isHl}
data-nav-key={key}
aria-current={isCur ? 'true' : undefined}
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
? 'bg-surface-hover'
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''}"
onmousedown={(e) => e.preventDefault()}
navKey={key}
{baseClass}
onclick={() => pick(it)}
onmouseenter={() => setHoverHighlight(key)}
>
<RowIcon kind={it.kind} size={12} />
<div class="min-w-0 flex-1">
{#if it.summary}
<div class="text-xs text-primary truncate">{it.summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
</button>
/>
{/snippet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -0,0 +1,146 @@
<!--
@component
Visual row for a workspace item (script / flow / app / resource /
schedule / trigger / …). Matches the leaf-row layout used by
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
beneath, or just the mono path when there's no summary.
Pure presentation — the caller controls highlighting / current state via
props, supplies the onclick/onmouseenter handlers, and can pass an
`extras` snippet for right-side adornments (status dots, badges, …).
The button uses `onmousedown={(e) => e.preventDefault()}` so the click
doesn't steal focus from a sibling search input (matches the picker).
-->
<script module lang="ts">
import type { ComponentProps } from 'svelte'
import RowIconType from '$lib/components/common/table/RowIcon.svelte'
export type WorkspaceItemRowKind = ComponentProps<typeof RowIconType>['kind']
</script>
<script lang="ts">
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import type { Snippet } from 'svelte'
interface Props {
kind: WorkspaceItemRowKind
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
* Forwarded to RowIcon. */
triggerKind?: string
/** Optional summary text shown above the path. */
summary?: string
/** Mono path (or any secondary identifier). When summary is empty
* this is the only visible text. */
secondary: string
/** Highlighted via keyboard nav. Used for `aria-selected` +
* surface-hover background. */
highlighted?: boolean
/** "Currently editing this" — the picker uses this to grey out the
* active row and disable its click. */
current?: boolean
/** DOM id, used for `aria-activedescendant`. */
id?: string
/** Stamped on the element as `data-nav-key` so the parent can
* `pickerRoot.querySelector(...)` to scroll into view. */
navKey?: string
/** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */
baseClass?: string
/** Extra left padding (px) for tree-view indentation. Adds to the
* default `px-3` horizontal padding. */
indent?: number
/** Title tooltip shown on hover; defaults to the secondary text. */
title?: string
/** When set, the row renders as an `<a href target="_blank">` link
* instead of a `<button>`. Used by callers that want native
* new-tab / cmd-click behaviour. `onclick` still forwards. */
href?: string
onclick?: () => void
onmouseenter?: () => void
/** Right-side adornments (status dot, badges, …). The `group` class
* is always applied to the root so the snippet can use
* `group-hover:*` utilities to reveal hover-only affordances. */
extras?: Snippet
}
let {
kind,
triggerKind,
summary,
secondary,
highlighted = false,
current = false,
id,
navKey,
baseClass = 'py-1.5',
indent = 0,
title,
href,
onclick,
onmouseenter,
extras
}: Props = $props()
const rootClass = $derived(
`group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}`
)
</script>
{#if href}
<a
{href}
target="_blank"
rel="noopener noreferrer"
{id}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: ${indent}px` : undefined}
class={rootClass}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</a>
{:else}
<button
type="button"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: ${indent}px` : undefined}
class={rootClass}
onmousedown={(e) => e.preventDefault()}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</button>
{/if}
@@ -77,7 +77,8 @@
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
unsavedConfirmationModal,
onSavedNewAppPath
onSavedNewAppPath,
onNavigate
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
@@ -857,6 +858,7 @@
rightPanelHidden={rightPanelSize === 0}
bottomPanelHidden={runnablePanelSize === 0}
{onSavedNewAppPath}
{onNavigate}
onShowLeftPanel={() => showLeftPanel()}
onShowRightPanel={() => showRightPanel()}
onShowBottomPanel={() => showBottomPanel()}
@@ -63,7 +63,7 @@
import DebugPanel from './contextPanel/DebugPanel.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { goto } from '$app/navigation'
import HideButton from './settingsPanel/HideButton.svelte'
@@ -109,6 +109,7 @@
onHideRightPanel?: () => void
onHideLeftPanel?: () => void
onHideBottomPanel?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
}
let {
@@ -129,7 +130,8 @@
onShowBottomPanel,
onHideLeftPanel,
onHideRightPanel,
onHideBottomPanel
onHideBottomPanel,
onNavigate = undefined
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
@@ -316,7 +318,6 @@
preserve_on_behalf_of: preserveOnBehalfOf || undefined
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: $summary,
@@ -1013,7 +1014,7 @@
bind:path={newEditedPath}
savedPath={$appPath || newPath || undefined}
kind="app"
onNavigate={(item) => goto(editPathFor(item))}
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div class="flex gap-2 {compactTopbar ? 'hidden' : ''}">
{#if $app}
@@ -122,7 +122,7 @@
})
$effect(() => {
appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl())
})
</script>
@@ -247,10 +247,10 @@
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
disabled={appPath == ''}
disabled={!savedApp}
/>
</div>
{#if appPath == ''}
{#if !savedApp}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<ClipboardPanel content={secretUrlHref} size="md" />
@@ -164,6 +164,8 @@ export interface AppEditorProps {
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
onSavedNewAppPath?: (path: string) => void
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
}
export type App = {
@@ -78,6 +78,15 @@ this component just proposes new values.
})
}
// External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap
// stays active for a brief window after the menu closes — focusing our
// input during that window causes checkFocusIn to slam focus back out, which
// fires onblur=save and instantly closes the edit. A 50ms defer is enough
// for Melt's trap to release.
export function edit() {
setTimeout(startEditing, 50)
}
function save() {
// Re-entry guard: Enter calls `save()` and sets `editing = false`,
// which unmounts the `<input>` and synchronously fires its `blur`
@@ -1,32 +1,66 @@
<script lang="ts">
import AIChatDisplay from './AIChatDisplay.svelte'
import { untrack } from 'svelte'
import { getContext, untrack } from 'svelte'
import { type ScriptLang } from '$lib/gen'
import { dbSchemas, userStore, workspaceStore } from '$lib/stores'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import {
AIChatManager,
aiChatManager as singletonAiChatManager,
AIMode
} from './AIChatManager.svelte'
const aiChatManager = getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager
import { base } from '$lib/base'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
let {
hideHeader = false,
hideModeSelector = false,
forceDisabled = false,
forceDisabledMessage = '',
wideLayout = false,
emptyHint,
inputPreface
}: {
hideHeader?: boolean
hideModeSelector?: boolean
// External "you can't type here" override. Used by sessions when
// the session's committed workspace was deleted/archived so the
// chat is effectively read-only until the user moves or discards
// the session. Wins over the internal disabled derivation.
forceDisabled?: boolean
forceDisabledMessage?: string
// Forwarded to AIChatDisplay. When true, the messages / input
// columns are centered in a max-w-3xl px-8 box. Sessions opt
// in; the narrow global-chat panel leaves it off.
wideLayout?: boolean
emptyHint?: import('svelte').Snippet
inputPreface?: import('svelte').Snippet
} = $props()
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const hasCopilot = $derived($copilotInfo.enabled)
const disabled = $derived(
!hasCopilot ||
forceDisabled ||
!hasCopilot ||
(aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
)
const disabledMessage = $derived(
!hasCopilot
? isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
forceDisabled
? forceDisabledMessage
: !hasCopilot
? isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -53,6 +87,10 @@
aiChatManager.sendRequest(options)
}
export function focusInput() {
aiChatDisplay?.focusInput()
}
const historyManager = aiChatManager.historyManager
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
@@ -129,4 +167,9 @@
{disabled}
{disabledMessage}
{suggestions}
{hideHeader}
{hideModeSelector}
{wideLayout}
{emptyHint}
{inputPreface}
></AIChatDisplay>
@@ -145,13 +145,18 @@ export class AIChatManager {
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
allowedModes: Record<AIMode, boolean> = $derived({
script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined,
flow: this.flowAiChatHelpers !== undefined,
app: this.appAiChatHelpers !== undefined,
navigator: true,
ask: true,
API: true,
script:
this.flowAiChatHelpers === undefined &&
this.scriptEditorOptions !== undefined &&
!this.disabledModes.script,
flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow,
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app,
navigator: !this.disabledModes.navigator,
ask: !this.disabledModes.ask,
API: !this.disabledModes.API,
// Dev-only gate. See `./global/gate.ts` for how to enable.
global: isAIModeVisible(AIMode.GLOBAL)
})
@@ -683,6 +688,12 @@ export class AIChatManager {
}
}
// Optional pre-flight hook called once per send, after validation but
// before any UI state mutates or backend calls go out. Sessions use
// this to commit/materialise the workspace (creating a staged fork via
// the API) so the first message targets the correct workspace.
beforeSend?: () => Promise<void> | void
sendRequest = async (
options: {
removeDiff?: boolean
@@ -707,6 +718,13 @@ export class AIChatManager {
if (!this.instructions.trim()) {
return
}
if (this.beforeSend) {
try {
await this.beforeSend()
} catch (e) {
console.error('AIChatManager beforeSend hook failed', e)
}
}
try {
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
@@ -76,7 +76,7 @@
onClick={() => onMenuOpen?.()}
startIcon={{ icon: Menu }}
iconOnly
></Button>
/>
</div>
<div class="flex-1 min-h-0">
{@render children?.()}
@@ -96,5 +96,13 @@
{/if}
</Splitpanes>
{:else}
{@render children?.()}
<div
class={classNames(
'flex-1 min-h-0 flex flex-col',
noBorder || $userStore?.operator || isMobile ? '' : isCollapsed ? 'pl-12' : 'pl-40',
'transition-all ease-in-out duration-200'
)}
>
{@render children?.()}
</div>
{/if}
@@ -2,7 +2,14 @@
import { ChevronDown } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { twMerge } from 'tailwind-merge'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { getContext } from 'svelte'
import {
AIChatManager,
aiChatManager as singletonAiChatManager,
AIMode
} from './AIChatManager.svelte'
const aiChatManager = getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager
</script>
<div class="min-w-0">
@@ -13,42 +20,38 @@
class="max-w-full"
>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
>
<span class={`truncate`}>
{aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode
</span>
{#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1}
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
</div>
{/snippet}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
>
<span class={`truncate`}>
{aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode
</span>
{#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1}
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each Object.values(AIMode) as possibleMode}
{#if aiChatManager.allowedModes[possibleMode]}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
aiChatManager.mode === possibleMode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.changeMode(possibleMode)
close()
}}
>
{possibleMode.charAt(0).toUpperCase() + possibleMode.slice(1)} mode
</button>
{/if}
{/each}
</div>
{/snippet}
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each Object.values(AIMode) as possibleMode}
{#if aiChatManager.allowedModes[possibleMode]}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
aiChatManager.mode === possibleMode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.changeMode(possibleMode)
close()
}}
>
{possibleMode.charAt(0).toUpperCase() + possibleMode.slice(1)} mode
</button>
{/if}
{/each}
</div>
{/snippet}
</Popover>
</div>
@@ -1,7 +1,10 @@
<script lang="ts">
import { AlertTriangle } from 'lucide-svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { getContext } from 'svelte'
import { AIChatManager, aiChatManager as singletonAiChatManager } from './AIChatManager.svelte'
const aiChatManager = getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
import { workspaceStore } from '$lib/stores'
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
@@ -69,4 +72,4 @@
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
/>
{/if}
</div>
</div>
@@ -11,6 +11,7 @@ interface ChatSchema extends IDBSchema {
displayMessages: DisplayMessage[]
title: string
lastModified: number
sessionId?: string
}
}
}
@@ -26,14 +27,21 @@ export default class HistoryManager {
title: string
id: string
lastModified: number
sessionId?: string
}
> = $state({})
private currentChatId: string = $state(createLongHash())
// When set, this manager is bound to a session: only chats tagged with this id
// are surfaced and new chats are saved with this id. When undefined (singleton),
// session-tagged chats are excluded from history.
private sessionId: string | undefined = $state(undefined)
private pastChats = $derived(
Object.values(this.savedChats)
.filter((c) => c.id !== this.currentChatId)
.filter((c) => (this.sessionId ? c.sessionId === this.sessionId : !c.sessionId))
.sort((a, b) => b.lastModified - a.lastModified)
)
@@ -69,10 +77,33 @@ export default class HistoryManager {
return this.currentChatId
}
setCurrentChatId(id: string) {
this.currentChatId = id
}
setSessionId(id: string | undefined) {
this.sessionId = id
}
async tagChatWithSession(chatId: string, sessionId: string) {
const existing = this.savedChats[chatId]
if (!existing || existing.sessionId === sessionId) return
const snapshot = $state.snapshot(existing)
const updated = { ...snapshot, sessionId }
this.savedChats = { ...this.savedChats, [chatId]: updated }
if (this.indexDB) {
await this.indexDB.put('chats', updated)
}
}
getPastChats() {
return this.pastChats
}
getAllSavedChats() {
return Object.values(this.savedChats)
}
async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
if (displayMessages.length > 0) {
// we don't want to save the snapshot in the history
@@ -84,7 +115,8 @@ export default class HistoryManager {
})),
title: displayMessages[0].content.slice(0, 50),
id: this.currentChatId,
lastModified: Date.now()
lastModified: Date.now(),
...(this.sessionId ? { sessionId: this.sessionId } : {})
}
this.savedChats = {
...this.savedChats,
@@ -1,7 +1,10 @@
<script lang="ts">
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
import { getContext } from 'svelte'
import { AIChatManager, aiChatManager as singletonAiChatManager } from './AIChatManager.svelte'
const aiChatManager = getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager
import type { ToolDisplayMessage } from './shared'
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
@@ -44,13 +47,12 @@
{#if activeUserQuestion}
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
{:else}
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<div class="bg-surface border border-border-light rounded-md overflow-hidden font-mono text-xs">
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
'w-full p-2 bg-surface-secondary/30 hover:bg-surface-hover transition-colors flex items-center justify-between text-left',
isExpanded ? 'border-b border-border-light' : '',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
@@ -99,7 +101,7 @@
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
hasParameters ? 'border-t border-border-light' : ''
)}
>
<Button
@@ -6,7 +6,9 @@
import type { FlowAIChatHelpers } from './core'
import { createInlineScriptSession } from './inlineScriptsUtils'
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
import { aiChatManager } from '../AIChatManager.svelte'
import { AIChatManager, aiChatManager as singletonAiChatManager } from '../AIChatManager.svelte'
const aiChatManager = getContext<AIChatManager>('aiChatManager') ?? singletonAiChatManager
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { FlowCopilotContext } from '../../flow'
import type { ScriptLintResult } from '../shared'
@@ -183,7 +183,7 @@ const setFlowJsonToolSchema = z.object({
.optional()
.nullable()
.describe(
'JSON string containing the optional array of semantic flow groups (summary, note, autocollapse, start_id, end_id, color). Pass null to clear groups.'
'JSON string containing the optional array of semantic flow groups. Each group has summary, note, autocollapse, start_id, end_id, color. color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes or other strings. Pass null to clear groups.'
)
})
@@ -947,7 +947,7 @@ Use the \`set_flow_json\` tool to set the entire flow structure at once. Provide
- \`schema\`: Flow input schema in JSON Schema format (optional)
- \`preprocessor_module\`: Special module that runs before \`modules\` (optional, separate from \`modules\`)
- \`failure_module\`: Special module that runs on failure (optional, separate from \`modules\`)
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
**Example - Simple flow:**
\`\`\`javascript
@@ -299,4 +299,23 @@ describe('validateFlowGroups', () => {
const result = validateFlowGroups([{ start_id: 'a', end_id: 'c', summary: 'G' }], moduleIds)
expect(result).toEqual([{ start_id: 'a', end_id: 'c', summary: 'G' }])
})
it('rejects an unknown color name', () => {
expect(() => validateFlowGroups([{ start_id: 'a', end_id: 'b', color: '#ff00aa' }])).toThrow(
/color must be one of/
)
expect(() => validateFlowGroups([{ start_id: 'a', end_id: 'b', color: 'magenta' }])).toThrow(
/color must be one of/
)
})
it('accepts a known color name', () => {
const result = validateFlowGroups([{ start_id: 'a', end_id: 'b', color: 'blue' }])
expect(result).toEqual([{ start_id: 'a', end_id: 'b', color: 'blue' }])
})
it('accepts a group with no color', () => {
const result = validateFlowGroups([{ start_id: 'a', end_id: 'b' }])
expect(result).toEqual([{ start_id: 'a', end_id: 'b' }])
})
})
@@ -1,8 +1,14 @@
import type { FlowModule, FlowValue, OpenFlow, RawScript } from '$lib/gen'
import { forEachFlowModule } from '$lib/components/flows/dfs'
import { findModuleInFlow } from '$lib/components/flows/flowTree'
import { NoteColor } from '$lib/components/graph/noteColors'
import type { InlineScriptSession } from './inlineScriptsUtils'
/** Allowed group color names matches the NoteColor palette the group
* editor uses. Other strings would render with default-blue styling at best
* and break the color picker UI at worst. */
const ALLOWED_GROUP_COLORS = new Set<string>(Object.values(NoteColor))
type FlowLike = Pick<OpenFlow, 'value'> & {
schema?: Record<string, any>
}
@@ -71,6 +77,13 @@ export function validateFlowGroups(
)
}
}
if (g.color !== undefined && g.color !== null) {
if (typeof g.color !== 'string' || !ALLOWED_GROUP_COLORS.has(g.color)) {
throw new Error(
`Invalid group at index ${index}: color must be one of ${[...ALLOWED_GROUP_COLORS].join(', ')}`
)
}
}
return g as unknown as FlowGroup
})
}
@@ -41,12 +41,7 @@ import {
validateEditableFlowJson
} from '../flow/editableFlowJson'
import { createInlineScriptSession } from '../flow/inlineScriptsUtils'
import {
getFlowPrompt,
getRawAppPrompt,
getResourcePrompt,
getScriptPrompt
} from '$system_prompts'
import { getFlowPrompt, getRawAppPrompt, getResourcePrompt, getScriptPrompt } from '$system_prompts'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
@@ -78,6 +73,8 @@ import {
type WorkspaceItemType
} from './draftStore.svelte'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import { userStore } from '$lib/stores'
import { get } from 'svelte/store'
const ITEM_TYPES = [
'script',
@@ -154,9 +151,7 @@ const readWorkspaceItemSchema = z.object({
})
const writeScriptSchema = z.object({
path: z
.string()
.describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'),
path: z.string().describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'),
summary: z.string().optional().describe('Short human-readable summary.'),
language: scriptLangSchema.describe('Script language.'),
content: z.string().describe('Full script source code.')
@@ -178,7 +173,7 @@ const setFlowModuleCodeSchema = z.object({
.describe(
'Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is "rawscript". Use patch_flow_json for structural changes.'
),
code: z.string().describe('New script source. Replaces the module\'s value.content entirely.')
code: z.string().describe("New script source. Replaces the module's value.content entirely.")
})
// Flow structure fields are taken as JSON strings rather than typed objects
@@ -187,9 +182,7 @@ const setFlowModuleCodeSchema = z.object({
// rejects those keywords ("Unknown name $ref/$defs"). Same trick as
// set_flow_json in chat/flow/core.ts.
const writeFlowSchema = z.object({
path: z
.string()
.describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
path: z.string().describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
summary: z.string().optional().describe('Short human-readable summary.'),
modules: z.string().describe('JSON string containing the complete flow modules array.'),
schema: z
@@ -436,7 +429,21 @@ const deleteAppRunnableSchema = z.object({
key: z.string().describe('Key of the backend runnable to remove.')
})
const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5', 'vue'] as const satisfies readonly FrameworkKey[]
const openPreviewSchema = z.object({
kind: z
.enum(['script', 'flow', 'app', 'raw_app'])
.describe(
'Item kind to preview. Use "raw_app" for code-based apps (created via init_app); use "app" for the legacy drag-and-drop app builder.'
),
path: z.string().describe('Workspace path of the item to preview.')
})
const FRAMEWORK_KEYS = [
'react19',
'react18',
'svelte5',
'vue'
] as const satisfies readonly FrameworkKey[]
const initAppSchema = z.object({
path: z
@@ -465,15 +472,27 @@ const initAppSchema = z.object({
.describe('Optional datatable configuration. Omit unless the user asked to wire one up.')
})
const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant.
const buildGlobalSystemPrompt = (
username: string
) => `You are Windmill's global workspace assistant.
The current user's workspace username is "${username}".
You can inspect workspace scripts, flows, schedules, triggers, resources, variables, and apps, then create draft changes in the frontend AI draft store.
Path conventions:
- Every workspace path has exactly three segments and starts with one of two namespaces:
- \`u/${username}/<name>\` — the current user's personal scope. Default for ad-hoc, exploratory, or scratch work.
- \`f/<folder>/<name>\` — a shared folder scope. The folder must already exist; bare \`f/<name>\` is INVALID and will fail.
- When the user gives a bare name without a namespace prefix (e.g. "create a flow called myflow"), default to \`u/${username}/<name>\`. Do NOT invent \`f/<name>\` — that is a structurally invalid path.
- If the request implies shared / team work but doesn't name a specific folder (e.g. "the marketing flow"), ask which folder to use rather than guessing. Call \`list_workspace_items\` with \`type: ['folder']\` (or rely on the user's hint) before assuming a folder exists.
- Only use an \`f/<folder>/<name>\` path when the user explicitly named the folder or you confirmed it exists.
Important rules:
- write_{script,flow,schedule,trigger,resource,variable} create or overwrite drafts. They do not save, deploy, or mutate workspace items.
- edit_script and patch_flow_json apply small exact-text edits and save the result as a draft. Prefer them for localized changes; use write_* for large rewrites.
- For flows specifically: read_workspace_item and patch_flow_json work on a COMPACT view where rawscript module bodies are replaced with the placeholder "inline_script.<moduleId>". Use read_flow_module_code / set_flow_module_code to inspect or overwrite an inline script body; use patch_flow_json for structural edits.
- deploy_workspace_item persists a draft to the workspace via the real backend create/update API and removes the draft. Requires user confirmation. Only call after the user has reviewed the draft and explicitly asked to deploy.
- edit_script applies small exact-text edits to a script and saves the result as a draft. Prefer it for localized script changes; use write_script for large rewrites.
- For flows: PREFER write_flow over patch_flow_json. write_flow takes the full structured flow (modules, schema, preprocessor_module, failure_module, groups) and replaces the draft atomically. patch_flow_json operates on a textual COMPACT JSON view (rawscript bodies are placeholders "inline_script.<moduleId>") and is harder to get right for non-trivial changes reserve it for narrow structural tweaks on very large flows where re-emitting the full flow would be wasteful. Almost everything users ask for in chat (renaming modules, adding/removing steps, retargeting branches, editing input_transforms, swapping the preprocessor) is faster and safer via write_flow. Use read_flow_module_code / set_flow_module_code to read or overwrite an inline rawscript body regardless of which structural tool you use.
- deploy_workspace_item persists a draft to the workspace via the real backend create/update API and removes the draft. Only call after the user has reviewed the draft and explicitly asked to deploy don't call it on your own initiative.
- delete_workspace_item permanently removes a workspace item (and any matching draft). Irreversible. Requires user confirmation. Only call when the user has explicitly asked to delete.
- Use list_workspace_items before broad reads.
- Use read_workspace_item before overwriting an existing item, unless the user already provided the complete current item. For triggers, pass trigger_kind.
@@ -488,6 +507,7 @@ Important rules:
- To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user do not silently default to react19, even though it is the recommended choice.
- Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts.
- Keep context targeted. Do not read unrelated items.
- After writing or substantially editing a script / flow / app draft inside an AI session, offer to open the preview via open_preview(kind, path) this lets the user see the editor and live preview right next to the chat. open_preview is a no-op outside of sessions and will return an error; don't call it from the regular global side-panel chat.
- Be explicit with the user when you create or update a draft.`
const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[]
@@ -658,7 +678,7 @@ function buildPersistedRunnable(
{ type: 'static', value: v, fieldType: 'object' }
])
)
: existing?.fields ?? {}
: (existing?.fields ?? {})
if (input.type === 'inline') {
if (!input.inlineScript) {
@@ -712,10 +732,12 @@ type AppMetadata = {
}
function summarizeAppValue(value: AppDraftValue): AppMetadata {
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(([path, content]) => ({
path,
size: typeof content === 'string' ? content.length : 0
}))
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(
([path, content]) => ({
path,
size: typeof content === 'string' ? content.length : 0
})
)
const backend: AppBackendRunnableMetadata[] = Object.entries(value.runnables).map(
([key, runnable]) => {
const converted = convertPersistedToBackendRunnable(runnable as PersistedRunnable, key)
@@ -863,11 +885,7 @@ function triggerToItem(
type TriggerService = {
exists(args: { workspace: string; path: string }): Promise<boolean>
get(args: { workspace: string; path: string }): Promise<TriggerLike>
list(args: {
workspace: string
pathStart?: string
perPage?: number
}): Promise<TriggerLike[]>
list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise<TriggerLike[]>
create(args: { workspace: string; requestBody: any }): Promise<string>
update(args: { workspace: string; path: string; requestBody: any }): Promise<string>
delete(args: { workspace: string; path: string }): Promise<string>
@@ -997,7 +1015,7 @@ async function readWorkspaceItem(
)
case 'resource':
return resourceToItem(
await ResourceService.getResource({ workspace, path }) as ListableResource,
(await ResourceService.getResource({ workspace, path })) as ListableResource,
true
)
case 'variable':
@@ -1117,7 +1135,7 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.
- A script draft is a workspace item: \`{ type: 'script', path, summary?, language, value, isDraft }\` where \`value\` is the source code string.
- Use workspace paths such as \`f/folder/name\` or \`u/username/name\`. Preserve the current path/language when modifying unless the user asked to change them.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.
- Use \`edit_script\` for small localized changes (provide \`old_string\`/\`new_string\`); use \`write_script\` for full rewrites.${note}
# Windmill script authoring reference (${selected})
@@ -1129,6 +1147,7 @@ function getFlowInstructions(): string {
return `# Global draft flow instructions
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
@@ -1156,7 +1175,7 @@ function getAppInstructions(): string {
return `# Global draft app instructions
- Global mode edits raw app drafts only; it does not save, deploy, or bundle.
- App drafts are addressed by workspace path (e.g. \`f/folder/my_app\`). The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, and a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`). Confirm framework + path + summary with the user before calling — do not silently default to \`react19\` even though it is the recommended choice. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
@@ -1343,12 +1362,7 @@ export const globalTools: Tool<{}>[] = [
toolCallbacks.setToolStatus(toolId, {
content: `Reading ${parsed.type} "${parsed.path}"...`
})
const item = await readWorkspaceItem(
parsed.type,
parsed.path,
workspace,
parsed.trigger_kind
)
const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind)
toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` })
return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2)
}
@@ -1492,13 +1506,11 @@ export const globalTools: Tool<{}>[] = [
def: createToolDef(
deployWorkspaceItemSchema,
'deploy_workspace_item',
'Persist an AI draft to the workspace by calling the real backend create/update API. This MUTATES the workspace. Requires user confirmation.',
'Persist an AI draft to the workspace by calling the real backend create/update API. This MUTATES the workspace. Only call after the user has reviewed the draft and explicitly asked to deploy.',
{ strict: false }
),
showDetails: true,
showFade: true,
requiresConfirmation: true,
confirmationMessage: 'Deploy AI draft to workspace',
fn: async (ctx) => {
const parsed = deployWorkspaceItemSchema.parse(ctx.args)
return deployDraft(parsed, ctx)
@@ -1710,6 +1722,17 @@ export const globalTools: Tool<{}>[] = [
const parsed = deleteAppRunnableSchema.parse(ctx.args)
return deleteAppRunnable(parsed, ctx)
}
},
{
def: createToolDef(
openPreviewSchema,
'open_preview',
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or app to let the user see and interact with it. The path you pass is the path of the item; for raw apps use kind="raw_app" instead of "app". Returns an error if there is no active session.'
),
fn: async (ctx) => {
const parsed = openPreviewSchema.parse(ctx.args)
return openSessionPreview(parsed)
}
}
]
@@ -1719,6 +1742,29 @@ type WriteDraftCtx = {
toolCallbacks: ToolCallbacks
}
// Sessions are the only context where `open_preview` makes sense — the global
// singleton chat in the right side panel has nowhere to mount an editor pane.
// The session runtime registers a handler at construction time so the tool
// has somewhere to dispatch. When no session is active the handler is
// undefined and the tool returns a polite error.
export type OpenPreviewHandler = (req: {
kind: 'script' | 'flow' | 'app' | 'raw_app'
path: string
}) => string
let openPreviewHandler: OpenPreviewHandler | undefined
export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined): void {
openPreviewHandler = handler
}
function openSessionPreview(args: { kind: 'script' | 'flow' | 'app' | 'raw_app'; path: string }) {
if (!openPreviewHandler) {
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
}
return openPreviewHandler(args)
}
async function loadScriptForEdit(
path: string,
workspace: string
@@ -2048,13 +2094,21 @@ async function patchAppFile(
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const { path, file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll } = args
const {
path,
file_path: filePath,
old_string: oldString,
new_string: newString,
replace_all: replaceAll
} = args
const target = resolveAppFileTarget(filePath)
if (target.kind === 'frontend') {
assertNotGeneratedAppFile(target.filePath)
}
toolCallbacks.setToolStatus(toolId, { content: `Patching ${target.filePath} in app "${path}"...` })
toolCallbacks.setToolStatus(toolId, {
content: `Patching ${target.filePath} in app "${path}"...`
})
const value = await loadAppDraftValue(path, workspace)
let currentContent: string
@@ -2088,7 +2142,8 @@ async function patchAppFile(
[target.key]: {
...runnable!,
inlineScript: {
language: runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'),
language:
runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'),
content: updated
}
}
@@ -2112,10 +2167,7 @@ async function patchAppFile(
}
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
value.policy = (await updateRawAppPolicy(
value.runnables as any,
value.policy as any
)) as any
value.policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as any
}
async function writeAppRunnable(
@@ -2196,10 +2248,7 @@ const triggerLabels: Record<TriggerKind, string> = {
azure: 'Azure Event Grid trigger'
}
function createOpenScheduleAction(
path: string,
targetKind: 'script' | 'flow'
): ToolDisplayAction {
function createOpenScheduleAction(path: string, targetKind: 'script' | 'flow'): ToolDisplayAction {
return {
id: `open-deployed-schedule:${path}`,
type: 'open_created_resource',
@@ -2470,7 +2519,8 @@ async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise<stri
export function prepareGlobalSystemMessage(
customPrompt?: string
): ChatCompletionSystemMessageParam {
let content = GLOBAL_SYSTEM_PROMPT
const username = get(userStore)?.username ?? ''
let content = buildGlobalSystemPrompt(username)
if (customPrompt?.trim()) {
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
}
@@ -13,7 +13,12 @@
import type { Flow, Job } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
import {
AIChatManager,
aiChatManager as singletonAiChatManager,
AIMode
} from '../copilot/chat/AIChatManager.svelte'
import { beforeNavigate } from '$app/navigation'
import type { GraphModuleState } from '../graph'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
@@ -22,6 +27,8 @@
import { extractAllModules } from '../copilot/chat/shared'
import type { Snippet } from 'svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
interface Props {
loading: boolean
@@ -131,15 +138,45 @@
aiChatManager.flowOptions = options
})
// Preserve the chat across two intra-flow-editor transitions:
// - /flows/add → /flows/edit/{path} (initial save promoting a draft)
// - /flows/edit/{path} → /flows/edit/{same path}?... (save-draft refresh,
// selected-step query change)
// Without this, FlowEditor's onDestroy + remount would saveAndClear the
// chat the user is still actively having about this same flow.
let preserveChatOnDestroy = $state(false)
beforeNavigate(({ to }) => {
const dest = to?.url.pathname ?? ''
if (!dest.startsWith('/flows/edit/')) return
const destPath = dest.slice('/flows/edit/'.length)
const currentFlowPath = aiChatManager.flowOptions?.path
// !currentFlowPath: we're on /flows/add and the destination is /flows/edit/{path}
// — initial save. destPath === currentFlowPath: same flow, e.g. selected=...
// query change after a save-draft.
if (!currentFlowPath || destPath === currentFlowPath) {
preserveChatOnDestroy = true
}
})
onMount(() => {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.FLOW)
if (!sessionScopedManager) {
// The previous instance's onDestroy may have preserved the chat for
// intra-flow-editor nav; in that case mode is still FLOW and
// displayMessages still hold the conversation. Skip saveAndClear so
// we don't blow it away.
if (aiChatManager.mode !== AIMode.FLOW) {
aiChatManager.saveAndClear()
}
aiChatManager.changeMode(AIMode.FLOW)
}
})
onDestroy(() => {
aiChatManager.flowOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
if (!sessionScopedManager && !preserveChatOnDestroy) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
}
})
</script>
@@ -200,6 +200,9 @@
markRemovedAsShadowed?: boolean
controlsPosition?: 'top' | 'bottom'
outerDivClass?: string
/** Fires when the computed graph height changes. Diff views can use
* this to equalize heights of side-by-side graphs. */
onHeight?: (height: number) => void
}
let {
@@ -273,7 +276,8 @@
onMoveMultiple = undefined,
movingIds = undefined,
controlsPosition = 'top',
outerDivClass = ''
outerDivClass = '',
onHeight = undefined
}: Props = $props()
// Initialize note manager with fine-grained reactivity
@@ -759,6 +763,7 @@
const computed = maxBottom - minY
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
}
onHeight?.(height)
}
$effect(() => {
@@ -63,6 +63,7 @@
}
| undefined
diffDrawer?: DiffDrawer | undefined
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/** Initial collapsed state for the file/runnable sidebar. The user's
* toggled preference is persisted under `sidebarStorageKey`; this prop
* only seeds the very first open. */
@@ -84,6 +85,7 @@
newPath = undefined,
savedApp = $bindable(undefined),
diffDrawer = undefined,
onNavigate,
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed'
}: Props = $props()
@@ -964,6 +966,7 @@
{data}
{runnables}
{getBundle}
{onNavigate}
canUndo={historyManager.canUndo}
canRedo={historyManager.canRedo}
onUndo={handleUndo}
@@ -2,7 +2,7 @@
import { Drawer, DrawerContent } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { isMac, userPathPrefix } from '$lib/utils'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
@@ -124,6 +124,7 @@
onOpenYamlEditor?: () => void
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
}
let {
@@ -147,7 +148,8 @@
onRedo = undefined,
onOpenYamlEditor = undefined,
sidebarCollapsed = false,
onToggleSidebar = undefined
onToggleSidebar = undefined,
onNavigate = undefined
}: Props = $props()
let newEditedPath = $state(
@@ -365,7 +367,6 @@
css
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: summary,
@@ -913,7 +914,7 @@
savedPath={appPath || newPath || undefined}
kind="app"
raw_app
onNavigate={(item) => goto(editPathFor(item))}
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div></div>
</div>
@@ -46,4 +46,7 @@ export interface ScriptBuilderProps {
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
onNavigate?: (item: WorkspaceItem) => void
// Forwarded to the underlying ScriptEditor. When true, the right-hand
// test/run pane opens collapsed. Used by the session preview.
initialTestPanelCollapsed?: boolean
}
@@ -0,0 +1,60 @@
<script lang="ts">
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
let {
runtime,
path,
workspaceId,
onNavigate
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadApp(workspaceId, path))
}
})
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadApp(workspaceId, path)
}
</script>
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
/>
{#if runtime.loadingApp && !runtime.loadedAppPath}
<div class="p-4 text-secondary text-sm">Loading app {path}</div>
{:else if runtime.notFoundApp && !runtime.loadedAppPath}
<div class="p-4 text-secondary text-sm">App not found at path {path}</div>
{:else if runtime.appStore.val}
<AppEditor
summary={runtime.appStore.val.summary ?? ''}
app={runtime.appStore.val.value}
newPath={runtime.appStore.val.path}
{path}
policy={runtime.appStore.val.policy}
bind:savedApp={runtime.savedApp.val}
version={runtime.appStore.val.versions
? runtime.appStore.val.versions[runtime.appStore.val.versions.length - 1]
: undefined}
newApp={false}
replaceStateFn={() => {}}
gotoFn={() => {}}
{diffDrawer}
{onNavigate}
/>
{/if}
@@ -0,0 +1,134 @@
<script lang="ts">
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import {
globalDraftStore,
type FlowDraftValue
} from '$lib/components/copilot/chat/global/draftStore.svelte'
import { initFlowState } from '$lib/components/flows/flowState'
import { applyDraftValueToFlow, flowToDraftValue } from './flowDraftCodec'
let {
runtime,
path,
workspaceId,
onNavigate
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
} = $props()
let selectedId = $state('settings-metadata')
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadFlow(workspaceId, path))
}
})
// In a session pane, "restore" just reloads from the current state — the
// session target stays put. The Diff drawer's primary use here is viewing
// the diff; restore is best-effort.
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadFlow(workspaceId, path)
}
// Bidirectional sync between this preview and the global AI chat's
// in-memory draft store. Same one-way-reactive discipline as the
// script case in ScriptEditorView.svelte — inbound tracks only the
// store, outbound tracks only `flowStore.val`; the "other side" read
// inside each effect goes through `untrack()`. Without that
// asymmetry, a user keystroke would re-fire the inbound effect with
// the pre-keystroke store value and revert the edit.
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs when globalDraftStore changes (AI write
// from this or another session). The flowStore read is untracked so
// the editor's own mutations don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = globalDraftStore.getFlowDraft(workspaceId, path)
if (!draft || !draft.value || typeof draft.value !== 'object' || !('value' in draft.value))
return
const incoming = draft.value as FlowDraftValue
const sig = JSON.stringify(incoming)
untrack(() => {
if (runtime.loadedPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.flowStore.val
if (!current) return
lastInboundSig = sig
runtime.flowStore.val = applyDraftValueToFlow(current, incoming)
// flowStateStore is keyed by module_id; after an AI write the set
// of module ids may differ, so rebuild the UI state. This wipes
// per-module test args / preview output for the new flow — a
// known v1 trade-off, see the plan's caveats.
void initFlowState(runtime.flowStore.val, runtime.flowStateStore)
})
})
// Editor → store. Re-runs on any deep mutation of flowStore.val
// (modules, schema, module bodies). The store read is untracked.
// Debounced 150ms so a typing burst inside an inline rawscript
// editor results in one serialise-and-write instead of one per
// keystroke.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedPath !== path) return
const flow = runtime.flowStore.val
if (!flow) return
const sig = JSON.stringify(flowToDraftValue(flow))
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = globalDraftStore.getFlowDraft(workspaceId, path)
if (current?.value && JSON.stringify(current.value) === sig) return
globalDraftStore.setDraft(workspaceId, {
type: 'flow',
path,
summary: flow.summary,
value: flowToDraftValue(flow),
isDraft: true
})
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedFlow.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
isFlow
/>
{/if}
{#if runtime.loadingFlow && !runtime.loadedPath}
<div class="p-4 text-secondary text-sm">Loading flow {path}</div>
{:else if runtime.notFound && !runtime.loadedPath}
<div class="p-4 text-secondary text-sm">Flow not found at path {path}</div>
{:else}
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={path}
newFlow={!runtime.savedFlow.val}
{selectedId}
loading={runtime.loadingFlow && !runtime.loadedPath}
bind:savedFlow={runtime.savedFlow.val}
{diffDrawer}
{onNavigate}
/>
{/if}
@@ -0,0 +1,736 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import {
AlertTriangle,
ArrowRight,
ChevronDown,
ChevronRight,
Folder,
GitFork,
GitMerge,
Loader2,
Minus,
Pencil,
Plus,
User
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, ExternalLink, SquareSplitHorizontal } from 'lucide-svelte'
import { WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff } from '$lib/gen'
import { getItemValue } from '$lib/utils_workspace_deploy'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
let {
forkWorkspaceId,
parentWorkspaceId
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
let drawer: Drawer | undefined = $state(undefined)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let loading = $state(false)
let error: string | undefined = $state(undefined)
let searchQuery = $state('')
let diffStyle = $state<'sbs' | 'inline'>('sbs')
const inlineDiff = $derived(diffStyle === 'inline')
const forkWs = $derived($userWorkspaces.find((w) => w.id === forkWorkspaceId))
const parentWs = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
export function open() {
drawer?.openDrawer()
void fetchComparison()
// Pull focus into the filter input so keyboard nav works without an
// extra click — drawer transition needs a tick first.
setTimeout(() => searchInputEl?.focus(), 50)
}
function openReview() {
goto(`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`)
}
async function fetchComparison() {
loading = true
error = undefined
try {
comparison = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId
})
// Diffs are expanded by default, so eagerly populate each row's
// content. Each loadDiffFor is idempotent and per-item, so
// rendering proceeds as values arrive.
if (comparison) {
for (const d of comparison.diffs) {
void loadDiffFor(d)
}
}
} catch (e) {
console.error('Fork diff: comparison failed', e)
error = `Failed to load comparison: ${e}`
comparison = undefined
} finally {
loading = false
}
}
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
function statusOf(d: WorkspaceItemDiff): DiffStatus {
if (d.exists_in_fork && !d.exists_in_source) return 'added'
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
if (d.ahead > 0 && d.behind > 0) return 'conflict'
return 'modified'
}
function itemKey(d: WorkspaceItemDiff): string {
return `${d.kind}/${d.path}`
}
// Editor URL for a diff row, scoped to the fork workspace.
function editUrlFor(d: WorkspaceItemDiff): string | undefined {
return buildEditUrl(d, forkWorkspaceId)
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
app: 'App',
raw_app: 'Raw app',
resource: 'Resource',
variable: 'Variable',
resource_type: 'Resource type',
folder: 'Folder',
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
// Lazily-loaded raw values per item, keyed by itemKey. Shaping (content
// vs metadata, YAML, lang detection) is owned by WorkspaceItemDiffViewer.
type LoadedDiff = {
state: 'loading' | 'ready' | 'error'
error?: string
parentRaw?: unknown
forkRaw?: unknown
}
let loadedDiffs: Record<string, LoadedDiff> = $state({})
// Per-item summary, derived from the fetched raw value so the tree on
// the left can show summary above the mono path (matches the picker).
let summaries: Record<string, string | undefined> = $state({})
async function loadDiffFor(d: WorkspaceItemDiff) {
const key = itemKey(d)
if (loadedDiffs[key]) return
loadedDiffs[key] = { state: 'loading' }
try {
// Source (parent) — empty for items only in fork. Fork — empty for
// items only in source. We swallow per-side errors so an "added"
// item still renders cleanly against an empty original.
const [parentRaw, forkRaw] = await Promise.all([
d.exists_in_source
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
d.exists_in_fork
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
// Prefer the fork's summary (the "current" side); fall back to parent.
const summary =
(forkRaw && typeof forkRaw === 'object' && (forkRaw as any).summary) ||
(parentRaw && typeof parentRaw === 'object' && (parentRaw as any).summary) ||
undefined
if (typeof summary === 'string' && summary.trim().length > 0) {
summaries[key] = summary
}
} catch (e) {
console.error('Fork diff: loadDiff failed', d, e)
loadedDiffs[key] = {
state: 'error',
error: String(e)
}
}
}
function onDetailsToggle(d: WorkspaceItemDiff, e: Event) {
const target = e.currentTarget as HTMLDetailsElement | null
if (target?.open) {
void loadDiffFor(d)
}
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
return 'blue'
}
const statusIcons = {
added: Plus,
removed: Minus,
modified: Pencil,
conflict: AlertTriangle
}
// File tree built from the diff paths. Top-level rows mirror
// WorkspaceItemDrillPicker: `f/foo` and `u/alice` collapse to a single
// "scope" row, then deeper segments split per `/`. Leaves carry their
// diff entry.
type FolderNode = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
children: TreeNode[]
}
type FileNode = { type: 'file'; name: string; diff: WorkspaceItemDiff }
type TreeNode = FolderNode | FileNode
function buildTree(diffs: WorkspaceItemDiff[]): FolderNode {
const root: FolderNode = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
children: []
}
const folderCache = new Map<string, FolderNode>()
for (const d of diffs) {
const parts = d.path.split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: d.path, diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
let scope = folderCache.get(scopeKey)
if (!scope) {
scope = {
type: 'folder',
name: scopeKey,
fullPath: scopeKey,
isScope: true,
children: []
}
folderCache.set(scopeKey, scope)
root.children.push(scope)
}
if (parts.length === 2) {
scope.children.push({ type: 'file', name: scopeKey, diff: d })
continue
}
const rest = parts.slice(2)
let parent = scope
let folderKey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
folderKey = `${folderKey}/${rest[i]}`
let folder = folderCache.get(folderKey)
if (!folder) {
folder = {
type: 'folder',
name: rest[i],
fullPath: folderKey,
isScope: false,
children: []
}
folderCache.set(folderKey, folder)
parent.children.push(folder)
}
parent = folder
}
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
}
const sortRec = (n: FolderNode) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return root
}
// Searchable string per diff: path + summary (when loaded) + kind label.
// SearchItems' uFuzzy runs fuzzy matching over these. Reads `summaries`
// directly so the index re-derives as summaries trickle in from
// loadDiffFor.
function searchableText(d: WorkspaceItemDiff): string {
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)]
if (s) parts.push(s)
return parts.join(' ')
}
let searchedDiffs: (WorkspaceItemDiff & { marked?: string })[] | undefined = $state(undefined)
// Empty query bypasses SearchItems entirely so we don't wait a tick for
// the async filter to run after open.
const filteredDiffs = $derived.by(() => {
const c = comparison
if (!c) return [] as WorkspaceItemDiff[]
const q = searchQuery.trim()
if (!q) return c.diffs
return (searchedDiffs ?? []) as WorkspaceItemDiff[]
})
const tree = $derived.by(() => {
const c = comparison
return c ? buildTree(filteredDiffs) : undefined
})
function rowId(d: WorkspaceItemDiff): string {
return `fork-diff-${itemKey(d)}`
}
function scrollToDiff(d: WorkspaceItemDiff) {
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
if (!el) return
el.open = true
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
// Per-folder open/closed state. Defaults to open; user toggles via the
// <details> summary or via Enter when a folder row is highlighted.
let folderOpen: Record<string, boolean> = $state({})
function isFolderOpen(key: string): boolean {
return folderOpen[key] ?? true
}
function folderKey(node: FolderNode): string {
return `folder:${node.fullPath}`
}
type NavEntry =
| { type: 'folder'; key: string; node: FolderNode }
| { type: 'file'; key: string; diff: WorkspaceItemDiff }
function flattenVisible(node: FolderNode): NavEntry[] {
const out: NavEntry[] = []
const walk = (n: TreeNode) => {
if (n.type === 'file') {
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
return
}
const fkey = folderKey(n)
out.push({ type: 'folder', key: fkey, node: n })
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
}
for (const c of node.children) walk(c)
return out
}
const navEntries = $derived(tree ? flattenVisible(tree) : [])
const navKeys = $derived(navEntries.map((e) => e.key))
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
let highlightedKey: string | undefined = $state(undefined)
let mouseActive = $state(false)
let searchInputEl: HTMLInputElement | undefined = $state()
let sidebarRoot: HTMLElement | undefined = $state()
$effect(() => {
if (navKeys.length === 0) return
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
highlightedKey = navKeys[0]
}
})
function scrollHighlightIntoView() {
if (!sidebarRoot || !highlightedKey) return
const el = sidebarRoot.querySelector<HTMLElement>(
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
function moveHighlight(delta: 1 | -1) {
if (navKeys.length === 0) return
const cur = navKeys.indexOf(highlightedKey ?? '')
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
highlightedKey = navKeys[next]
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function setHoverHighlight(key: string) {
// Same defense as the picker: ignore until the user actually moves the
// mouse, so a cursor parked over a row doesn't clobber the keyboard
// highlight when the layout shifts.
if (mouseActive) highlightedKey = key
}
function activateHighlighted() {
if (!highlightedKey) return
const entry = entryByKey.get(highlightedKey)
if (!entry) return
if (entry.type === 'file') {
scrollToDiff(entry.diff)
} else {
folderOpen[entry.key] = !isFolderOpen(entry.key)
}
}
// The folder containing an entry, as a folder key (or undefined if the
// entry is at the top scope and has no parent folder).
function parentFolderKeyFor(entry: NavEntry): string | undefined {
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
const parts = path.split('/')
// `f/foo` (scope) has no parent.
if (entry.type === 'folder' && parts.length <= 2) return undefined
// File directly under a scope: parent is the scope (f/foo).
if (entry.type === 'file' && parts.length === 3) {
return `folder:${parts.slice(0, 2).join('/')}`
}
return `folder:${parts.slice(0, -1).join('/')}`
}
function firstChildKey(node: FolderNode): string | undefined {
const c = node.children[0]
if (!c) return undefined
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
}
function selectKey(key: string) {
highlightedKey = key
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
activateHighlighted()
} else if (e.key === 'ArrowRight') {
// On a closed folder: open it. On an open folder: jump to its first
// child (folder or file). On a file: no-op.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry || entry.type !== 'folder') return
if (!isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = true
return
}
const child = firstChildKey(entry.node)
if (child) {
e.preventDefault()
selectKey(child)
}
} else if (e.key === 'ArrowLeft') {
// On an open folder: collapse it. On a closed folder (or a file):
// jump to the parent folder.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry) return
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = false
return
}
const parent = parentFolderKeyFor(entry)
if (parent && entryByKey.has(parent)) {
e.preventDefault()
selectKey(parent)
}
}
}
</script>
<SearchItems
filter={searchQuery}
items={comparison?.diffs ?? []}
bind:filteredItems={searchedDiffs}
f={(d: WorkspaceItemDiff) => searchableText(d)}
/>
{#snippet renderTreeNode(node: TreeNode, depth: number)}
{#if node.type === 'folder'}
{@const isUserScope = node.isScope && node.name.startsWith('u/')}
{@const fkey = folderKey(node)}
{@const open = isFolderOpen(fkey)}
{@const isHl = fkey === highlightedKey}
<details
{open}
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
class="select-none"
>
<summary
role="option"
aria-selected={isHl}
data-nav-key={fkey}
onmouseenter={() => setHoverHighlight(fkey)}
class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl
? 'bg-surface-hover'
: ''}"
style="padding-left: {depth * 12 + 8}px"
>
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
{#if isUserScope}
<User size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="truncate" title={node.name}>{node.name}</span>
</summary>
<div>
{#each node.children as child}
{@render renderTreeNode(child, depth + 1)}
{/each}
</div>
</details>
{:else}
{@const status = statusOf(node.diff)}
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind}
summary={summaries[key]}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={node.diff.path}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
}}
onmouseenter={() => setHoverHighlight(key)}
>
{#snippet extras()}
<span
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
? 'bg-green-500'
: status === 'removed'
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
{/if}
{/snippet}
<Drawer bind:this={drawer} size="1200px">
<DrawerContent
title="Fork changes"
on:close={() => drawer?.closeDrawer()}
documentationLink={undefined}
noPadding
overflow_y={false}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<GitFork class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{forkWs?.name ?? forkWorkspaceId}</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate">{parentWs?.name ?? parentWorkspaceId}</span>
{#if comparison}
<Badge color="transparent" class="ml-2">
{comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''}
</Badge>
{#if comparison.summary.conflicts > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
{/if}
</div>
{/snippet}
{#snippet actions()}
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
{#snippet children({ item })}
<ToggleButton
value="sbs"
label="Side-by-side"
icon={SquareSplitHorizontal}
tooltip="Side-by-side diff"
iconOnly
{item}
/>
<ToggleButton
value="inline"
label="Unified"
icon={DiffIcon}
tooltip="Unified diff"
iconOnly
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: GitMerge }}
on:click={openReview}
>
Review
</Button>
{/snippet}
<div class="flex flex-row h-full min-h-0">
{#if comparison && comparison.diffs.length > 0}
<aside
bind:this={sidebarRoot}
onmousemove={() => (mouseActive = true)}
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
>
<div class="px-3 pt-3 pb-2 shrink-0">
<input
bind:this={searchInputEl}
type="search"
bind:value={searchQuery}
placeholder="Filter files..."
onkeydown={handleSearchKeydown}
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
{#if tree && tree.children.length > 0}
{#each tree.children as child}
{@render renderTreeNode(child, 0)}
{/each}
{:else}
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
{/if}
</div>
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-y-auto">
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
{#if loading && !comparison}
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
<Loader2 class="w-4 h-4 animate-spin" />
Loading comparison...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
{:else if comparison?.skipped_comparison}
<div class="text-sm text-secondary py-4">
This fork was created before change tracking was added — diffs are not available.
</div>
{:else if comparison && comparison.diffs.length === 0}
<div class="text-sm text-secondary py-4"
>No changes between this fork and its parent.</div
>
{:else if comparison && filteredDiffs.length === 0}
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
{:else if comparison}
<div class="flex flex-col gap-2">
{#each filteredDiffs as d (itemKey(d))}
{@const key = itemKey(d)}
{@const status = statusOf(d)}
{@const StatusIcon = statusIcons[status]}
{@const loaded = loadedDiffs[key]}
{@const editUrl = editUrlFor(d)}
<details
open
id={rowId(d)}
class="border border-light rounded-md bg-surface scroll-mt-2"
ontoggle={(e) => onDetailsToggle(d, e)}
>
<summary
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
>
<ChevronDown
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
/>
<RowIcon kind={d.kind} size={14} />
<div class="min-w-0 flex-1">
{#if editUrl}
<a
href={editUrl}
target="_blank"
rel="noopener noreferrer"
title={d.path}
onclick={(e) => e.stopPropagation()}
class="group inline-flex items-center gap-1 max-w-full text-xs text-primary font-mono truncate hover:underline"
>
<span class="truncate">{d.path}</span>
<ExternalLink
class="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity"
/>
</a>
{:else}
<div class="text-xs text-primary font-mono truncate" title={d.path}>
{d.path}
</div>
{/if}
</div>
<div class="shrink-0 flex items-center gap-2">
{#if d.ahead > 0}
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
{/if}
{#if d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
</Badge>
</div>
</summary>
<div
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
>
{#if !loaded || loaded.state === 'loading'}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Loading diff…
</div>
{:else if loaded.state === 'error'}
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
{:else if loaded.state === 'ready'}
<WorkspaceItemDiffViewer
kind={d.kind}
originalRaw={loaded.parentRaw}
currentRaw={loaded.forkRaw}
{inlineDiff}
/>
{/if}
</div>
</details>
{/each}
</div>
{/if}
</div></main
></div
>
</DrawerContent>
</Drawer>
<style>
/* Diff rows use a ChevronDown; rotate it back when collapsed. */
details:not([open]) :global(.chevron) {
transform: rotate(-90deg);
}
/* Tree folder rows: swap chevrons based on the folder's open state. */
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
display: none;
}
details[open] > .tree-summary :global(.tree-chevron-closed) {
display: none;
}
</style>
@@ -0,0 +1,125 @@
<script lang="ts">
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import {
globalDraftStore,
type AppDraftValue
} from '$lib/components/copilot/chat/global/draftStore.svelte'
import { applyDraftValueToRawApp, rawAppToDraftValue } from './appDraftCodec'
let {
runtime,
path,
workspaceId,
onNavigate
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadRawApp(workspaceId, path))
}
})
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadRawApp(workspaceId, path)
}
// Bidirectional sync with the global AI chat's globalDraftStore.
// Same one-way-reactive discipline as ScriptEditorView / FlowEditorView:
// inbound tracks only the store, outbound tracks only rawApp.val; each
// side's read into the other goes through untrack() to break the
// keystroke-revert race.
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs on globalDraftStore changes (AI write).
$effect(() => {
if (!workspaceId || !path) return
const draft = globalDraftStore.getAppDraft(workspaceId, path)
if (
!draft ||
!draft.value ||
typeof draft.value !== 'object' ||
!('files' in (draft.value as object))
)
return
const incoming = draft.value as AppDraftValue
const sig = JSON.stringify(incoming)
untrack(() => {
if (runtime.loadedRawAppPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.rawApp.val
if (!current) return
lastInboundSig = sig
runtime.rawApp.val = applyDraftValueToRawApp(current, incoming)
})
})
// Editor → store. Debounced 150ms so a typing burst inside a frontend
// file's Monaco editor coalesces into one store write.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedRawAppPath !== path) return
const raw = runtime.rawApp.val
if (!raw) return
const sig = JSON.stringify(rawAppToDraftValue(raw))
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = globalDraftStore.getAppDraft(workspaceId, path)
if (current?.value && JSON.stringify(current.value) === sig) return
globalDraftStore.setDraft(workspaceId, {
type: 'app',
path,
summary: raw.summary,
value: rawAppToDraftValue(raw),
isDraft: true
})
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedRawApp.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
/>
{/if}
{#if runtime.loadingRawApp && !runtime.loadedRawAppPath}
<div class="p-4 text-secondary text-sm">Loading raw app {path}</div>
{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath}
<div class="p-4 text-secondary text-sm">Raw app not found at path {path}</div>
{:else if runtime.rawApp.val}
<RawAppEditor
bind:files={runtime.rawApp.val.files}
bind:runnables={runtime.rawApp.val.runnables}
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
newPath={runtime.rawApp.val.path}
{path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val}
{diffDrawer}
{onNavigate}
defaultSidebarCollapsed
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
/>
{/if}
@@ -0,0 +1,120 @@
<script lang="ts">
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { globalDraftStore } from '$lib/components/copilot/chat/global/draftStore.svelte'
let {
runtime,
path,
workspaceId,
onNavigate,
initialTestPanelCollapsed = false
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
initialTestPanelCollapsed?: boolean
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadScript(workspaceId, path))
}
})
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadScript(workspaceId, path)
}
// Bidirectional sync between this preview and the global AI chat's
// in-memory draft store. The store is workspace-scoped and keyed by
// (type, path), so any session looking at the same script — and the
// AI's read_workspace_item / write_script / edit_script tools — all
// converge on the same content.
//
// CRITICAL: each effect must be one-way reactive. The inbound tracks
// ONLY the store (and unwraps its mutation through `script.content`
// via untrack), and the outbound tracks ONLY `script.content` (and
// unwraps the store via untrack). Tracking both sides in either effect
// creates a race: a user keystroke updates `script.content` first,
// inbound re-fires while the store still holds the pre-keystroke
// value, and writes the stale store value back into the editor —
// visibly "resetting" the user's typing.
let lastInboundContent: string | undefined = $state(undefined)
// Store → editor. Re-runs when globalDraftStore changes (AI write,
// other session edit, etc.). The script.content read is untracked
// so user keystrokes don't trigger this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = globalDraftStore.getScriptDraft(workspaceId, path)
if (!draft || typeof draft.value !== 'string') return
const incoming = draft.value
untrack(() => {
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
if (incoming === script.content) return
lastInboundContent = incoming
script.content = incoming
if (draft.language) script.language = draft.language
if (draft.summary !== undefined) script.summary = draft.summary
})
})
// Editor → store. Re-runs when script.content changes (user typing,
// inbound mutation). The store read is untracked so writing to it
// here doesn't ping-pong the inbound effect.
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
const content = script.content
if (content === lastInboundContent) return
untrack(() => {
const current = globalDraftStore.getScriptDraft(workspaceId, path)
if (typeof current?.value === 'string' && current.value === content) return
globalDraftStore.setDraft(workspaceId, {
type: 'script',
path,
language: script.language,
summary: script.summary,
value: content,
isDraft: true
})
})
})
</script>
{#if runtime.savedScript.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
/>
{/if}
{#if runtime.loadingScript && !runtime.loadedScriptPath}
<div class="p-4 text-secondary text-sm">Loading script {path}</div>
{:else if runtime.notFoundScript && !runtime.loadedScriptPath}
<div class="p-4 text-secondary text-sm">Script not found at path {path}</div>
{:else if runtime.scriptStore.val}
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={path}
fullyLoaded={!runtime.loadingScript}
disableHistoryChange={true}
replaceStateFn={() => {}}
{diffDrawer}
{onNavigate}
{initialTestPanelCollapsed}
/>
{/if}
@@ -0,0 +1,213 @@
<script lang="ts">
import {
Archive,
ArrowRight,
GitCompareArrows,
GitFork,
GitMerge,
GitPullRequestArrow,
GitPullRequestClosed,
MoveRight,
Trash2
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { goto } from '$lib/navigation'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { isCloudHosted } from '$lib/cloud'
import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import ForkDiffDrawer from './ForkDiffDrawer.svelte'
let {
session,
onMove,
onCreateForkAndMove,
onArchive,
onDelete
}: {
session: Session
onMove?: (workspaceId: string) => void
onCreateForkAndMove?: (fork: {
parent_workspace_id: string
id: string
name: string
}) => void | Promise<void>
onArchive?: () => void
onDelete?: () => void
} = $props()
// The fork bar surfaces a committed workspace relationship — only
// visible after the session locked its workspace at first send. Drafts
// (workspace_id undefined) get nothing here.
const committedId = $derived(session.workspace_id)
const sessionWorkspace = $derived(
committedId ? $userWorkspaces.find((w) => w.id === committedId) : undefined
)
const parentWorkspaceId = $derived(sessionWorkspace?.parent_workspace_id ?? undefined)
const parentWorkspace = $derived(
parentWorkspaceId ? $userWorkspaces.find((w) => w.id === parentWorkspaceId) : undefined
)
const isFork = $derived(!!parentWorkspaceId)
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
// When forking isn't available the diff/review surface is moot.
const forksAllowed = $derived(
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
)
let diffDrawer: ForkDiffDrawer | undefined = $state(undefined)
// Comparison data lives on the shared SessionRuntime resource so any
// future consumer (e.g. the diff drawer, a merge action) reads the
// same cache and can invalidate it after mutating the fork.
const runtime = $derived(getRuntime(session.id))
const comparison = $derived(runtime?.forkComparison.val)
const totalDiffs = $derived(comparison?.summary?.total_diffs ?? 0)
const forkStatus = $derived(deriveForkStatus(session, $userWorkspaces, comparison))
const isUnavailable = $derived(forkStatus === 'unavailable')
$effect(() => {
if (!runtime || !committedId || !parentWorkspaceId) return
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
})
function refreshComparison() {
if (!runtime || !committedId || !parentWorkspaceId) return
runtime.invalidateForkComparison()
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
}
// Refresh when the AI finishes a turn (loading transitions true →
// false). Tool calls in that turn may have created / edited / deleted
// fork items, so the diff count needs to reflect them immediately.
let wasLoading = $state(false)
$effect(() => {
const isLoading = runtime?.manager.loading ?? false
if (wasLoading && !isLoading) refreshComparison()
wasLoading = isLoading
})
// Refresh when the tab regains visibility — covers edits made in
// another tab or by another user while we were away.
$effect(() => {
if (!runtime || !committedId || !parentWorkspaceId) return
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
if (sessionState.currentSessionId !== session.id) return
refreshComparison()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
})
export const refresh = refreshComparison
function openReview() {
if (!committedId || isUnavailable) return
goto(`/forks/compare?workspace_id=${encodeURIComponent(committedId)}`)
}
</script>
{#if committedId && isUnavailable}
<!-- Fork workspace is no longer in the user's list (deleted, archived,
or access revoked). Surface an actionable banner: move the session
to a still-valid workspace, or discard it (archive / delete). The
chat input is disabled by SessionWrapper while this is shown. -->
<div class="flex flex-col gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary">
<div class="flex flex-row items-start gap-2">
<GitPullRequestClosed class="w-4 h-4 shrink-0 text-tertiary mt-0.5" />
<div class="flex flex-col min-w-0 flex-1">
<span class="text-primary font-medium">The fork has been archived or deleted</span>
<span class="text-2xs text-tertiary">
Move this session to another workspace, or discard it.
<span class="font-mono text-tertiary" title={committedId}>{committedId}</span>
</span>
</div>
</div>
<div class="flex flex-row items-center justify-end gap-1.5">
<WorkspaceFamilyPicker
onPick={(workspaceId) => onMove?.(workspaceId)}
onCreateFork={async (fork) => {
await onCreateForkAndMove?.(fork)
}}
createForkCaption="Created immediately and the session moved into it."
>
{#snippet trigger()}
<Button variant="default" unifiedSize="sm" startIcon={{ icon: MoveRight }}>
Move to workspace
</Button>
{/snippet}
</WorkspaceFamilyPicker>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Archive }}
dropdownItems={[{ label: 'Delete', icon: Trash2, onClick: () => onDelete?.() }]}
on:click={() => onArchive?.()}
>
Archive
</Button>
</div>
</div>
{:else if forksAllowed && isFork && sessionWorkspace && parentWorkspace && parentWorkspaceId && committedId}
{@const StatusIcon =
forkStatus === 'ahead'
? GitPullRequestArrow
: forkStatus === 'diverged'
? GitCompareArrows
: GitFork}
{@const statusColor =
forkStatus === 'ahead'
? 'text-blue-500'
: forkStatus === 'diverged'
? 'text-amber-500'
: 'text-secondary'}
{@const statusTitle =
forkStatus === 'ahead'
? 'Ahead of parent'
: forkStatus === 'diverged'
? 'Diverged from parent'
: forkStatus === 'in_sync'
? 'In sync with parent'
: 'Fork'}
<div
class="flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary"
>
<div class="flex items-center gap-1.5 min-w-0">
<span title={statusTitle} class="inline-flex shrink-0">
<StatusIcon class="w-3.5 h-3.5 {statusColor}" />
</span>
<span class="truncate text-secondary" title={sessionWorkspace.name}>
{sessionWorkspace.name}
</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="truncate text-secondary" title={parentWorkspace.name}>
{parentWorkspace.name}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: GitCompareArrows }}
disabled={totalDiffs === 0}
title="{totalDiffs} modified item{totalDiffs === 1 ? '' : 's'}"
on:click={() => diffDrawer?.open()}
>
{totalDiffs}
</Button>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: GitMerge }}
on:click={openReview}
>
Review
</Button>
</div>
</div>
<ForkDiffDrawer bind:this={diffDrawer} forkWorkspaceId={committedId} {parentWorkspaceId} />
{/if}
@@ -0,0 +1,587 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import {
Archive,
ArchiveRestore,
ChevronDown,
ChevronRight,
EllipsisVertical,
Filter,
MessageSquare,
Pencil,
PencilLine,
Plus,
Trash2
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { goto } from '$lib/navigation'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import {
createSession,
deriveForkStatus,
getEffectiveWorkspaceId,
isForkSession,
renameSession,
selectSession,
sessionState,
setSessionArchived,
syncWorkspaceTo,
type Session
} from './sessionState.svelte'
import { forgetSessionSeen, unreadCountFor } from './sessionUnread.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
getOrCreateRuntime,
getRuntime,
getSessionChatStatus,
removeSession
} from './sessionRuntime.svelte'
import SessionStatusDot from './SessionStatusDot.svelte'
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { visibleWorkspaceIds } from './sessionScope.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
// Look up the cached fork comparison for a session through its runtime
// (if any). The deriveForkStatus helper handles the "no runtime yet"
// and "comparison not loaded" cases by returning undefined; we render
// a neutral fork icon in that interim, then upgrade to the proper
// status icon once the comparison lands.
function forkStatusFor(session: Session) {
return deriveForkStatus(session, $userWorkspaces, getRuntime(session.id)?.forkComparison.val)
}
function isForkFor(session: Session): boolean {
return isForkSession(session, $userWorkspaces)
}
// Compute the unread count for a session. Driven by the per-runtime
// displayMessages array vs. the localStorage-backed lastSeen map;
// both are reactive so the badge updates without polling.
function unreadFor(session: Session): number {
return unreadCountFor(session.id, getRuntime(session.id))
}
// Whether the composer for a session holds non-whitespace text. We
// read manager.instructions directly (not the derived chat status)
// so the draft cue still shows during streaming/needs-confirmation —
// those override the icon slot but shouldn't hide the fact that the
// user has unsent text in this session.
function hasDraft(session: Session): boolean {
const rt = getRuntime(session.id)
return !!rt && rt.manager.instructions.trim().length > 0
}
// Sessions piggyback on the same dev gate as the global AI chat — when
// the feature flag is off, the sidebar section is hidden entirely.
const globalEnabled = isGlobalAiEnabled()
interface Props {
isCollapsed?: boolean
}
let { isCollapsed = false }: Props = $props()
const sectionCollapsed = useLocalStorageValue(
'windmill_sessions_section_collapsed',
false,
'boolean'
)
const showArchived = useLocalStorageValue('windmill_sessions_show_archived', false, 'boolean')
let listRoot: HTMLDivElement | undefined = $state()
// Sessions visible in the current workspace (active workspace + its
// forks). Drafts (no committed workspace) are scoped by their
// pending workspace pick — set at create time to the workspace the
// user was in. Archived sessions are filtered out unless the user
// has opted in via the filter popover.
const visibleSessions = $derived(
sessionState.sessions.filter((s) => {
// Transient (not-yet-sent) sessions live as their own page but
// don't clutter the sidebar list.
if (s.transient) return false
if (s.archived && !showArchived.val) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
// Unavailable sessions (committed workspace was deleted /
// archived / access revoked) stay visible everywhere so the
// user can resolve them — move, archive, or delete. They'd
// otherwise be permanently hidden the moment their workspace
// disappeared.
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
const archivedCount = $derived(
sessionState.sessions.filter((s) => {
if (!s.archived || s.transient) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
}).length
)
// Sum of unread across every visible session — surfaced on the
// collapsed-sidebar chat icon so the user sees there's pending
// AI activity in some session without expanding the sidebar.
const totalUnread = $derived(visibleSessions.reduce((acc, s) => acc + unreadFor(s), 0))
// Eagerly create a runtime per VISIBLE session so the status dot reflects
// the persisted chat (last message, pending confirmation, etc.) without
// requiring the user to open the session first. Sessions outside the
// current workspace scope are left cold to avoid opening IDB connections
// for unrelated work.
$effect(() => {
for (const session of visibleSessions) {
getOrCreateRuntime(session)
}
})
// Pre-fetch the fork comparison for every visible fork session so the
// sidebar icons reflect the right ahead/diverged state without
// requiring the user to click into each session. Cheap enough at
// typical session counts; falls back to a plain dot until the
// fetch lands.
$effect(() => {
if (sectionCollapsed.val) return
for (const session of visibleSessions) {
if (!session.workspace_id) continue
const ws = $userWorkspaces.find((w) => w.id === session.workspace_id)
if (!ws?.parent_workspace_id) continue
const rt = getRuntime(session.id)
if (!rt) continue
void rt.ensureForkComparison(ws.parent_workspace_id, session.workspace_id)
}
})
function isUnavailableFork(session: Session): boolean {
return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id)
}
async function activate(session: Session, restoreFocus: boolean = false) {
selectSession(session.id)
// If the session has a committed workspace different from the
// active one, switch globally so the editor/forks resolve correctly.
// Skip for unavailable forks — switching to a deleted workspace
// would error out and leave the user in limbo.
if (!isUnavailableFork(session)) {
syncWorkspaceTo(session.workspace_id)
}
// Refresh the fork diff count — users typically click back into a
// session after editing items elsewhere in the SPA, where neither
// the visibility-change nor the AI-loading signal would fire.
void getRuntime(session.id)?.refreshForkComparison()
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
if (restoreFocus) {
// goto() resets focus to <body> — put it back on the active session button
// so subsequent arrow keys keep navigating the list.
requestAnimationFrame(() => {
const selected = listRoot?.querySelector<HTMLButtonElement>(
'button[data-session-button][aria-selected="true"]'
)
selected?.focus()
})
}
}
async function createAndOpen() {
await activate(createSession())
}
let editingId: string | undefined = $state(undefined)
let renameDraft = $state('')
function startRename(session: Session) {
editingId = session.id
renameDraft = session.summary ?? ''
}
function commitRename() {
const id = editingId
if (!id) return
renameSession(id, renameDraft)
editingId = undefined
}
function cancelRename() {
editingId = undefined
}
let pendingDelete: Session | undefined = $state(undefined)
let deleteAlsoFork = $state(false)
// Fork workspace tied to `pendingDelete`, if any, and still accessible.
const pendingDeleteForkId = $derived.by(() => {
const wsId = pendingDelete?.workspace_id
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
const ws = $userWorkspaces.find((w) => w.id === wsId)
if (!ws || !ws.parent_workspace_id) return undefined
return wsId
})
async function handleConfirmedDelete() {
const session = pendingDelete
const forkToDelete = deleteAlsoFork ? pendingDeleteForkId : undefined
pendingDelete = undefined
deleteAlsoFork = false
if (!session) return
const wasActive = sessionState.currentSessionId === session.id
removeSession(session.id)
forgetSessionSeen(session.id)
if (forkToDelete) {
try {
await WorkspaceService.deleteWorkspace({ workspace: forkToDelete })
sendUserToast(`Deleted forked workspace ${forkToDelete}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e: any) {
sendUserToast(`Failed to delete fork ${forkToDelete}: ${e?.body ?? e}`, true)
}
}
if (wasActive) {
const next = sessionState.sessions[0]
if (next) await activate(next)
else await goto('/sessions')
}
}
function focusAt(index: number) {
const buttons = listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
: []
if (buttons.length === 0) return
const wrapped = ((index % buttons.length) + buttons.length) % buttons.length
buttons[wrapped]?.focus()
}
function handleListKeydown(e: KeyboardEvent) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') {
return
}
const buttons = listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
: []
if (buttons.length === 0) return
const current = buttons.indexOf(document.activeElement as HTMLButtonElement)
e.preventDefault()
if (e.key === 'ArrowDown') focusAt(current < 0 ? 0 : current + 1)
else if (e.key === 'ArrowUp') focusAt(current < 0 ? buttons.length - 1 : current - 1)
else if (e.key === 'Home') focusAt(0)
else if (e.key === 'End') focusAt(buttons.length - 1)
}
const menuItemBase = twMerge(
'text-secondary text-left font-normal text-xs',
'flex flex-row items-center gap-2 px-3 py-1.5 w-full',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
</script>
{#if !globalEnabled}
<!-- Sessions hidden until the global-ai dev gate is enabled. -->
{:else if isCollapsed}
<div class="px-2 pt-3 pb-2 border-b border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<div class="relative">
<MenuButton
class="!text-xs"
icon={MessageSquare}
label="AI sessions"
{isCollapsed}
{trigger}
/>
{#if totalUnread > 0}
<span
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-blue-500"
aria-label="{totalUnread} unread message{totalUnread === 1
? ''
: 's'} across all sessions"
></span>
{/if}
</div>
{/snippet}
{#snippet children({ item })}
<div class="divide-y min-w-48" role="none">
<div class="py-1" role="none">
<MenuItem class={menuItemBase} onClick={createAndOpen} {item}>
<Plus size={14} />
New session
</MenuItem>
</div>
<div class="py-1" role="none">
{#each visibleSessions as session (session.id)}
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected = session.id === sessionState.currentSessionId}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<MenuItem
class={twMerge(menuItemBase, isSelected ? 'bg-surface-hover' : '')}
onClick={() => activate(session)}
{item}
>
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<span
class={twMerge(
'truncate flex-1 text-left',
unread > 0 ? 'font-semibold text-primary' : ''
)}
>
{session.summary ?? 'Untitled session'}
</span>
{#if draft || unread > 0}
<span class="ml-auto shrink-0 inline-flex items-center gap-1">
{#if draft}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
</span>
{/if}
</span>
{/if}
</MenuItem>
{/each}
</div>
</div>
{/snippet}
</Menu>
{/snippet}
</Menubar>
</div>
{:else}
<div class="px-2 pt-3 pb-2 flex flex-col gap-1 border-b border-light dark:border-gray-700">
<div class="flex flex-row items-center justify-between pl-1 pr-0.5">
<button
type="button"
onclick={() => (sectionCollapsed.val = !sectionCollapsed.val)}
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
aria-expanded={!sectionCollapsed.val}
>
AI sessions
{#if sectionCollapsed.val}
<ChevronRight size={10} />
{:else}
<ChevronDown size={10} />
{/if}
</button>
<div class="flex flex-row items-center gap-0.5">
<Popover placement="bottom-end" usePointerDownOutside disableFocusTrap class="inline-flex">
{#snippet trigger()}
<button
type="button"
title="Filter sessions"
aria-label="Filter sessions"
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary {showArchived.val
? 'text-emphasis'
: ''}"
>
<Filter size={12} />
</button>
{/snippet}
{#snippet content()}
<div
class="w-56 p-2 bg-surface-tertiary dark:border rounded-md shadow-lg flex flex-col gap-1"
>
<Toggle
bind:checked={showArchived.val}
size="xs"
options={{ right: 'Show archived' }}
/>
{#if archivedCount > 0}
<span class="text-2xs text-tertiary pl-1">
{archivedCount} archived session{archivedCount === 1 ? '' : 's'}
</span>
{/if}
</div>
{/snippet}
</Popover>
<Button
variant="subtle"
size="xs2"
iconOnly
startIcon={{ icon: Plus }}
on:click={createAndOpen}
title="New session"
/>
</div>
</div>
{#if !sectionCollapsed.val}
<div
bind:this={listRoot}
transition:slide={{ duration: 180 }}
class="flex flex-col gap-0.5 max-h-[40vh] overflow-y-auto"
onkeydown={handleListKeydown}
role="listbox"
tabindex="-1"
>
{#each visibleSessions as session (session.id)}
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected = session.id === sessionState.currentSessionId}
{@const isEditing = editingId === session.id}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<div
class={twMerge(
'flex flex-row items-center group rounded',
isSelected ? 'bg-surface-hover text-primary' : 'hover:bg-surface-hover',
session.archived ? 'italic opacity-60' : ''
)}
>
{#if isEditing}
<span class="flex flex-row items-center gap-2 flex-1 px-2 py-1 min-w-0">
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={renameDraft}
onkeydown={(e) => {
if (e.key === 'Enter') commitRename()
else if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
placeholder="Untitled session"
autofocus
spellcheck="false"
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs font-normal text-primary"
/>
</span>
{:else}
<button
type="button"
data-session-button
role="option"
aria-selected={isSelected}
onclick={() => activate(session)}
class={twMerge(
'flex flex-row items-center gap-2 text-left text-xs font-normal focus:outline-none flex-1 min-w-0 px-2 py-1',
unread > 0 ? 'text-primary font-semibold' : 'text-secondary'
)}
>
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<span class="truncate flex-1">{session.summary ?? 'Untitled session'}</span>
{#if draft || unread > 0}
<span class="shrink-0 inline-flex items-center gap-1">
{#if draft}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
</span>
{/if}
</span>
{/if}
</button>
<div
class="opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity pr-0.5"
>
<DropdownV2
fixedHeight={false}
placement="bottom-end"
items={[
{
displayName: 'Rename',
icon: Pencil,
action: () => startRename(session)
},
session.archived
? {
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
: {
displayName: 'Archive',
icon: Archive,
action: () => setSessionArchived(session.id, true)
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (pendingDelete = session)
}
]}
>
{#snippet buttonReplacement()}
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
title="More"
>
<EllipsisVertical size={14} />
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<ConfirmationModal
open={!!pendingDelete}
title="Delete session"
confirmationText="Delete"
onConfirmed={handleConfirmedDelete}
onCanceled={() => {
pendingDelete = undefined
deleteAlsoFork = false
}}
>
<div class="flex flex-col gap-3">
<p>
Delete session <span class="font-medium text-primary"
>{pendingDelete?.summary ?? pendingDelete?.name}</span
>? This cannot be undone.
</p>
{#if pendingDeleteForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={deleteAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also delete forked workspace <span class="font-mono">{pendingDeleteForkId}</span></span
>
<span class="text-3xs text-tertiary"
>The fork won't be reachable from any other session — leaving it would orphan it.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
@@ -0,0 +1,98 @@
<script lang="ts">
import {
AlertCircle,
AlertTriangle,
Building,
GitCompareArrows,
GitFork,
GitPullRequestArrow,
GitPullRequestClosed
} from 'lucide-svelte'
import type { SessionChatStatus } from './sessionRuntime.svelte'
import type { ForkStatus } from './sessionState.svelte'
let {
status,
isFork,
forkStatus
}: { status: SessionChatStatus; isFork: boolean; forkStatus?: ForkStatus } = $props()
const statusTooltip: Record<SessionChatStatus, string> = {
idle: 'No chat activity',
streaming: 'Generating response…',
'awaiting-user': 'Waiting for your reply',
'needs-confirmation': 'Needs your confirmation',
draft: 'Unsent draft',
error: 'Last message had an error'
}
const forkTooltip: Record<ForkStatus, string> = {
in_sync: 'Fork — in sync with parent',
ahead: 'Fork — ahead of parent',
diverged: 'Fork — diverged from parent',
unavailable: 'Fork — no longer available'
}
// Live chat signals take precedence over the persistent kind/fork
// indicator: streaming, needs-confirmation, and error are time-critical
// and warrant briefly hijacking the icon slot.
const liveOverride = $derived(
status === 'streaming' || status === 'needs-confirmation' || status === 'error'
)
const persistentTitle = $derived(
isFork ? (forkStatus ? forkTooltip[forkStatus] : 'Fork session') : 'Root workspace session'
)
const title = $derived(liveOverride ? statusTooltip[status] : persistentTitle)
</script>
<span class="inline-flex items-center justify-center w-4 h-3 shrink-0" {title}>
{#if status === 'streaming'}
<span class="inline-flex items-end gap-0.5">
<span class="w-1 h-1 rounded-full bg-blue-500 typing-dot"></span>
<span class="w-1 h-1 rounded-full bg-blue-500 typing-dot dot-2"></span>
<span class="w-1 h-1 rounded-full bg-blue-500 typing-dot dot-3"></span>
</span>
{:else if status === 'needs-confirmation'}
<AlertCircle class="w-3 h-3 text-amber-500" />
{:else if status === 'error'}
<AlertTriangle class="w-3 h-3 text-red-500" />
{:else if isFork}
{#if forkStatus === 'ahead'}
<GitPullRequestArrow class="w-3 h-3 text-blue-500" />
{:else if forkStatus === 'diverged'}
<GitCompareArrows class="w-3 h-3 text-amber-500" />
{:else if forkStatus === 'unavailable'}
<GitPullRequestClosed class="w-3 h-3 text-red-500" />
{:else}
<GitFork class="w-3 h-3 text-tertiary" />
{/if}
{:else}
<Building class="w-3 h-3 text-tertiary" />
{/if}
</span>
<style>
.typing-dot {
animation: typing 1.2s ease-in-out infinite;
}
.dot-2 {
animation-delay: 0.15s;
}
.dot-3 {
animation-delay: 0.3s;
}
@keyframes typing {
0%,
60%,
100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-1px);
}
}
</style>
@@ -0,0 +1,75 @@
<script lang="ts">
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
import {
getEffectiveWorkspaceId,
setSessionPendingFork,
setSessionPendingWorkspace,
syncWorkspaceTo,
type Session
} from './sessionState.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { Building, ChevronDown, GitFork } from 'lucide-svelte'
let { session }: { session: Session } = $props()
function findRoot(id: string | undefined, all: UserWorkspace[]): UserWorkspace | undefined {
if (!id) return undefined
let current = all.find((w) => w.id === id)
while (current?.parent_workspace_id) {
const parent = all.find((w) => w.id === current!.parent_workspace_id)
if (!parent) break
current = parent
}
return current
}
// Effective workspace for display: committed → pending pick → active store.
const effectiveId = $derived(getEffectiveWorkspaceId(session) ?? $workspaceStore ?? undefined)
const root = $derived(findRoot(effectiveId, $userWorkspaces))
const currentWs = $derived(
effectiveId ? $userWorkspaces.find((w) => w.id === effectiveId) : undefined
)
const pendingFork = $derived(session.pending_fork)
function pick(id: string) {
// Pre-send only: writes the pending pick. workspace_id stays
// undefined until the user sends their first message.
setSessionPendingWorkspace(session.id, id)
syncWorkspaceTo(id)
}
function stageFork(req: { parent_workspace_id: string; id: string; name: string }) {
setSessionPendingFork(session.id, req)
syncWorkspaceTo(req.parent_workspace_id)
}
</script>
<div class="flex flex-row items-center gap-1 py-0.5 px-1 text-2xs text-secondary">
<span class="shrink-0">Run in</span>
<WorkspaceFamilyPicker
selectedId={effectiveId}
{pendingFork}
onPick={pick}
onCreateFork={stageFork}
createForkCaption="Created when you send your first message."
>
{#snippet trigger()}
<span
class="inline-flex flex-row items-center gap-1 px-1.5 py-0.5 rounded hover:bg-surface-hover text-2xs"
>
{#if pendingFork || (currentWs && currentWs.id !== root?.id)}
<GitFork class="w-3 h-3 shrink-0" />
{:else}
<Building class="w-3 h-3 shrink-0" />
{/if}
<span class="font-medium text-primary truncate max-w-[180px]">
{pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'}
</span>
{#if pendingFork}
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
{/if}
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary" />
</span>
{/snippet}
</WorkspaceFamilyPicker>
</div>
@@ -0,0 +1,507 @@
<script lang="ts">
import { setContext } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Toggle from '$lib/components/Toggle.svelte'
import { copilotInfo, loadCopilot } from '$lib/aiStore'
import {
Archive,
ArchiveRestore,
EllipsisVertical,
PanelRightClose,
PanelRightOpen,
Pencil,
Trash2
} from 'lucide-svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import WorkspaceItemDrillPicker from '$lib/components/WorkspaceItemDrillPicker.svelte'
import FlowEditorView from './FlowEditorView.svelte'
import ScriptEditorView from './ScriptEditorView.svelte'
import AppEditorView from './AppEditorView.svelte'
import RawAppEditorView from './RawAppEditorView.svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import SessionForkBar from './SessionForkBar.svelte'
import {
commitSessionWorkspace,
createSession,
getEffectiveWorkspaceId,
moveSessionToNewFork,
moveSessionToWorkspace,
persistSessions,
selectSession,
sessionState,
setSessionArchived,
setSessionTarget,
type SessionTarget
} from './sessionState.svelte'
import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import { goto } from '$lib/navigation'
import { slide } from 'svelte/transition'
let { sessionId }: { sessionId: string } = $props()
// LRU-warm sessions get their editor pane mounted; others render
// chat-only. Reading from the reactive Set keeps SessionWrapper in
// sync with promoteEditorWarm without an explicit prop round-trip
// through the page route.
const mountEditor = $derived(editorWarmIds.has(sessionId))
// Parent keys by sessionId; this wrapper only mounts when the session exists.
// Captured at script-init so we can synchronously bind context.
const initialSession = sessionState.sessions.find((s) => s.id === sessionId)
const runtime = initialSession ? getOrCreateRuntime(initialSession) : undefined
if (runtime) {
setContext<AIChatManager>('aiChatManager', runtime.manager)
}
// Reactive session reference (mutations to summary/target propagate via the $state proxy)
const session = $derived(sessionState.sessions.find((s) => s.id === sessionId))
$effect(() => {
if ($workspaceStore) {
loadCopilot($workspaceStore)
}
})
let summaryInput: EditableInput | undefined = $state(undefined)
// Drop the user on a fresh new-session page. Used after archiving or
// deleting the open session: the session they were on is no longer
// usable, and routing to a sibling would feel arbitrary.
async function resetToNewSession() {
const fresh = createSession()
selectSession(fresh.id)
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
}
// If the session targets a forked workspace that's still accessible,
// offer to delete / archive the fork alongside the session — otherwise
// the fork lingers as an orphan whose only purpose was this session.
const sessionForkId = $derived.by(() => {
const wsId = session?.workspace_id
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
const ws = $userWorkspaces.find((w) => w.id === wsId)
// Don't offer the option if the fork is gone or not user-accessible.
if (!ws || !ws.parent_workspace_id) return undefined
return wsId
})
let deleteConfirmOpen = $state(false)
let deleteAlsoFork = $state(false)
let archiveConfirmOpen = $state(false)
let archiveAlsoFork = $state(false)
async function refreshWorkspaceList() {
// Match the SidebarContent.deleteFork pattern: replace the in-memory
// list rather than nulling it. See B1 fix.
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
console.error('Failed to refresh workspaces', e)
}
}
async function handleConfirmedDelete() {
deleteConfirmOpen = false
if (!session) return
const forkToDelete = deleteAlsoFork ? sessionForkId : undefined
deleteAlsoFork = false
removeSession(session.id)
if (forkToDelete) {
try {
await WorkspaceService.deleteWorkspace({ workspace: forkToDelete })
sendUserToast(`Deleted forked workspace ${forkToDelete}`)
await refreshWorkspaceList()
} catch (e: any) {
sendUserToast(`Failed to delete fork ${forkToDelete}: ${e?.body ?? e}`, true)
}
}
await resetToNewSession()
}
async function handleConfirmedArchive() {
archiveConfirmOpen = false
if (!session) return
const forkToArchive = archiveAlsoFork ? sessionForkId : undefined
archiveAlsoFork = false
setSessionArchived(session.id, true)
if (forkToArchive) {
try {
await WorkspaceService.archiveWorkspace({ workspace: forkToArchive })
sendUserToast(`Archived forked workspace ${forkToArchive}`)
await refreshWorkspaceList()
} catch (e: any) {
sendUserToast(`Failed to archive fork ${forkToArchive}: ${e?.body ?? e}`, true)
}
}
await resetToNewSession()
}
// Kept for the "Archive" entry that doesn't go through the confirmation
// modal — when the session isn't in a fork, no extra question to ask.
async function archiveAndReset() {
if (!session) return
// If the session is in a fork, route through the confirm modal so the
// user can opt into archiving the fork. Otherwise skip the modal.
if (sessionForkId) {
archiveAlsoFork = false
archiveConfirmOpen = true
return
}
setSessionArchived(session.id, true)
await resetToNewSession()
}
// Workspace bar is shown only before the session sends its first user
// message — after that the session's workspace is immutable.
const hasFirstUserMessage = $derived(
runtime?.manager.displayMessages.some((m) => m.role === 'user') ?? false
)
// Commit pending workspace pick (or current active workspace as
// fallback) into `workspace_id` exactly once, when the first user
// message lands. This is the only path that defines workspace_id.
// When a pending fork is staged, this is also where the fork is
// materialised via the API — no orphan forks for abandoned drafts.
let committing = $state(false)
$effect(() => {
if (!session || !hasFirstUserMessage || session.workspace_id || committing) return
committing = true
commitSessionWorkspace(session.id, $workspaceStore ?? undefined)
.catch((e) => console.error('Failed to commit session workspace', e))
.finally(() => {
committing = false
})
})
// Effective workspace for routing editor views — committed if set,
// otherwise the pending pick, otherwise the current active workspace.
const effectiveWorkspaceId = $derived(
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
)
// Core mutation: assign a target via the canonical setter, then re-open
// the editor pane. Shared by every code path that swaps the session's
// editor target (drill picker, fork-bar dropdown, …).
function applyEditorTarget(target: SessionTarget, summary?: string) {
if (!session) return
setSessionTarget(session.id, target, summary)
// Picking a target also re-opens the editor pane (the user just chose
// what to view).
editorVisible = true
}
function pickEditorTarget(item: WorkspaceItem) {
// WorkspaceItem.kind is 'flow'|'script'|'app'; raw apps are flagged
// via item.raw_app. The diff-API uses 'raw_app' as its kind so we
// align SessionTarget on the same canonical string.
const kind: SessionTarget['kind'] = item.kind === 'app' && item.raw_app ? 'raw_app' : item.kind
applyEditorTarget({ kind, path: item.path }, item.summary)
}
// Editor pane visibility. Toggling this just hides/shows the pane via CSS
// — the editor stays mounted, so re-opening doesn't pay a remount cost
// and xy-flow / Monaco keep their viewport state.
let editorVisible = $state(true)
// Focus the chat input whenever this session is the active one.
// The textarea is disabled until copilotInfo loads (otherwise focus is
// a silent no-op), so we wait for that too. Triggers on initial mount,
// warm-session switch via the picker, and the moment copilot finishes
// loading.
let aiChat: AIChat | undefined = $state(undefined)
$effect(() => {
if (sessionState.currentSessionId !== sessionId) return
if (!aiChat) return
if (!$copilotInfo.enabled) return
const chat = aiChat
setTimeout(() => chat.focusInput(), 0)
})
// True when the session committed to a workspace that's no longer in
// the user's list (deleted / archived / access revoked). The chat is
// disabled and SessionForkBar shows a move/discard banner.
const isUnavailable = $derived(
!!session?.workspace_id && !$userWorkspaces.find((w) => w.id === session!.workspace_id)
)
async function moveAndActivate(targetWorkspaceId: string) {
if (!session) return
moveSessionToWorkspace(session.id, targetWorkspaceId)
}
async function createForkAndMove(fork: {
parent_workspace_id: string
id: string
name: string
}) {
if (!session) return
await moveSessionToNewFork(session.id, fork)
}
</script>
{#if !session || !runtime}
<div class="p-8 text-secondary text-sm">Session not found</div>
{:else}
{@const hasTarget =
session.target?.kind === 'flow' ||
session.target?.kind === 'script' ||
session.target?.kind === 'app' ||
session.target?.kind === 'raw_app'}
{@const hasEditor = mountEditor && hasTarget && editorVisible}
{#snippet inputPreface()}
{#if !hasFirstUserMessage}
<SessionWorkspaceBar {session} />
{/if}
<SessionForkBar
{session}
onMove={(workspaceId) => moveAndActivate(workspaceId)}
onCreateForkAndMove={(fork) => createForkAndMove(fork)}
onArchive={() => archiveAndReset()}
onDelete={() => (deleteConfirmOpen = true)}
/>
{/snippet}
<!-- Override the chat's default keyboard-shortcut hint with nothing —
sessions have their own empty-state affordances above. -->
{#snippet sessionEmptyHint()}{/snippet}
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane size={hasEditor ? 50 : 100} minSize={25} class="flex flex-col min-h-0 pb-2">
<header class="flex flex-row items-center gap-1 pl-4 pr-4 py-2 shrink-0">
<EditableInput
bind:this={summaryInput}
value={session.summary ?? ''}
placeholder="Untitled session"
onSave={(v) => {
session.summary = v
persistSessions()
}}
class="text-sm font-semibold"
inputClass="!text-sm !font-semibold"
/>
<DropdownV2
fixedHeight={false}
placement="bottom-start"
items={[
{
displayName: 'Rename',
icon: Pencil,
action: () => summaryInput?.edit()
},
session.archived
? {
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
: {
displayName: 'Archive',
icon: Archive,
action: () => archiveAndReset()
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (deleteConfirmOpen = true)
}
]}
>
{#snippet buttonReplacement()}
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
title="More"
>
<EllipsisVertical size={14} />
</span>
{/snippet}
</DropdownV2>
{#if !session.target && hasFirstUserMessage}
<!-- Drill-picker for sessions that have started but haven't
picked an editor target yet. Hidden on fresh sessions
(no messages yet) — the workspace bar is the only
header affordance during the empty state. -->
<div class="ml-auto">
<Popover
placement="bottom-end"
usePointerDownOutside
disableFocusTrap
class="inline-flex"
>
{#snippet trigger()}
<Button variant="default" unifiedSize="xs" startIcon={{ icon: PanelRightOpen }}>
Open editor
</Button>
{/snippet}
{#snippet content()}
<WorkspaceItemDrillPicker
onPick={(item: WorkspaceItem) => pickEditorTarget(item)}
/>
{/snippet}
</Popover>
</div>
{:else if hasTarget && mountEditor && !editorVisible}
<div class="ml-auto">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: PanelRightOpen }}
on:click={() => (editorVisible = true)}
>
Show editor
</Button>
</div>
{:else if hasEditor}
<div class="ml-auto flex flex-row items-center gap-1">
<button
type="button"
onclick={() => (editorVisible = false)}
title="Close editor"
aria-label="Close editor"
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
>
<PanelRightClose size={14} />
</button>
</div>
{/if}
</header>
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
<AIChat
bind:this={aiChat}
hideHeader
hideModeSelector
wideLayout
forceDisabled={isUnavailable}
forceDisabledMessage={isUnavailable
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
: ''}
emptyHint={sessionEmptyHint}
{inputPreface}
/>
</div>
</Pane>
{#if hasEditor && session.target}
<Pane size={50} minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
<div
transition:slide={{ axis: 'x', duration: 200 }}
class="flex flex-col flex-1 min-h-0 rounded-md border border-light overflow-hidden relative"
>
{#if session.target.kind === 'flow'}
<FlowEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
/>
{:else if session.target.kind === 'script'}
<ScriptEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
initialTestPanelCollapsed
/>
{:else if session.target.kind === 'app'}
<AppEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
/>
{:else if session.target.kind === 'raw_app'}
<RawAppEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
/>
{/if}
</div>
</Pane>
{/if}
</Splitpanes>
<ConfirmationModal
open={deleteConfirmOpen}
title="Delete session"
confirmationText="Delete"
onConfirmed={handleConfirmedDelete}
onCanceled={() => {
deleteConfirmOpen = false
deleteAlsoFork = false
}}
>
<div class="flex flex-col gap-3">
<p>
Delete session <span class="font-medium text-primary"
>{session?.summary ?? session?.name}</span
>? This cannot be undone.
</p>
{#if sessionForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={deleteAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also delete forked workspace <span class="font-mono">{sessionForkId}</span></span
>
<span class="text-3xs text-tertiary"
>The fork won't be reachable from any other session — leaving it would orphan it.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
<ConfirmationModal
open={archiveConfirmOpen}
title="Archive session"
confirmationText="Archive"
onConfirmed={handleConfirmedArchive}
onCanceled={() => {
archiveConfirmOpen = false
archiveAlsoFork = false
}}
>
<div class="flex flex-col gap-3">
<p>
Archive session <span class="font-medium text-primary"
>{session?.summary ?? session?.name}</span
>? You can restore it later from the archived list.
</p>
{#if sessionForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={archiveAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also archive forked workspace <span class="font-mono">{sessionForkId}</span></span
>
<span class="text-3xs text-tertiary"
>Archived workspaces can be unarchived later from instance settings.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
{/if}
<style>
:global(.splitter-hidden .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
opacity: 0 !important;
}
</style>
@@ -0,0 +1,312 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte'
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { isCloudHosted } from '$lib/cloud'
import { random_adj } from '$lib/components/random_positive_adjetive'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import InputError from '$lib/components/InputError.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Building, Check, GitFork, Plus } from 'lucide-svelte'
type PendingFork = { id: string; name: string }
type ForkRequest = { parent_workspace_id: string; id: string; name: string }
let {
// Workspace currently associated with the consumer (for display only —
// drives the family-root resolution and the "selected" highlight).
// Defaults to the active workspace store when not set.
selectedId,
// A staged-but-not-yet-created fork (e.g. SessionWorkspaceBar's
// pre-send draft). Highlighted as the active row when set.
pendingFork,
onPick,
onCreateFork,
allowCreateFork = true,
// Optional caption rendered under the new-fork input. Lets the
// consumer differentiate "staged for first send" vs. "will be
// created immediately" semantics.
createForkCaption = '',
trigger
}: {
selectedId?: string
pendingFork?: PendingFork
onPick: (workspaceId: string) => void | Promise<void>
onCreateFork?: (fork: ForkRequest) => void | Promise<void>
allowCreateFork?: boolean
createForkCaption?: string
trigger: Snippet<[{ open: boolean }]>
} = $props()
const WM_FORK_PREFIX = 'wm-fork-'
function findRoot(id: string | undefined, all: UserWorkspace[]): UserWorkspace | undefined {
if (!id) return undefined
let current = all.find((w) => w.id === id)
while (current?.parent_workspace_id) {
const parent = all.find((w) => w.id === current!.parent_workspace_id)
if (!parent) break
current = parent
}
return current
}
const effectiveId = $derived(selectedId ?? $workspaceStore ?? undefined)
const root = $derived(findRoot(effectiveId, $userWorkspaces))
const forks = $derived(root ? findWorkspaceDescendants(root.id, $userWorkspaces) : [])
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
const forksGateOpen = $derived(
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
)
const showCreateFork = $derived(allowCreateFork && forksGateOpen && !!onCreateFork && !!root)
let dropdownOpen = $state(false)
let creatingFork = $state(false)
let newForkName = $state('')
let forkInput: TextInput | undefined = $state(undefined)
// Manual keyboard navigation, modelled after SelectDropdown. melt's
// menu API couples Enter/Space to closing the menu, which we explicitly
// don't want for the "Create new fork" row — it swaps to inline input.
type NavRow = { kind: 'create' } | { kind: 'root'; id: string } | { kind: 'fork'; id: string }
const navRows = $derived<NavRow[]>([
...(showCreateFork ? [{ kind: 'create' as const }] : []),
...(root ? [{ kind: 'root' as const, id: root.id }] : []),
...forks.map((f) => ({ kind: 'fork' as const, id: f.id }))
])
let keyArrowPos = $state<number | undefined>(undefined)
$effect(() => {
if (!dropdownOpen) keyArrowPos = undefined
})
function activateRow(row: NavRow) {
if (row.kind === 'create') {
void enterCreateMode()
} else if (row.kind === 'root' || row.kind === 'fork') {
void pick(row.id)
}
}
function defaultForkName(): string {
const taken = new Set($userWorkspaces.map((w) => w.id))
if (pendingFork) taken.add(pendingFork.id)
for (let i = 0; i < 50; i++) {
const name = `${random_adj()}-fork`
if (!taken.has(`${WM_FORK_PREFIX}${name}`)) return name
}
const base = `${random_adj()}-fork`
let n = 1
while (taken.has(`${WM_FORK_PREFIX}${base}-${n}`)) n++
return `${base}-${n}`
}
async function pick(id: string) {
dropdownOpen = false
creatingFork = false
await onPick(id)
}
async function enterCreateMode(initialName?: string) {
creatingFork = true
newForkName = initialName ?? defaultForkName()
await tick()
forkInput?.focus()
forkInput?.select()
}
function cancelCreate() {
creatingFork = false
newForkName = ''
}
function slugForkBaseId(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
const forkNameError = $derived.by<string | undefined>(() => {
const trimmed = newForkName.trim()
if (!trimmed) return undefined
const baseId = slugForkBaseId(trimmed)
if (!baseId) return 'Name must contain at least one letter or number'
const prefixed = `${WM_FORK_PREFIX}${baseId}`
const taken = new Set($userWorkspaces.map((w) => w.id))
if (pendingFork) taken.delete(pendingFork.id)
if (taken.has(prefixed)) return 'A workspace with this name already exists'
return undefined
})
async function stageNewFork() {
const name = newForkName.trim()
if (!root || !name || forkNameError || !onCreateFork) return
const baseId = slugForkBaseId(name)
if (!baseId) return
const prefixed = `${WM_FORK_PREFIX}${baseId}`
// Close optimistically; consumer can re-open + toast on error.
creatingFork = false
newForkName = ''
dropdownOpen = false
await onCreateFork({ parent_workspace_id: root.id, id: prefixed, name })
}
function isSelected(id: string): boolean {
if (pendingFork?.id === id) return true
return !pendingFork && effectiveId === id
}
// Reopening the dropdown while a pending fork is staged drops the user
// directly into edit mode so they can refine the name. Avoids re-
// entering edit mode after an explicit cancel.
let lastDropdownOpen = $state(false)
$effect(() => {
const wasOpen = lastDropdownOpen
lastDropdownOpen = dropdownOpen
if (dropdownOpen && !wasOpen && pendingFork && !creatingFork && showCreateFork) {
void enterCreateMode(pendingFork.name)
}
})
</script>
<svelte:window
onkeydown={(e) => {
if (!dropdownOpen) return
if (creatingFork) return
if (navRows.length === 0) return
if (e.key === 'ArrowDown') {
keyArrowPos = keyArrowPos === undefined ? 0 : Math.min(navRows.length - 1, keyArrowPos + 1)
e.preventDefault()
} else if (e.key === 'ArrowUp') {
keyArrowPos = keyArrowPos === undefined ? navRows.length - 1 : Math.max(0, keyArrowPos - 1)
e.preventDefault()
} else if (e.key === 'Enter' && keyArrowPos !== undefined) {
activateRow(navRows[keyArrowPos])
e.preventDefault()
} else if (e.key === 'Escape') {
dropdownOpen = false
e.preventDefault()
}
}}
/>
<DropdownV2
bind:open={dropdownOpen}
customMenu
placement="bottom-start"
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
{@render trigger({ open: dropdownOpen })}
{/snippet}
{#snippet menu()}
{@const rowBase =
'px-3 py-1.5 text-xs text-primary flex flex-row gap-2 items-center text-left rounded-sm w-full'}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-left rounded-lg shadow-lg focus:outline-none py-1 flex flex-col max-h-80 overflow-y-auto"
>
{#if showCreateFork}
{#if creatingFork}
<div class="flex flex-col gap-1 px-2 py-1.5">
<div class="flex flex-row items-center gap-1.5">
<Plus size={14} class="shrink-0 text-tertiary" />
<!-- svelte-ignore a11y_autofocus -->
<TextInput
bind:this={forkInput}
bind:value={newForkName}
size="xs"
error={forkNameError}
class="flex-1 min-w-0"
inputProps={{
placeholder: 'Fork name',
autofocus: true,
'aria-invalid': forkNameError ? 'true' : undefined,
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
void stageNewFork()
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
cancelCreate()
}
}
}}
/>
<button
type="button"
aria-label="Confirm"
title="Stage fork"
onclick={() => void stageNewFork()}
disabled={!newForkName.trim() || !!forkNameError}
class="inline-flex items-center justify-center w-5 h-5 rounded text-accent hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed"
>
<Check size={14} />
</button>
</div>
{#if forkNameError || createForkCaption}
<div class="pl-6">
<InputError error={forkNameError} />
{#if !forkNameError && createForkCaption}
<span class="text-2xs text-tertiary">{createForkCaption}</span>
{/if}
</div>
{/if}
</div>
{:else}
{@const createIdx = 0}
<button
type="button"
class={`${rowBase} ${keyArrowPos === createIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
onmouseenter={() => (keyArrowPos = createIdx)}
onclick={() => enterCreateMode()}
>
<Plus size={14} class="shrink-0 text-tertiary" />
<span>Create new fork…</span>
</button>
{/if}
<div class="my-1 border-t border-border-light shrink-0"></div>
{/if}
{#if root}
{@const rootIdx = showCreateFork ? 1 : 0}
<button
type="button"
class={`${rowBase} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${keyArrowPos === rootIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
onmouseenter={() => (keyArrowPos = rootIdx)}
onclick={() => void pick(root.id)}
>
<Building size={14} class="shrink-0 text-tertiary" />
<span class="truncate">{root.name}</span>
<span class="text-2xs text-tertiary shrink-0 ml-auto">root</span>
</button>
{/if}
{#each forks as f, fi (f.id)}
{@const forkIdx = (showCreateFork ? 1 : 0) + (root ? 1 : 0) + fi}
<button
type="button"
class={`${rowBase} ${isSelected(f.id) ? 'bg-surface-selected' : ''} ${keyArrowPos === forkIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
onmouseenter={() => (keyArrowPos = forkIdx)}
onclick={() => void pick(f.id)}
>
<GitFork size={14} class="shrink-0 text-tertiary" />
<span class="truncate">{f.name}</span>
</button>
{/each}
{#if pendingFork && !creatingFork}
<div
class="px-3 py-1.5 text-xs text-primary flex flex-row gap-2 items-center text-left rounded-sm bg-surface-selected cursor-default"
>
<GitFork size={14} class="shrink-0 text-tertiary" />
<span class="truncate">{pendingFork.name}</span>
<span class="text-2xs text-tertiary italic shrink-0 ml-auto">New</span>
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
@@ -0,0 +1,41 @@
import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils'
import type { AppDraftValue } from '$lib/components/copilot/chat/global/draftStore.svelte'
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
// sessionRuntime.svelte.ts lines 74-84). Slightly flatter than the AI's
// `AppDraftValue`: `path` is metadata not present on the AI side, and
// `summary` is required here.
export type RuntimeRawApp = {
summary: string
path: string
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
}
// Strip runtime metadata (just `path` for raw apps) and project into the
// AI-facing `AppDraftValue` envelope.
export function rawAppToDraftValue(raw: RuntimeRawApp): AppDraftValue {
return {
summary: raw.summary,
files: raw.files,
runnables: raw.runnables,
data: raw.data,
policy: raw.policy
// custom_path is read-only and not held on the runtime.
}
}
// Overlay an AI-produced draft onto an existing runtime raw app,
// preserving metadata fields (path) that don't live in `AppDraftValue`.
export function applyDraftValueToRawApp(raw: RuntimeRawApp, dv: AppDraftValue): RuntimeRawApp {
return {
...raw,
summary: dv.summary ?? raw.summary,
files: dv.files,
runnables: dv.runnables,
data: (dv.data as RawAppData | undefined) ?? raw.data,
policy: dv.policy ?? raw.policy
}
}
@@ -0,0 +1,28 @@
import type { Flow } from '$lib/gen'
import type { FlowDraftValue } from '$lib/components/copilot/chat/global/draftStore.svelte'
// Convert the editor's full `Flow` (carrying metadata like path, edited_by,
// edited_at, archived, etc.) into the slimmer `FlowDraftValue` shape the
// global AI chat's draft store uses. Metadata stays on the runtime side —
// the draft store only holds what the AI's tools need to round-trip the
// in-flight edit.
export function flowToDraftValue(flow: Flow): FlowDraftValue {
return {
value: flow.value,
schema: flow.schema ?? null,
groups: flow.value.groups ?? null
}
}
// Overlay a draft from the store onto an existing `Flow`. Preserves the
// metadata fields that aren't in `FlowDraftValue` (path, edited_by,
// edited_at, archived, extra_perms, …). `groups` lives inside
// `FlowValue`, so it rides along on `dv.value` automatically; the
// sibling-key on `FlowDraftValue` is purely for the AI's tool I/O.
export function applyDraftValueToFlow(flow: Flow, dv: FlowDraftValue): Flow {
return {
...flow,
value: dv.value,
schema: dv.schema ?? flow.schema
}
}
@@ -0,0 +1,14 @@
import type { WorkspaceItemDiff } from '$lib/gen'
// Editor URL for a workspace-item diff, scoped to a given workspace. Returns
// undefined for kinds we don't have a dedicated editor for (resource,
// variable, schedule, triggers, …).
export function editUrlFor(d: WorkspaceItemDiff, workspaceId: string): string | undefined {
const ws = encodeURIComponent(workspaceId)
const path = d.path
if (d.kind === 'flow') return `/flows/edit/${path}?workspace=${ws}`
if (d.kind === 'script') return `/scripts/edit/${path}?workspace=${ws}`
if (d.kind === 'app') return `/apps/edit/${path}?workspace=${ws}`
if (d.kind === 'raw_app') return `/apps_raw/edit/${path}?workspace=${ws}`
return undefined
}
@@ -0,0 +1,690 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { get } from 'svelte/store'
import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import {
AppService,
FlowService,
ScriptService,
WorkspaceService,
type AppWithLastVersion,
type Flow,
type NewScript,
type NewScriptWithDraft,
type WorkspaceComparison
} from '$lib/gen'
import type { App as AppValue, HiddenRunnable } from '$lib/components/apps/types'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { workspaceStore } from '$lib/stores'
import { emptySchema, type StateStore } from '$lib/utils'
import {
commitSessionWorkspace,
deleteSession as deleteSessionState,
ensureChatIdsSeeded,
materializeTransient,
sessionState,
setSessionChatId,
setSessionTarget,
type Session,
type SessionTarget
} from './sessionState.svelte'
import {
globalDraftStore,
type AppDraftValue,
type FlowDraftValue
} from '$lib/components/copilot/chat/global/draftStore.svelte'
import { applyDraftValueToFlow, flowToDraftValue } from './flowDraftCodec'
import { applyDraftValueToRawApp, rawAppToDraftValue } from './appDraftCodec'
import { setOpenPreviewHandler } from '$lib/components/copilot/chat/global/core'
export interface SessionRuntime {
readonly sessionId: string
readonly manager: AIChatManager
// Flow target state
readonly flowStore: StateStore<Flow>
readonly flowStateStore: { val: Record<string, any> }
readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined }
readonly loadingFlow: boolean
readonly notFound: boolean
readonly loadedPath: string | undefined
loadFlow(workspace: string, path: string): Promise<void>
// Script target state (parallel to flow, populated only for script-targeted sessions)
readonly scriptStore: { val: NewScript | undefined }
readonly savedScript: { val: NewScriptWithDraft | undefined }
readonly loadingScript: boolean
readonly notFoundScript: boolean
readonly loadedScriptPath: string | undefined
loadScript(workspace: string, path: string): Promise<void>
// App (regular drag-and-drop apps) target state
readonly appStore: {
val: (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
}
readonly savedApp: {
val:
| {
value: AppValue
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
}
readonly loadingApp: boolean
readonly notFoundApp: boolean
readonly loadedAppPath: string | undefined
loadApp(workspace: string, path: string): Promise<void>
// Raw App (HTML-based) target state
readonly rawApp: {
val:
| {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
summary: string
path: string
}
| undefined
}
readonly savedRawApp: {
val:
| {
value: {
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
}
readonly loadingRawApp: boolean
readonly notFoundRawApp: boolean
readonly loadedRawAppPath: string | undefined
loadRawApp(workspace: string, path: string): Promise<void>
// Fork comparison cache: shared between SessionForkBar (count + dropdown)
// and any future consumer that needs the parent ↔ fork diff list. Keyed
// implicitly by the (parent, fork) pair last passed to ensureForkComparison;
// invalidateForkComparison() forces a refresh after a known-mutating action.
readonly forkComparison: { val: WorkspaceComparison | undefined }
readonly loadingForkComparison: boolean
ensureForkComparison(parent: string, fork: string): Promise<void>
invalidateForkComparison(): void
// Force-refresh against the last (parent, fork) pair the runtime
// fetched for. No-op if no comparison has ever been loaded. Useful
// for session-activation hooks that need a fresh count regardless of
// the dedupe key match.
refreshForkComparison(): Promise<void>
}
const runtimes = new SvelteMap<string, SessionRuntime>()
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
}
function createRuntime(session: Session): SessionRuntime {
const manager = new AIChatManager()
manager.disabledModes = { navigator: true }
// Sessions always operate in GLOBAL mode (workspace-item tools across
// the session's workspace). The page-level gate already requires the
// global-AI flag, so this is always available here. Mode is locked
// and the dropdown is hidden in the chat UI.
manager.mode = AIMode.GLOBAL
// Pre-flight: materialise the (still-transient) session, then commit
// the workspace (creating a staged fork if needed) before any send.
// AIChatManager awaits this so the first message hits a persisted
// session targeting the right workspace. Both calls are idempotent.
manager.beforeSend = async () => {
materializeTransient(session.id)
await commitSessionWorkspace(session.id, get(workspaceStore) ?? undefined)
}
const flowStore: StateStore<Flow> = $state({ val: emptyFlow() })
const flowStateStore: { val: Record<string, any> } = $state({ val: {} })
const savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } = $state({
val: undefined
})
let loadingFlow = $state(false)
let notFound = $state(false)
let loadedPath = $state<string | undefined>(undefined)
const scriptStore: { val: NewScript | undefined } = $state({ val: undefined })
const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined })
let loadingScript = $state(false)
let notFoundScript = $state(false)
let loadedScriptPath = $state<string | undefined>(undefined)
const appStore: {
val: (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
} = $state({ val: undefined })
const savedApp: { val: SessionRuntime['savedApp']['val'] } = $state({ val: undefined })
let loadingApp = $state(false)
let notFoundApp = $state(false)
let loadedAppPath = $state<string | undefined>(undefined)
const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined })
const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined })
let loadingRawApp = $state(false)
let notFoundRawApp = $state(false)
let loadedRawAppPath = $state<string | undefined>(undefined)
const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined })
let loadingForkComparison = $state(false)
let forkComparisonKey: string | undefined = undefined
return {
sessionId: session.id,
manager,
flowStore,
flowStateStore,
savedFlow,
get loadingFlow() {
return loadingFlow
},
get notFound() {
return notFound
},
get loadedPath() {
return loadedPath
},
async loadFlow(workspace: string, path: string) {
if (loadedPath === path) return
loadingFlow = true
notFound = false
try {
// Draft first. globalDraftStore is the authoritative content
// source: the AI writes through it (write_flow / patch_flow_json
// / set_flow_module_code) and the editor's outbound $effect
// mirrors user edits back into it. If a draft exists we render
// from it, even when the path has never been deployed.
const aiDraft = globalDraftStore.getFlowDraft(workspace, path)
const draftValue =
aiDraft &&
aiDraft.value &&
typeof aiDraft.value === 'object' &&
'value' in (aiDraft.value as object)
? (aiDraft.value as FlowDraftValue)
: undefined
if (draftValue) {
// Best-effort fetch the backend baseline for the diff
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only flows are a valid state.
try {
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
} catch {
savedFlow.val = undefined
}
const skeleton: Flow = (savedFlow.val as Flow | undefined) ?? {
path,
summary: '',
value: { modules: [] },
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
const flow = applyDraftValueToFlow(skeleton, draftValue)
await initFlow(flow, flowStore, flowStateStore)
loadedPath = path
return
}
// No draft yet. Seed one from the last deploy (or the
// backend-side draft, if one exists).
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
const flow: Flow = (result.draft as Flow | undefined) ?? (result as Flow)
globalDraftStore.setDraft(workspace, {
type: 'flow',
path,
summary: flow.summary,
value: flowToDraftValue(flow),
isDraft: true
})
await initFlow(flow, flowStore, flowStateStore)
loadedPath = path
} catch (err) {
console.error('Failed to load flow', err)
notFound = true
} finally {
loadingFlow = false
}
},
scriptStore,
savedScript,
get loadingScript() {
return loadingScript
},
get notFoundScript() {
return notFoundScript
},
get loadedScriptPath() {
return loadedScriptPath
},
async loadScript(workspace: string, path: string) {
if (loadedScriptPath === path) return
loadingScript = true
notFoundScript = false
try {
// Draft first. globalDraftStore is the authoritative content
// source: the AI writes through it (write_script / edit_script)
// and the editor's outbound $effect mirrors user edits back
// into it. If a draft exists we render from it, even when the
// path has never been deployed.
const aiDraft = globalDraftStore.getScriptDraft(workspace, path)
const draftContent =
aiDraft && typeof aiDraft.value === 'string' ? aiDraft.value : undefined
if (aiDraft && draftContent !== undefined) {
// Best-effort fetch the backend baseline for the diff
// drawer + parent_hash. 404 means draft-only — leave
// savedScript undefined and skip parent_hash.
try {
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
savedScript.val = result
} catch {
savedScript.val = undefined
}
const baseline: NewScript = savedScript.val
? ((savedScript.val.draft as NewScript | undefined) ?? (savedScript.val as NewScript))
: {
path,
summary: aiDraft.summary ?? '',
content: '',
description: '',
schema: emptySchema(),
language: (aiDraft.language ?? 'bun') as any
}
if (savedScript.val?.hash) {
baseline.parent_hash = savedScript.val.hash
}
baseline.content = draftContent
if (aiDraft.language) baseline.language = aiDraft.language
if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary
scriptStore.val = baseline
loadedScriptPath = path
return
}
// No draft yet. Seed from backend.
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
savedScript.val = result
const baseline = (result.draft as NewScript | undefined) ?? (result as NewScript)
baseline.parent_hash = result.hash
globalDraftStore.setDraft(workspace, {
type: 'script',
path,
language: baseline.language,
summary: baseline.summary,
value: baseline.content ?? '',
isDraft: true
})
scriptStore.val = baseline
loadedScriptPath = path
} catch (err) {
console.error('Failed to load script', err)
notFoundScript = true
} finally {
loadingScript = false
}
},
appStore,
savedApp,
get loadingApp() {
return loadingApp
},
get notFoundApp() {
return notFoundApp
},
get loadedAppPath() {
return loadedAppPath
},
async loadApp(workspace: string, path: string) {
if (loadedAppPath === path) return
loadingApp = true
notFoundApp = false
try {
const result = await AppService.getAppByPathWithDraft({ workspace, path })
savedApp.val = {
summary: result.summary,
value: result.value as AppValue,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft:
result.draft?.['summary'] !== undefined
? result.draft
: result.draft
? {
summary: result.summary,
value: result.draft,
path: result.path,
policy: result.policy,
custom_path: result.custom_path
}
: undefined,
custom_path: result.custom_path
}
if (result.draft) {
appStore.val =
result.summary !== undefined
? { ...result, ...(result.draft as Record<string, any>) }
: ({ ...result, value: result.draft as any } as any)
} else {
appStore.val = result as any
}
loadedAppPath = path
} catch (err) {
console.error('Failed to load app', err)
notFoundApp = true
} finally {
loadingApp = false
}
},
rawApp,
savedRawApp,
get loadingRawApp() {
return loadingRawApp
},
get notFoundRawApp() {
return notFoundRawApp
},
get loadedRawAppPath() {
return loadedRawAppPath
},
async loadRawApp(workspace: string, path: string) {
if (loadedRawAppPath === path) return
loadingRawApp = true
notFoundRawApp = false
try {
// Draft first. globalDraftStore is the authoritative content
// source: the AI writes through it (init_app / write_app_file
// / ...) and the editor's outbound $effect mirrors user edits
// back into it. If a draft exists we render from it, even
// when the path has never been deployed.
const aiDraft = globalDraftStore.getAppDraft(workspace, path)
const draftValue =
aiDraft &&
aiDraft.value &&
typeof aiDraft.value === 'object' &&
'files' in (aiDraft.value as object)
? (aiDraft.value as AppDraftValue)
: undefined
if (draftValue) {
// Best-effort fetch the backend baseline for the diff
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only apps are a valid state.
try {
const result = await AppService.getAppByPathWithDraft({ workspace, path })
savedRawApp.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft: result.draft,
custom_path: result.custom_path
}
} catch {
savedRawApp.val = undefined
}
rawApp.val = applyDraftValueToRawApp(
{
files: {},
runnables: {},
data: { ...DEFAULT_DATA },
policy: undefined,
summary: draftValue.summary ?? '',
path
},
draftValue
)
loadedRawAppPath = path
return
}
// No draft yet. Seed one from the last deploy (or the
// backend-side draft, if one exists — that's the user's
// "Save draft" content from the standalone editor and is
// fresher than `value`).
const result = await AppService.getAppByPathWithDraft({ workspace, path })
savedRawApp.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft: result.draft,
custom_path: result.custom_path
}
const sourceValue: any = result.draft ?? result.value
let data: RawAppData = { ...DEFAULT_DATA }
if (sourceValue?.data) {
const d = sourceValue.data
if (d.creation) {
data = {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (sourceValue?.datatables) {
data = { ...DEFAULT_DATA, tables: sourceValue.datatables }
}
const runtimeValue = {
files: (sourceValue?.files ?? {}) as Record<string, string>,
runnables: (sourceValue?.runnables ?? {}) as Record<string, any>,
data,
policy: result.policy,
summary: result.summary ?? '',
path: result.path
}
globalDraftStore.setDraft(workspace, {
type: 'app',
path,
summary: runtimeValue.summary,
value: rawAppToDraftValue(runtimeValue),
isDraft: true
})
rawApp.val = runtimeValue
loadedRawAppPath = path
} catch (err) {
console.error('Failed to load raw app', err)
notFoundRawApp = true
} finally {
loadingRawApp = false
}
},
forkComparison,
get loadingForkComparison() {
return loadingForkComparison
},
async ensureForkComparison(parent: string, fork: string) {
const key = `${parent}|${fork}`
if (forkComparisonKey === key && forkComparison.val) return
if (loadingForkComparison && forkComparisonKey === key) return
forkComparisonKey = key
loadingForkComparison = true
try {
forkComparison.val = await WorkspaceService.compareWorkspaces({
workspace: parent,
targetWorkspaceId: fork
})
} catch (e) {
console.error('SessionRuntime: forkComparison fetch failed', e)
forkComparison.val = undefined
// On error, clear the key so the next call retries.
if (forkComparisonKey === key) forkComparisonKey = undefined
} finally {
loadingForkComparison = false
}
},
invalidateForkComparison() {
forkComparisonKey = undefined
forkComparison.val = undefined
},
async refreshForkComparison() {
const key = forkComparisonKey
if (!key) return
const sep = key.indexOf('|')
if (sep < 0) return
const parent = key.slice(0, sep)
const fork = key.slice(sep + 1)
// Stale-while-revalidate: re-fetch in place so the cached
// status (driving the sidebar dot, fork bar, etc.) stays put
// until the new result lands. Clearing forkComparison.val
// here flickered the icon back to the neutral GitFork on
// every session-activate refresh.
if (loadingForkComparison) return
loadingForkComparison = true
try {
forkComparison.val = await WorkspaceService.compareWorkspaces({
workspace: parent,
targetWorkspaceId: fork
})
} catch (e) {
console.error('SessionRuntime: forkComparison refresh failed', e)
} finally {
loadingForkComparison = false
}
}
}
}
async function initRuntime(runtime: SessionRuntime, session: Session) {
const { manager } = runtime
await manager.historyManager.init()
manager.historyManager.setSessionId(session.id)
await ensureChatIdsSeeded(manager.historyManager)
if (session.chatId) {
manager.historyManager.setCurrentChatId(session.chatId)
await manager.historyManager.tagChatWithSession(session.chatId, session.id)
await manager.loadPastChat(session.chatId)
} else {
setSessionChatId(session.id, manager.historyManager.getCurrentChatId())
}
}
export function getOrCreateRuntime(session: Session): SessionRuntime {
let runtime = runtimes.get(session.id)
if (!runtime) {
runtime = createRuntime(session)
runtimes.set(session.id, runtime)
initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e))
}
return runtime
}
export function disposeRuntime(sessionId: string) {
const runtime = runtimes.get(sessionId)
if (!runtime) return
runtime.manager.cancel('runtime disposed')
runtime.manager.historyManager.close()
runtimes.delete(sessionId)
}
export function listRuntimes(): SessionRuntime[] {
return Array.from(runtimes.values())
}
export function getRuntime(sessionId: string): SessionRuntime | undefined {
return runtimes.get(sessionId)
}
export type SessionChatStatus =
| 'idle'
| 'streaming'
| 'awaiting-user'
| 'needs-confirmation'
| 'draft'
| 'error'
// MRU set of session ids whose FlowEditorView is currently mounted. Capped at
// MAX_WARM_EDITORS — sessions outside the set show chat-only. Module-scoped so
// both the page (which mutates) and the sidebar (which reads for the dev clue)
// see the same state.
const MAX_WARM_EDITORS = 3
export const editorWarmIds = new SvelteSet<string>()
// Full session teardown: dispose the runtime, drop the LRU entry, and remove
// from sessionState in one call. Callers (sidebar / header dropdowns) just
// invoke this; navigation away from a deleted active session is the caller's
// responsibility.
export function removeSession(sessionId: string): void {
disposeRuntime(sessionId)
editorWarmIds.delete(sessionId)
deleteSessionState(sessionId)
}
export function promoteEditorWarm(sessionId: string): void {
editorWarmIds.delete(sessionId)
editorWarmIds.add(sessionId)
while (editorWarmIds.size > MAX_WARM_EDITORS) {
const oldest = editorWarmIds.values().next().value
if (oldest === undefined) break
editorWarmIds.delete(oldest)
}
}
// Register the global open_preview tool handler once at module load. The
// handler dispatches to the currently active session — if no session is
// active (chat is in the right side panel singleton), the tool returns an
// error message via `setOpenPreviewHandler(undefined)`-style guard inside
// core.ts.
setOpenPreviewHandler(({ kind, path }) => {
const sessionId = sessionState.currentSessionId
if (!sessionId) {
return 'Error: no active session to open the preview in.'
}
const target: SessionTarget = { kind, path }
setSessionTarget(sessionId, target)
promoteEditorWarm(sessionId)
return `Opened ${kind} preview for ${path} in the side panel.`
})
export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus {
const m = runtime.manager
if (m.loading) return 'streaming'
if (m.instructions.trim().length > 0) return 'draft'
const last = m.displayMessages[m.displayMessages.length - 1]
if (last?.role === 'tool' && last.needsConfirmation) return 'needs-confirmation'
if (last?.role === 'user' && last.error) return 'error'
if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user'
return 'idle'
}
@@ -0,0 +1,32 @@
import { derived, type Readable } from 'svelte/store'
import { workspaceStore, userWorkspaces, type UserWorkspace } from '$lib/stores'
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
// Walk up parent_workspace_id chain to find the root of the fork family
// containing `id`. Falls back to the workspace itself if it has no parent
// (or its parent isn't in the user's list).
function findFamilyRoot(id: string, all: UserWorkspace[]): UserWorkspace | undefined {
let current = all.find((w) => w.id === id)
while (current?.parent_workspace_id) {
const parent = all.find((w) => w.id === current!.parent_workspace_id)
if (!parent) break
current = parent
}
return current
}
// Set of workspace ids a session must belong to for the user to see it in
// the sidebar list. The whole fork family is visible from any node: when
// the user is inside fork A whose root is R, sessions belonging to R or
// any sibling fork of A are listed too. Recomputes when the user switches
// workspace or when the workspace list refreshes.
export const visibleWorkspaceIds: Readable<Set<string>> = derived(
[workspaceStore, userWorkspaces],
([ws, all]) => {
if (!ws) return new Set<string>()
const root = findFamilyRoot(ws, all) ?? ({ id: ws } as UserWorkspace)
const ids = new Set<string>([root.id])
for (const d of findWorkspaceDescendants(root.id, all)) ids.add(d.id)
return ids
}
)
@@ -0,0 +1,470 @@
import { BROWSER } from 'esm-env'
import { get } from 'svelte/store'
import { createLongHash } from '$lib/editorLangUtils'
import { random_adj } from '$lib/components/random_positive_adjetive'
import {
userWorkspaces,
usersWorkspaceStore,
workspaceStore,
type UserWorkspace
} from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
// Switch the global workspace iff the target differs from the active one
// and is non-empty. Centralises the "session needs its workspace in focus"
// rule so picker, deep-link, and workspace-bar paths agree on the same
// semantic. No-op for `undefined` / empty.
export function syncWorkspaceTo(workspaceId: string | undefined): void {
if (!workspaceId) return
if (workspaceId === get(workspaceStore)) return
switchWorkspace(workspaceId)
}
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte'
export type SessionTarget = { kind: 'flow' | 'script' | 'app' | 'raw_app'; path: string }
// Kinds the in-session editor pane can host. Useful for filtering
// dropdowns / pickers to "items the side panel can open".
export const EDITOR_TARGET_KINDS: ReadonlySet<SessionTarget['kind']> = new Set([
'flow',
'script',
'app',
'raw_app'
])
// Lifecycle status for a fork session. Git-parallel:
// in_sync — fork is up to date with parent (or only behind — treated
// the same since the user has no unmerged work either way).
// ahead — fork has unmerged changes vs parent (branch ahead).
// diverged — fork has unmerged changes AND parent has moved (branch
// diverged from upstream — potential conflicts).
// unavailable — fork workspace is no longer in the user's list (deleted,
// archived, or access revoked). Read-only fallback.
//
// `undefined` is the loading / not-applicable state (root session,
// comparison not yet fetched).
export type ForkStatus = 'in_sync' | 'ahead' | 'diverged' | 'unavailable'
// Whether the session points at a workspace that is itself a fork (i.e.
// has a parent). Independent of comparison-fetch state — used by the
// sidebar to pick between a root (Building) icon and a fork-status icon
// before the comparison has loaded.
//
// Sessions whose committed workspace is no longer in the user's list are
// still treated as forks (the "unavailable" terminal state) so we don't
// flip them back to Building once access is lost.
export function isForkSession(session: Session, allWorkspaces: UserWorkspace[]): boolean {
const wsId = session.workspace_id ?? session.pending_workspace_id
if (!wsId) return false
const ws = allWorkspaces.find((w) => w.id === wsId)
if (!ws) return !!session.workspace_id
return !!ws.parent_workspace_id
}
export function deriveForkStatus(
session: Session,
allWorkspaces: UserWorkspace[],
comparison: WorkspaceComparison | undefined
): ForkStatus | undefined {
const wsId = session.workspace_id ?? session.pending_workspace_id
if (!wsId) return undefined
const ws = allWorkspaces.find((w) => w.id === wsId)
// Committed fork workspaces that disappear from the user's list
// (deleted, archived, or access lost) are flagged unavailable so
// the UI can render a terminal state without trying to switch into
// them. Drafts whose pending workspace also vanished get the same
// treatment.
if (!ws) return session.workspace_id ? 'unavailable' : undefined
if (!ws.parent_workspace_id) return undefined
if (!comparison) return undefined
const ahead = comparison.summary?.total_ahead ?? 0
const behind = comparison.summary?.total_behind ?? 0
if (ahead > 0 && behind > 0) return 'diverged'
if (ahead > 0) return 'ahead'
return 'in_sync'
}
export type PendingFork = {
// Existing workspace to fork from (drives routing/scope pre-send).
parent_workspace_id: string
// Slug the new fork will use, e.g. `wm-fork-foo`.
id: string
// Display name shown in the workspace bar.
name: string
}
export type Session = {
id: string
name: string
// Committed strictly at first user-message send. Undefined for drafts
// that have never been sent — those scope by `pending_workspace_id`
// instead and don't show the fork bar.
workspace_id?: string
// Pre-send draft workspace, picked via SessionWorkspaceBar. Drives
// scope/editor/display while workspace_id is undefined; gets copied
// into workspace_id at first send and then becomes irrelevant.
pending_workspace_id?: string
// Pre-send intent to create a new fork. The actual API call is
// deferred to first send (via commitSessionWorkspace) so cancelling
// the draft doesn't leave an orphan fork behind.
pending_fork?: PendingFork
chatId?: string
target?: SessionTarget
summary?: string
createdAt: number
// User-archived sessions are hidden from the sidebar by default
// (toggleable via the picker filter). Archive is reversible — distinct
// from delete, which removes the session entirely.
archived?: boolean
// In-memory-only flag: the session exists but isn't written to
// localStorage until the user sends their first message. Avoids
// piling abandoned drafts across `+` clicks — createSession reuses
// the existing transient if one is already open.
transient?: boolean
}
const STORAGE_KEY = 'windmill_sessions'
const now = Date.now()
const defaultSessions: Session[] = [
{
id: createLongHash(),
name: 'session-1',
summary: 'testing_flow',
target: { kind: 'flow', path: 'u/guilhempw/testing_flow' },
createdAt: now
},
{
id: createLongHash(),
name: 'session-2',
summary: 'demo_groups',
target: { kind: 'flow', path: 'u/guilhempw/demo_groups' },
createdAt: now
}
]
function loadSessions(): Session[] {
if (!BROWSER) return defaultSessions
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
// Drop empty-string workspace_id (older sessions used '' as a
// missing-value marker) so the undefined-until-first-send invariant
// holds for legacy drafts. Also migrate the deprecated
// 'rawapp' target.kind to the canonical 'raw_app'.
let mutated = false
for (const s of parsed) {
if (s.workspace_id === '') {
delete s.workspace_id
mutated = true
}
if (s.target?.kind === 'rawapp') {
s.target.kind = 'raw_app'
mutated = true
}
}
if (mutated) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed))
} catch (e) {
console.error('Failed to persist normalised sessions', e)
}
}
return parsed as Session[]
}
}
} catch (e) {
console.error('Failed to load sessions from localStorage', e)
}
return defaultSessions
}
export const sessionState = $state<{
sessions: Session[]
currentSessionId: string | undefined
}>({
sessions: loadSessions(),
currentSessionId: undefined
})
export function persistSessions() {
if (!BROWSER) return
try {
// Transient (unsent) sessions stay in memory only. They get
// materialised — and from then on written to storage — when the
// user sends their first message.
const toPersist = $state.snapshot(sessionState.sessions).filter((s) => !s.transient)
localStorage.setItem(STORAGE_KEY, JSON.stringify(toPersist))
} catch (e) {
console.error('Failed to persist sessions', e)
}
}
export function findSessionByName(name: string): Session | undefined {
return sessionState.sessions.find((s) => s.name === name)
}
// Walk up parent_workspace_id to the family root, given a starting
// workspace id. Returns the input id if no parent chain is visible.
function familyRootId(id: string | undefined, all: UserWorkspace[]): string | undefined {
if (!id) return undefined
let cur = all.find((w) => w.id === id)
while (cur?.parent_workspace_id) {
const parent = all.find((w) => w.id === cur!.parent_workspace_id)
if (!parent) break
cur = parent
}
return cur?.id ?? id
}
export function createSession(): Session {
// Reuse the existing transient session (if any) so the user can hit
// the "+" button repeatedly without piling drafts. The transient
// becomes a real session at first-message-send time.
const existingTransient = sessionState.sessions.find((s) => s.transient)
if (existingTransient) {
sessionState.currentSessionId = existingTransient.id
return existingTransient
}
const existingNumbers = sessionState.sessions
.map((s) => /^session-(\d+)$/.exec(s.name)?.[1])
.map((n) => (n ? parseInt(n, 10) : 0))
const next = (existingNumbers.length ? Math.max(...existingNumbers) : 0) + 1
// Default to the family root rather than wherever the user happens
// to be — sessions usually start from "the canonical workspace" and
// the picker lets them switch to a fork later.
const currentWs = get(workspaceStore)
const root = familyRootId(currentWs ?? undefined, get(userWorkspaces))
const pending = root ?? currentWs
// Friendly default summary so the header reads like "Zippy session"
// rather than "Untitled session" — assigned at create time, the user
// can still rename it (or it gets overwritten by an editor target).
const adj = random_adj()
const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session`
const session: Session = {
id: createLongHash(),
name: `session-${next}`,
summary,
pending_workspace_id: pending && pending.length > 0 ? pending : undefined,
createdAt: Date.now(),
transient: true
}
sessionState.sessions = [session, ...sessionState.sessions]
sessionState.currentSessionId = session.id
// persistSessions() filters out transients — this call is a no-op for
// the new draft, but kept so any other session mutations get flushed.
persistSessions()
return session
}
// Promote an in-memory transient session to a persisted one. No-op when
// the session isn't transient. Called by the chat manager's beforeSend
// hook so the session is only written to localStorage once the user
// commits to it by sending their first message.
export function materializeTransient(id: string): void {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s || !s.transient) return
delete s.transient
persistSessions()
}
export function setSessionPendingWorkspace(id: string, workspace_id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
const changed = s.pending_workspace_id !== workspace_id || s.pending_fork !== undefined
s.pending_workspace_id = workspace_id
// Picking an existing workspace cancels any pending fork intent.
s.pending_fork = undefined
if (changed) persistSessions()
}
// Records the user's intent to create a new fork without firing the API
// call yet. Routing/scope stay on the parent workspace until commit.
export function setSessionPendingFork(id: string, fork: PendingFork) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.pending_fork = { ...fork }
s.pending_workspace_id = fork.parent_workspace_id
persistSessions()
}
// One-shot commit: locks in workspace_id at first user-message send.
// If a pending fork is set, materialises it via the API first, then
// switches the global workspace to the freshly created fork. Falls back
// to the pending pick, then the active workspace. Clears pending so it
// doesn't shadow later reads.
export async function commitSessionWorkspace(
id: string,
fallback?: string
): Promise<string | undefined> {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return undefined
if (s.workspace_id) return s.workspace_id
if (s.pending_fork) {
const fork = s.pending_fork
const newId = await materializeFork(fork)
if (!newId) return undefined
if (get(workspaceStore) !== newId) switchWorkspace(newId)
s.workspace_id = newId
s.pending_fork = undefined
s.pending_workspace_id = undefined
persistSessions()
return newId
}
const ws = s.pending_workspace_id ?? fallback
if (!ws) return undefined
s.workspace_id = ws
s.pending_workspace_id = undefined
persistSessions()
return ws
}
// Effective workspace for scope/routing — committed if set, otherwise the
// pre-send pending pick (which defaults to the workspace at create time).
// Pending forks route via their parent until creation lands.
export function getEffectiveWorkspaceId(session: Session): string | undefined {
return session.workspace_id ?? session.pending_workspace_id
}
// Canonical mutation for session.target. Persists, optionally seeds the
// session summary, and centralises the path so callers don't reach into
// session.target directly.
export function setSessionTarget(id: string, target: SessionTarget, summary?: string): void {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.target = target
if (!s.summary && summary) s.summary = summary
persistSessions()
}
export function selectSession(id: string) {
sessionState.currentSessionId = id
}
export function renameSession(id: string, newSummary: string) {
const trimmed = newSummary.trim()
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.summary = trimmed.length > 0 ? trimmed : undefined
persistSessions()
}
// Create a new fork workspace via the API, refresh the user-workspaces
// store, and return the new fork id. Used by both the first-send commit
// path (commitSessionWorkspace) and the move-session-to-a-new-fork path
// in the unavailable-session banner. Returns undefined on failure (a
// user-facing toast is already emitted).
export async function materializeFork(fork: PendingFork): Promise<string | undefined> {
try {
await WorkspaceService.createWorkspaceFork({
workspace: fork.parent_workspace_id,
requestBody: { id: fork.id, name: fork.name }
})
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
sendUserToast(`Created fork ${fork.name}`)
return fork.id
} catch (e: any) {
sendUserToast(`Could not create fork: ${e?.body ?? e?.message ?? e}`, true)
return undefined
}
}
// Re-assign a committed session to a different workspace. Used to rescue
// sessions whose original workspace was deleted / archived / had access
// revoked — the chat history (stored in IndexedDB keyed by session id) is
// preserved; only the workspace pointer changes. Drops pending fields
// since the session is already past the draft stage by definition.
export function moveSessionToWorkspace(id: string, newWorkspaceId: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
if (s.workspace_id === newWorkspaceId) return
s.workspace_id = newWorkspaceId
delete s.pending_workspace_id
delete s.pending_fork
persistSessions()
}
// Create a brand-new fork and re-assign a committed session to it. Used
// by the unavailable-session banner's "Create new fork" path in the
// move dropdown. On success the global workspace is switched to the
// freshly created fork.
export async function moveSessionToNewFork(
id: string,
fork: PendingFork
): Promise<string | undefined> {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return undefined
const newId = await materializeFork(fork)
if (!newId) return undefined
if (get(workspaceStore) !== newId) switchWorkspace(newId)
moveSessionToWorkspace(id, newId)
return newId
}
export function setSessionArchived(id: string, archived: boolean) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
const next = archived ? true : undefined
if (s.archived === next) return
if (archived) s.archived = true
else delete s.archived
persistSessions()
}
export function deleteSession(id: string) {
const idx = sessionState.sessions.findIndex((s) => s.id === id)
if (idx < 0) return
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== id)
if (sessionState.currentSessionId === id) {
sessionState.currentSessionId = sessionState.sessions[0]?.id
}
persistSessions()
}
export function setSessionChatId(sessionId: string, chatId: string) {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (s && s.chatId !== chatId) {
s.chatId = chatId
persistSessions()
}
}
let seedPromise: Promise<void> | undefined
// One-shot pairing of the user's two most-recently-modified saved chats with
// the two seeded sessions. Idempotent across all callers / SessionWrappers.
export function ensureChatIdsSeeded(historyManager: HistoryManager): Promise<void> {
if (!seedPromise) {
seedPromise = (async () => {
try {
await historyManager.init()
// Read directly from storage so we see chats regardless of this manager's
// own session-scope filter (getPastChats would hide already-tagged ones).
const pastChats = historyManager.getAllSavedChats()
const untagged = pastChats
.filter((c) => !c.sessionId)
.sort((a, b) => b.lastModified - a.lastModified)
let mutated = false
for (let i = 0; i < Math.min(sessionState.sessions.length, untagged.length); i++) {
if (!sessionState.sessions[i].chatId) {
const chatId = untagged[i].id
const sessionId = sessionState.sessions[i].id
sessionState.sessions[i].chatId = chatId
await historyManager.tagChatWithSession(chatId, sessionId)
mutated = true
}
}
if (mutated) persistSessions()
} catch (e) {
console.error('Failed to seed chat ids from history', e)
}
})()
}
return seedPromise
}
@@ -0,0 +1,40 @@
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
// Per-user, per-session "last seen" marker — count of displayMessages the
// last time the user was actually on that session's page. Compared against
// the runtime's current message count to derive an unread badge.
//
// Stored as a single localStorage entry holding Record<sessionId, count>
// to avoid scattering keys across the namespace and to make
// useLocalStorageValue's reactivity cover all sessions at once.
const lastSeenStore = useLocalStorageValue<Record<string, number>>(
'windmill_sessions_last_seen_counts',
{}
)
// Mark the session as seen up to `count` messages. No-op when already at
// or past that count (idempotent — call freely from $effects).
export function markSessionSeen(sessionId: string, count: number) {
const current = lastSeenStore.val[sessionId] ?? 0
if (current >= count) return
lastSeenStore.val = { ...lastSeenStore.val, [sessionId]: count }
}
// Drop the session entry entirely (used on delete so we don't leak
// stale ids into localStorage indefinitely).
export function forgetSessionSeen(sessionId: string) {
if (!(sessionId in lastSeenStore.val)) return
const next = { ...lastSeenStore.val }
delete next[sessionId]
lastSeenStore.val = next
}
// Number of unread messages for a session. Undefined / unloaded runtime
// returns 0 — until messages are hydrated we don't know what's new.
export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number {
if (!runtime) return 0
const seen = lastSeenStore.val[sessionId] ?? 0
const total = runtime.manager.displayMessages.length
return Math.max(0, total - seen)
}
@@ -122,7 +122,12 @@
{:else}
<SvelteComponent
size={16}
class={twMerge('flex-shrink-0', sidebarClasses.iconText, 'transition-colors', iconClasses)}
class={twMerge(
'flex-shrink-0',
sidebarClasses.iconText,
'transition-colors',
iconClasses
)}
{...iconProps}
/>
{/if}
@@ -140,9 +145,7 @@
title={label}
>
{label}
<span
class="pl-2 text-xs text-secondary font-semibold"
>
<span class="pl-2 text-xs text-secondary font-semibold">
{shortcut}
</span>
</div>
@@ -162,7 +165,7 @@
</div>
{#if isCollapsed && notificationsCount > 0}
<div class="absolute translate-x-1/2 translate-y-1/2 -top-2 right-1 flex h-fit w-fit">
<div class="absolute top-1 right-1 flex h-fit w-fit">
<SideBarNotification notificationCount={notificationsCount} small={true} />
</div>
{:else if notificationsCount > 0}
@@ -2,15 +2,15 @@
import Notification from '$lib/components/common/alert/Notification.svelte'
interface Props {
notificationCount?: number;
small?: boolean;
notificationCount?: number
small?: boolean
}
let { notificationCount = 0, small = false }: Props = $props();
let { notificationCount = 0, small = false }: Props = $props()
</script>
{#if !small}
<Notification {notificationCount} notificationLimit={9} />
{:else}
<div class="bg-red-500 rounded-md w-3 h-3 flex items-center justify-center"></div>
<div class="bg-red-500 rounded-full w-2 h-2 flex items-center justify-center"></div>
{/if}
@@ -4,6 +4,7 @@
superadmin,
usedTriggerKinds,
userStore,
usersWorkspaceStore,
userWorkspaces,
workspaceStore,
isCriticalAlertsUIOpen,
@@ -41,13 +42,17 @@
Database,
Pyramid,
Trash2,
MailIcon
MailIcon,
ChevronDown,
ChevronRight
} from 'lucide-svelte'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import UserMenu from './UserMenu.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { clearStores } from '$lib/storeUtils'
import { clearStores, switchWorkspace } from '$lib/storeUtils'
import Toggle from '$lib/components/Toggle.svelte'
import { goto } from '$lib/navigation'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
@@ -113,6 +118,11 @@
async function deleteFork() {
const workspace = $workspaceStore ?? ''
// Capture the parent before delete so we can land the user there
// instead of dropping them back on the workspace-picker menu.
// Only valid if the parent is still in the user's workspace list.
const parentId = $userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id
const parentStillAccessible = !!(parentId && $userWorkspaces.find((w) => w.id === parentId))
const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name)
if (dbsToDrop.length > 0) {
@@ -138,8 +148,20 @@
await WorkspaceService.deleteWorkspace({ workspace })
sendUserToast('You deleted the workspace')
clearStores()
goto('/user/workspaces')
if (parentStillAccessible && parentId) {
// Refresh the workspace list before landing on the parent.
// `clearStores()` would null `usersWorkspaceStore`, which the
// sidebar's `visibleSessions` filter reads via `$userWorkspaces`
// — with an empty list, every committed session falls into the
// "workspace_id set but not in user's list" branch and renders
// as "Fork — no longer available" until a hard reload.
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(parentId)
await goto('/')
} else {
clearStores()
await goto('/user/workspaces')
}
}
let deleteForkedChildren = $state(false)
@@ -173,6 +195,12 @@
loadAvailableNativeTriggers()
const triggersCollapsed = useLocalStorageValue(
'windmill_triggers_section_collapsed',
false,
'boolean'
)
onMount(async () => {
if (lastOpened) {
// @ts-ignore
@@ -612,188 +640,207 @@
'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between gap-2'
)}
>
<div class={twMerge('pt-4 mb-6 md:mb-10')}>
<div class={twMerge('pt-4 mb-6 md:mb-10 flex flex-col grow')}>
<div class="space-y-1">
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
</div>
<div class="pt-4">
<div
class="text-secondary text-[0.5rem] uppercase transition-opacity"
class:opacity-0={isCollapsed}>Triggers</div
>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
aiId="sidebar-menu-link-add-trigger"
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
class={twMerge(
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
'data-[highlighted]:bg-surface-hover'
)}
meltElement={trigger}
>
<Plus size={14} />
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<MenuItem
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
href={subItem.disabled ? '' : subItem.href}
class={twMerge(
itemClass,
subItem.disabled ? 'pointer-events-none opacity-50' : ''
)}
{item}
disabled={subItem.disabled}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
</div>
</div>
<div class="flex flex-col h-full justify-end">
<Menubar class="flex flex-col gap-1 mb-6 md:mb-10">
{#snippet children({ createMenu })}
<UserMenu {isCollapsed} {createMenu} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification notificationCount={subItem['notificationCount']} />
</div>
{/if}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{#if isCollapsed}
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
Triggers
</div>
{:else}
<button
type="button"
onclick={() => (triggersCollapsed.val = !triggersCollapsed.val)}
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
aria-expanded={!triggersCollapsed.val}
>
Triggers
{#if triggersCollapsed.val}
<ChevronRight size={10} />
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
<ChevronDown size={10} />
{/if}
{/each}
{/snippet}
</Menubar>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
</button>
{/if}
{#if isCollapsed || !triggersCollapsed.val}
<div transition:slide={{ duration: 180 }}>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
aiId="sidebar-menu-link-add-trigger"
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
'data-[highlighted]:bg-surface-hover'
)}
meltElement={trigger}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
<Plus size={14} />
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<MenuItem
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
href={subItem.disabled ? '' : subItem.href}
class={twMerge(
itemClass,
subItem.disabled ? 'pointer-events-none opacity-50' : ''
)}
{item}
disabled={subItem.disabled}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
</div>
{/if}
</div>
<div class="flex flex-col mt-auto">
<Menubar class="flex flex-col gap-1 mb-6 md:mb-10">
{#snippet children({ createMenu })}
<UserMenu {isCollapsed} {createMenu} />
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<div class="flex flex-row items-center gap-2">
{changelog.label}
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification notificationCount={subItem['notificationCount']} />
</div>
{/if}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
{/snippet}
</Menubar>
</div>
</nav>
{/snippet}
</Menu>
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
{/if}
{/each}
{/snippet}
</Menubar>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
)}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{changelog.label}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
{/snippet}
</Menubar>
</div>
</div></nav
>
<ConfirmationModal
open={leaveWorkspaceModal}
+10 -42
View File
@@ -43,59 +43,31 @@ type WorkspaceCache = {
app?: WorkspaceItem[]
}
/** Module-level session cache. Persists across picker mounts within a single
* page session. NOT invalidated automatically call `invalidate()` after
* creating/deleting an item if the picker may be opened again before a full
* reload. */
const cache = new Map<string, WorkspaceCache>()
/** Module-level snapshot of the last fetched items per workspace+kind. Used
* for instant first paint (`getCachedItems`); the picker always re-fetches in
* the background via `loadKind` so the snapshot becomes "what we last saw"
* rather than "the source of truth". This guarantees freshness after AI
* draft creates and after deploy without explicit invalidation. */
const lastFetched = new Map<string, WorkspaceCache>()
const inflight = new Map<string, Promise<WorkspaceItem[]>>()
/** Bumped by `invalidate()`. Each in-flight `loadKind` captures the version
* at start and only writes back to the cache if it still matches so a
* deploy mid-fetch can't have its stale predecessor repopulate the cache. */
const cacheVersion = new Map<string, number>()
const cacheKey = (workspace: string, kind: WorkspaceItemKind) => `${workspace}:${kind}`
const KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app']
function bumpVersion(workspace: string, kind: WorkspaceItemKind) {
const k = cacheKey(workspace, kind)
cacheVersion.set(k, (cacheVersion.get(k) ?? 0) + 1)
}
export function getCachedItems(
workspace: string,
kind: WorkspaceItemKind
): WorkspaceItem[] | undefined {
return cache.get(workspace)?.[kind]
}
/** Drop a workspace+kind (or a whole workspace) from the cache so the next
* picker open re-fetches. Use after creating/deleting items. Also bumps the
* version so any in-flight `loadKind` started before the invalidate won't
* write its (now-stale) result back to the cache. */
export function invalidate(workspace: string, kind?: WorkspaceItemKind) {
if (!kind) {
cache.delete(workspace)
for (const k of KINDS) bumpVersion(workspace, k)
return
}
const bucket = cache.get(workspace)
if (bucket) delete bucket[kind]
bumpVersion(workspace, kind)
return lastFetched.get(workspace)?.[kind]
}
export async function loadKind(
workspace: string,
kind: WorkspaceItemKind
): Promise<WorkspaceItem[]> {
const existing = cache.get(workspace)?.[kind]
if (existing) return existing
const key = cacheKey(workspace, kind)
const flying = inflight.get(key)
if (flying) return flying
const startVersion = cacheVersion.get(key) ?? 0
const promise = (async () => {
const { ScriptService, FlowService, AppService } = await import('$lib/gen')
let items: WorkspaceItem[]
@@ -133,13 +105,9 @@ export async function loadKind(
raw_app: a.raw_app ?? false
}))
}
// Only commit if the cache version hasn't changed since we started —
// otherwise we'd overwrite a deliberate `invalidate()` with stale data.
if ((cacheVersion.get(key) ?? 0) === startVersion) {
const bucket = cache.get(workspace) ?? {}
bucket[kind] = items
cache.set(workspace, bucket)
}
const bucket = lastFetched.get(workspace) ?? {}
bucket[kind] = items
lastFetched.set(workspace, bucket)
return items
})()
inflight.set(key, promise)
@@ -63,6 +63,7 @@
import { Menubar } from '$lib/components/meltComponents'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte'
import SessionPicker from '$lib/components/sessions/SessionPicker.svelte'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
@@ -328,6 +329,9 @@
}
let devOnly = $derived(page.url.pathname.startsWith(base + '/scripts/dev'))
// Sessions own their own chat pane; suppress the global Ask-AI chat on the /sessions route
// so it doesn't render a second chat overlay on top of the session.
let inSessionRoute = $derived(page.url.pathname.startsWith(base + '/sessions'))
async function loadDefaultScripts(workspace: string, user: UserExt | undefined) {
if (!user?.operator) {
@@ -364,8 +368,8 @@
async function loadCriticalAlertsMuted() {
let g_muted = true
const ws_muted =
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
false
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! }))
.mute_critical_alerts || false
if ($superadmin) {
g_muted = (await SettingService.getGlobal({
@@ -662,6 +666,8 @@
/>
</div>
<SessionPicker {isCollapsed} />
<SidebarContent
{isCollapsed}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
@@ -823,6 +829,7 @@
<AiChatLayout
{children}
noPadding={devOnly}
disableAi={inSessionRoute}
{isCollapsed}
isMobile={innerWidth < 768}
onMenuOpen={() => {
@@ -4,7 +4,7 @@
import { page } from '$app/state'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte'
import { FlowService, type Flow } from '$lib/gen'
@@ -194,11 +194,9 @@
<FlowBuilder
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/edit/${e.path}?selected=${e.id}`)
}}
onDeploy={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
@@ -2,7 +2,7 @@
import { FlowService, type Flow, DraftService } from '$lib/gen'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { initialArgsStore, workspaceStore } from '$lib/stores'
import {
cleanValueProperties,
@@ -319,7 +319,6 @@
{:else}
<FlowBuilder
onDeploy={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
@@ -2,10 +2,16 @@
import CompareWorkspaces from '$lib/components/CompareWorkspaces.svelte'
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { page } from '$app/state'
import { userWorkspaces } from '$lib/stores'
import { userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { Archive, Trash2 } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { switchWorkspace } from '$lib/storeUtils'
import { goto } from '$lib/navigation'
let comparison: WorkspaceComparison | undefined = $state(undefined)
@@ -21,25 +27,15 @@
return
}
// loading = true
// error = undefined
try {
// Compare with parent workspace
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: currentWorkspaceId
})
comparison = result
// isVisible = result.summary.total_diffs > 0
} catch (e) {
console.error('Failed to compare workspaces:', e)
// error = 'Failed to check for changes'
// Still show banner if there's an error, but with error message
// isVisible = true
} finally {
// loading = false
}
}
@@ -48,19 +44,93 @@
untrack(() => checkForChanges())
})
// Fork lifecycle actions — placed in the page header so they're available
// regardless of merge state. Both go through a confirmation modal because
// archive is reversible-ish but delete is irreversible, and either way the
// user is about to navigate away from this page.
let archiveConfirmOpen = $state(false)
let deleteConfirmOpen = $state(false)
let acting = $state(false)
async function afterForkGone() {
// Mirror SidebarContent.deleteFork (B1): refresh the workspace list
// rather than letting `clearStores()` null it, then land the user on
// the parent if still accessible.
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
console.error('Failed to refresh workspaces', e)
}
if (parentWorkspaceId && $userWorkspaces.find((w) => w.id === parentWorkspaceId)) {
switchWorkspace(parentWorkspaceId)
await goto('/')
} else {
await goto('/user/workspaces')
}
}
async function confirmArchive() {
archiveConfirmOpen = false
if (!currentWorkspaceId) return
acting = true
try {
await WorkspaceService.archiveWorkspace({ workspace: currentWorkspaceId })
sendUserToast(`Archived fork ${currentWorkspaceId}`)
await afterForkGone()
} catch (e: any) {
sendUserToast(`Failed to archive fork: ${e?.body ?? e}`, true)
} finally {
acting = false
}
}
async function confirmDelete() {
deleteConfirmOpen = false
if (!currentWorkspaceId) return
acting = true
try {
await WorkspaceService.deleteWorkspace({ workspace: currentWorkspaceId })
sendUserToast(`Deleted fork ${currentWorkspaceId}`)
await afterForkGone()
} catch (e: any) {
sendUserToast(`Failed to delete fork: ${e?.body ?? e}`, true)
} finally {
acting = false
}
}
const isFork = $derived(!!parentWorkspaceId && currentWorkspaceId?.startsWith('wm-fork-'))
</script>
<CenteredPage>
<PageHeader title="Merge workspaces" />
<PageHeader title="Merge workspaces">
{#if isFork}
<div class="flex flex-row gap-2 items-center">
<Button
variant="default"
color="light"
size="xs"
startIcon={{ icon: Archive }}
disabled={acting}
on:click={() => (archiveConfirmOpen = true)}
>
Archive fork
</Button>
<Button
variant="default"
color="red"
size="xs"
startIcon={{ icon: Trash2 }}
disabled={acting}
on:click={() => (deleteConfirmOpen = true)}
>
Delete fork
</Button>
</div>
{/if}
</PageHeader>
{#if currentWorkspaceId && parentWorkspaceId}
<!-- <WorkspaceComparisonDrawer -->
<!-- {comparison} -->
<!-- sourceWorkspace={currentWorkspaceId} -->
<!-- targetWorkspace={parentWorkspaceId} -->
<!-- on:deployed={() => { -->
<!-- sendUserToast('Changes deployed successfully') -->
<!-- }} -->
<!-- /> -->
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison} />
{/if}
{#if !currentWorkspaceId}
@@ -69,3 +139,33 @@
workspace {currentWorkspaceId} has no parent workspace
{/if}
</CenteredPage>
<ConfirmationModal
open={archiveConfirmOpen}
title="Archive fork"
confirmationText="Archive"
onConfirmed={confirmArchive}
onCanceled={() => (archiveConfirmOpen = false)}
>
<p>
Archive forked workspace <span class="font-mono font-medium text-primary"
>{currentWorkspaceId}</span
>? It will be hidden from the workspace picker; a superadmin can restore it from instance
settings later.
</p>
</ConfirmationModal>
<ConfirmationModal
open={deleteConfirmOpen}
title="Delete fork"
confirmationText="Delete"
onConfirmed={confirmDelete}
onCanceled={() => (deleteConfirmOpen = false)}
>
<p>
Permanently delete forked workspace <span class="font-mono font-medium text-primary"
>{currentWorkspaceId}</span
>? This cannot be undone. Any sessions still bound to this fork will show as "Fork — no longer
available" in the sidebar.
</p>
</ConfirmationModal>
@@ -4,7 +4,7 @@
import { page } from '$app/state'
import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import type { Schema } from '$lib/common'
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { goto } from '$lib/navigation'
@@ -169,11 +169,9 @@
? 'wac_typescript'
: 'script')}
onDeploy={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/edit/${e.path}`)
}}
onNavigate={(item) => goto(editPathFor(item))}
@@ -3,7 +3,7 @@
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
@@ -229,11 +229,9 @@
{savedPrimarySchedule}
searchParams={page.url.searchParams}
onDeploy={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/edit/${e.path}`)
}}
onSeeDetails={(e) => {
@@ -0,0 +1,154 @@
<script lang="ts">
import { untrack } from 'svelte'
import { page } from '$app/state'
import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte'
import {
getEffectiveWorkspaceId,
selectSession,
sessionState,
syncWorkspaceTo
} from '$lib/components/sessions/sessionState.svelte'
import {
getOrCreateRuntime,
getRuntime,
listRuntimes,
promoteEditorWarm
} from '$lib/components/sessions/sessionRuntime.svelte'
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
import { visibleWorkspaceIds } from '$lib/components/sessions/sessionScope.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces } from '$lib/stores'
const globalEnabled = isGlobalAiEnabled()
const sessionName = $derived(page.url.searchParams.get('session_name') ?? '')
// Unfiltered resolution by name — used to drive workspace switching
// when a deep-linked session lives outside the current workspace.
const sessionByName = $derived(
sessionName ? sessionState.sessions.find((s) => s.name === sessionName) : undefined
)
// If the deep-linked session committed to a workspace different from
// the active one, switch globally so visibility resolves and the
// editor loads against the right workspace. Skip the switch when the
// target workspace is no longer in the user's list — pointing the
// global workspace at a deleted id would break sidebar scope and the
// editor; SessionWrapper handles the unavailable state separately.
$effect(() => {
const ws = sessionByName?.workspace_id
if (!ws) return
if (!$userWorkspaces.find((w) => w.id === ws)) return
untrack(() => syncWorkspaceTo(ws))
})
// Resolve the active session if its effective workspace is in scope
// (active workspace + its forks). Unavailable sessions — committed to
// a workspace that no longer exists — also resolve so the user can
// land on the move/discard banner instead of hitting "Session not
// found".
const activeSession = $derived(
sessionState.sessions.find((s) => {
if (s.name !== sessionName) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
// Touch the runtime for the active session so it gets created on first visit
// and the pane shows up. Subsequent renders find it via listRuntimes().
// Also refresh the fork diff count: deep-link / back-button navigation
// changes the URL but doesn't fire the picker.activate path nor the
// visibility-change signal, so this is the only hook that catches a
// user returning from another route in the same tab.
//
// Gate on session identity (id) rather than the full activeSession
// derived — sessionState.sessions mutates on every persisted change
// (including token-by-token last_message updates during AI streaming),
// so a value-trigger would re-fetch compareWorkspaces dozens of times
// per turn. We only want to refresh when the user actually arrives at
// a new session.
let lastArrivedSessionId: string | undefined
$effect(() => {
const session = activeSession
if (!session) {
lastArrivedSessionId = undefined
return
}
if (lastArrivedSessionId === session.id) return
lastArrivedSessionId = session.id
untrack(() => {
// Keep currentSessionId in sync with the URL so consumers
// (refresh hooks, picker selection) react to deep links the
// same way they react to picker clicks.
selectSession(session.id)
const rt = getOrCreateRuntime(session)
void rt.refreshForkComparison()
})
})
// Warm = has a live runtime (module-scoped) AND its workspace is in
// scope (or its workspace is unavailable — those sessions still need
// to render the move/discard banner instead of vanishing on us).
const warmSessions = $derived(
listRuntimes()
.map((r) => sessionState.sessions.find((s) => s.id === r.sessionId))
.filter((s): s is NonNullable<typeof s> => s != null)
.filter((s) => {
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
// Promote the active session in the LRU. Mutations untracked so the effect
// only re-runs when activeSession changes, not on its own writes.
$effect(() => {
const id = activeSession?.id
if (!id) return
untrack(() => promoteEditorWarm(id))
})
// Mark the active session "seen" up to its current displayMessages
// length. Watching messages.length here means: arrive at the page →
// clear unread; AI streams a new message while you're on the page →
// clear unread again so the badge never lights up for a session
// you're actively looking at. The effect only depends on the
// length, not the array contents, so token-by-token streams within
// a single message don't fire it on every chunk.
$effect(() => {
const id = activeSession?.id
if (!id) return
const rt = getRuntime(id)
if (!rt) return
const count = rt.manager.displayMessages.length
untrack(() => markSessionSeen(id, count))
})
</script>
{#if !globalEnabled}
<div class="p-8 text-secondary text-sm">
Sessions are gated on the global-AI dev flag. Enable with
<code class="text-2xs font-mono">localStorage.setItem('wm_dev_global_ai', '1')</code> and reload.
</div>
{:else if !sessionName}
<div class="p-8 text-secondary">No session selected — pick one in the sidebar.</div>
{:else}
<div class="relative flex-1 min-h-0">
{#each warmSessions as s (s.id)}
<div
class="absolute inset-0 flex flex-col {s.id === activeSession?.id
? 'z-10 opacity-100 pointer-events-auto'
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} />
</div>
{/each}
</div>
{/if}
@@ -30,10 +30,12 @@
enterpriseLicense,
superadmin,
userStore,
userWorkspaces,
usersWorkspaceStore,
workspaceStore,
isCriticalAlertsUIOpen
} from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { sendUserToast } from '$lib/toast'
import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
@@ -1542,11 +1544,24 @@
unifiedSize="md"
btnClasses="mt-2"
on:click={async () => {
await WorkspaceService.archiveWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Archived workspace ${$workspaceStore}`)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
goto('/user/workspaces')
const ws = $workspaceStore ?? ''
// Land on the parent workspace if this is a fork and the
// parent is still accessible — otherwise fall back to the
// workspace picker.
const parentId = $userWorkspaces.find((w) => w.id === ws)?.parent_workspace_id
const parentStillAccessible = !!(
parentId && $userWorkspaces.find((w) => w.id === parentId)
)
await WorkspaceService.archiveWorkspace({ workspace: ws })
sendUserToast(`Archived workspace ${ws}`)
if (parentStillAccessible && parentId) {
switchWorkspace(parentId)
await goto('/')
} else {
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
await goto('/user/workspaces')
}
}}
>
Archive workspace