mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
feat(sessions): chat + editor side-by-side with multi-session state
Introduces the Sessions feature: a workspace where the AI chat and an editor (flow / script / app / raw-app) sit side-by-side, with each session having its own AIChatManager instance, history, and target item. Sessions are persisted across reloads and can be staged into forks for review. Key pieces: - sessions/ — SessionWrapper (the split-pane shell), SessionPicker (sidebar list), SessionForkBar, SessionWorkspaceBar, FlowEditorView / ScriptEditorView / AppEditorView / RawAppEditorView, ForkDiffDrawer, sessionRuntime (per-session AIChatManager + draft state), sessionState (in-memory + persisted index), sessionUnread, sessionScope, appDraftCodec / flowDraftCodec, forkEditUrl, /sessions route. - WorkspaceItemDrillPicker refactor — extracts WorkspaceItemRow + adds surfaceAI drafts, stale-while-revalidate. workspacePicker.ts drops explicit invalidate() in favor of always re-fetching in the background. - ForkDiffDrawer + WorkspaceItemDiffViewer — per-kind diff bodies reusable from the compare page. FlowGraphDiffViewer / FlowGraphV2 gain inlineDiff forwarding + onHeight callback for equal-height layout. - Global AI chat sessions plumbing — AIChatManager exports the class + adds disabledModes, beforeSend hook, scoped instance context. AIChat / AIChatDisplay accept session-only props (wideLayout, emptyHint, inputPreface, hideHeader, hideModeSelector, forceDisabled). Chat preserved across /flows/add → /flows/edit, /scripts/add → /scripts/edit. - Draft-first loaders — sessions open drafts when present, otherwise seed a draft from the last deployed value via globalDraftStore. RawAppEditor / AppEditor / AppEditorHeaderDeploy get newApp prop + fixes so draft-only apps can deploy. - Compare page (/forks/compare) — bigger overhaul to plug into the new drawer. - Sidebar — Sessions entry + unread badge + status dot in SidebarContent / MenuButton / SideBarNotification. - Misc fixes — chat group color palette constraint, deploy_workspace_item confirmation dropped, open_preview tool, picker drafts surfacing, fork archive/delete buttons on compare page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user