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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-05-26 18:09:55 +02:00
parent 402d18dcf5
commit 3cfd858e36
7 changed files with 471 additions and 74 deletions
@@ -42,6 +42,12 @@
type DeployResult
} from '$lib/utils_workspace_deploy'
import { isTriggerOrScheduleKind } from 'windmill-utils-internal'
import {
augmentForkComparisonWithLocalDrafts,
getForkItemValue,
type AugmentedWorkspaceComparison,
type AugmentedWorkspaceItemDiff
} from './sessions/forkDraftDiff'
import Tooltip from './Tooltip.svelte'
import OnBehalfOfSelector, {
needsOnBehalfOfSelection,
@@ -64,7 +70,29 @@
comparison: WorkspaceComparison | undefined
}
let { currentWorkspaceId, parentWorkspaceId, comparison }: Props = $props()
let { currentWorkspaceId, parentWorkspaceId, comparison: comparisonProp }: Props = $props()
// Local (localStorage) drafts aren't in the fork's backend DB, so the
// backend `compareWorkspaces` can't see them. Augment the comparison with
// them for display. `comparisonProp` stays as the raw backend result for
// deploy-equality checks (isComparisonUpToDate); `comparison` is the
// augmented view everything renders from. Draft-only items are flagged
// `draftOnly` and excluded from the deployable set below.
let comparison = $state<AugmentedWorkspaceComparison | undefined>(undefined)
$effect(() => {
const backend = comparisonProp
if (!backend) {
comparison = undefined
return
}
let cancelled = false
void augmentForkComparisonWithLocalDrafts(backend, currentWorkspaceId).then((augmented) => {
if (!cancelled) comparison = augmented
})
return () => {
cancelled = true
}
})
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
@@ -79,6 +107,9 @@
let selectableDiffs = $derived(
comparison?.diffs.filter((diff) => {
// Local-draft-only rows aren't backend fork-vs-parent diffs — they
// live in localStorage, not the fork DB, so they can't be deployed.
if (diff.localOnly) return false
if (mergeIntoParent) {
return diff.ahead > 0
} else {
@@ -252,23 +283,42 @@
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let isFlow = $state(true)
async function showDiff(kind: Kind, path: string) {
async function showDiff(kind: Kind, path: string, hasLocalChanges = false) {
if (!diffDrawer) return
isFlow = kind == 'flow'
diffDrawer.openDrawer()
const workspaceTo = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
const workspaceFrom = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
if (diffDrawer) {
isFlow = kind == 'flow'
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceTo),
getItemValue(kind, path, workspaceFrom)
// Tab 1 — fork vs parent: deployed values on both sides.
const [toVal, fromVal] = await Promise.all([
getItemValue(kind, path, workspaceTo).catch(() => ({})),
getItemValue(kind, path, workspaceFrom).catch(() => ({}))
])
// Tab 2 — local draft vs fork: the uncommitted local changes a deploy
// would drop. Only when the item carries a local draft.
let secondary: { original: any; current: any; title: string } | undefined
if (hasLocalChanges) {
const [forkDeployed, forkDraft] = await Promise.all([
getItemValue(kind, path, currentWorkspaceId).catch(() => ({})),
getForkItemValue(kind, path, currentWorkspaceId)
])
diffDrawer?.setDiff({
mode: 'simple',
original: values?.[0] as any,
current: values?.[1] as any,
title: `${workspaceFrom} <> ${workspaceTo}`
})
secondary = {
original: forkDeployed as any,
current: forkDraft as any,
title: 'Local draft <> fork'
}
}
diffDrawer.setDiff({
mode: 'simple',
original: toVal as any,
current: fromVal as any,
title: `${workspaceFrom} <> ${workspaceTo}`,
secondary
})
}
function kindLabel(kind: string): string {
return KIND_DISPLAY_NAMES[kind] ?? (kind === 'raw_app' ? 'app' : kind)
}
// All *diff* items selected. Trigger items are opt-in and don't count
@@ -340,7 +390,7 @@
let allowBehindChangesOverride = $state(false)
async function isComparisonUpToDate(): Promise<boolean> {
if (!comparison) {
if (!comparisonProp) {
return false
}
@@ -350,7 +400,10 @@
targetWorkspaceId: currentWorkspaceId
})
const nonDeployedChanges = comparison.diffs.filter(
// Compare against the raw backend diff (not the local-draft-augmented
// view) — local drafts are never deployed here and would otherwise
// spuriously trip the "new changes detected" guard.
const nonDeployedChanges = comparisonProp.diffs.filter(
(e) => !(deploymentStatus[getItemKey(e)]?.status == 'deployed')
)
@@ -605,6 +658,17 @@
{selectedItems}
{deploymentStatus}
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
nonSelectableTooltip={(item) => {
const d = item.diff as AugmentedWorkspaceItemDiff
// Local-draft rows show a warning icon in the checkbox slot and stay
// full opacity. Both cases point to the same fix: deploy the item
// inside the fork first so it becomes a fork↔parent change here.
if (d?.newLocalDraft)
return `This ${kindLabel(d.kind)} only exists as a draft in your browser. Deploy it inside the fork first to be able to deploy it here.`
if (d?.localChanges)
return `This ${kindLabel(d.kind)} has changes saved only in your browser. Deploy it inside the fork first to be able to deploy them here.`
return undefined
}}
{allSelected}
onToggleItem={(item) => toggleKey(item.key)}
onSelectAll={selectAll}
@@ -668,7 +732,7 @@
{/if}
<div class="flex items-center gap-2 text-sm">
<Badge color="transparent">
{comparison.summary.total_diffs} total items
{comparison?.summary.total_diffs ?? 0} total items
</Badge>
<Badge color="transparent">
{selectableDiffs.length}
@@ -757,17 +821,17 @@
</span>
</Alert>
{/if}
{#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible}
{#if !comparison?.all_ahead_items_visible || !comparison?.all_behind_items_visible}
<Alert
title="This fork has changes not visible to your user"
type="warning"
class="my-2"
>
{#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible}
{#if !comparison?.all_ahead_items_visible && !comparison?.all_behind_items_visible}
This fork is ahead and behind its parent
{:else if !comparison.all_behind_items_visible}
{:else if !comparison?.all_behind_items_visible}
This fork is behind of its parent
{:else if !comparison.all_ahead_items_visible}
{:else if !comparison?.all_ahead_items_visible}
This fork is ahead of its parent
{/if}
and some of the changes are not visible by you. Only a user with access to the whole context
@@ -833,6 +897,20 @@
customValue={customOnBehalfOf[key]?.permissionedAs}
/>
{/if}
{#if (diff as AugmentedWorkspaceItemDiff).localChanges}
<!-- Case 2 (new local draft) shows a warning icon in the checkbox
slot instead of a badge; Case 1 keeps this badge + diff link. -->
<Badge
small
color="yellow"
icon={{ icon: AlertTriangle }}
title="This {kindLabel(
diff.kind
)} has local changes. If you deploy the item, they will be dropped."
>
local changes detected
</Badge>
{/if}
{#if diff.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
@@ -900,7 +978,12 @@
<Button
size="xs"
variant="subtle"
onclick={() => showDiff(diff.kind as Kind, diff.path)}
onclick={() =>
showDiff(
diff.kind as Kind,
diff.path,
(diff as AugmentedWorkspaceItemDiff).localChanges
)}
>
<DiffIcon class="w-3 h-3" />
Show diff
@@ -914,7 +997,7 @@
<div></div>
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
{#if comparison?.all_behind_items_visible && comparison?.all_ahead_items_visible}
<div class="flex items-center gap-2">
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<Button
+44 -34
View File
@@ -19,14 +19,27 @@
metadata: string
}
let diffType: 'draft' | 'deployed' | 'custom' | undefined = $state(undefined)
let diffType: 'draft' | 'deployed' | 'custom' | 'custom2' | undefined = $state(undefined)
// The "before" (left) and "after" (right) sides for the selected tab,
// resolved across all modes. `simple` mode's optional `secondary` adds a
// second tab (`custom2`) with its own original/current pair.
let leftDiff = $derived.by((): DiffData | undefined => {
if (!data) return undefined
if (data.mode === 'normal') return diffType === 'draft' ? data.draft : data.deployed
return diffType === 'custom2' ? data.secondary?.original : data.original
})
let rightDiff = $derived.by((): DiffData | undefined => {
if (!data) return undefined
if (data.mode === 'simple' && diffType === 'custom2') return data.secondary?.current
return data.current
})
let contentType = $derived.by(() => {
if (!data || !diffType) return undefined
const dataType = diffType === 'custom' ? 'original' : diffType
return data[dataType]?.content !== data.current.content
if (!data || !diffType || !rightDiff) return undefined
return leftDiff?.content !== rightDiff.content
? 'content'
: data[dataType]?.metadata !== data.current.metadata
: leftDiff?.metadata !== rightDiff.metadata
? 'metadata'
: undefined
})
@@ -55,6 +68,7 @@
title: string
original: DiffData | undefined
current: DiffData
secondary?: { title: string; original: DiffData | undefined; current: DiffData }
button?: { text: string; onClick: () => void }
}
| undefined = $state(undefined)
@@ -97,6 +111,7 @@
original: Value
current: Value
title: string
secondary?: { original: Value; current: Value; title: string }
button?: { text: string; onClick: () => void }
}
) {
@@ -119,12 +134,19 @@
diffType = 'draft'
}
} else {
const { original, current, title, button } = diff
const { original, current, title, button, secondary } = diff
data = {
title,
mode: 'simple',
original: prepareDiff(original),
current: prepareDiff(current),
secondary: secondary
? {
title: secondary.title,
original: prepareDiff(secondary.original),
current: prepareDiff(secondary.current)
}
: undefined,
button
}
diffType = 'custom'
@@ -139,6 +161,9 @@
<Tabs bind:selected={diffType} wrapperClass="shrink-0">
{#if data.mode === 'simple'}
<Tab value="custom" label={data.title} />
{#if data.secondary}
<Tab value="custom2" label={data.secondary.title} />
{/if}
{:else}
<Tab
value="deployed"
@@ -179,36 +204,21 @@
{/if}
{#if data}
{#if contentType}
{@const content =
data.mode === 'normal'
? diffType === 'draft'
? data.draft?.content
: data.deployed?.content
: data.original?.content}
{@const metadata =
data.mode === 'normal'
? diffType === 'draft'
? data.draft?.metadata
: data.deployed?.metadata
: data.original?.metadata}
{@const lang =
data.mode === 'normal'
? diffType === 'draft'
? data.draft?.lang
: data.deployed?.lang
: data.original?.lang}
{@const content = leftDiff?.content}
{@const metadata = leftDiff?.metadata}
{@const lang = leftDiff?.lang}
<div class="flex flex-col h-full gap-4">
{#if data.current.content !== undefined}
{#if rightDiff?.content !== undefined}
<Tabs bind:selected={contentType}>
<Tab
value="content"
disabled={content === data.current.content}
label={`Content${content === data.current.content ? ' (no changes)' : ''}`}
disabled={content === rightDiff?.content}
label={`Content${content === rightDiff?.content ? ' (no changes)' : ''}`}
/>
<Tab
value="metadata"
disabled={metadata === data.current.metadata}
label={`Metadata${metadata === data.current.metadata ? ' (no changes)' : ''}`}
disabled={metadata === rightDiff?.metadata}
label={`Metadata${metadata === rightDiff?.metadata ? ' (no changes)' : ''}`}
/>
</Tabs>
{/if}
@@ -223,9 +233,9 @@
automaticLayout
className="h-full"
defaultLang={lang}
defaultModifiedLang={data.current.lang}
defaultModifiedLang={rightDiff?.lang}
defaultOriginal={content}
defaultModified={data.current.content}
defaultModified={rightDiff?.content}
readOnly
/>
{/await}
@@ -236,7 +246,7 @@
{:then Module}
<Module.default
beforeYaml={metadata ?? ''}
afterYaml={data.current.metadata}
afterYaml={rightDiff?.metadata ?? ''}
/>
{/await}
{:else}
@@ -249,7 +259,7 @@
className="h-full"
defaultLang="yaml"
defaultOriginal={metadata}
defaultModified={data.current.metadata}
defaultModified={rightDiff?.metadata}
readOnly
/>
{/await}
@@ -264,7 +274,7 @@
There are no differences between latest saved draft and current
{:else if diffType === 'deployed'}
There are no differences between deployed and current
{:else if diffType === 'custom'}
{:else if diffType === 'custom' || diffType === 'custom2'}
There are no differences
{/if}
</Alert>
@@ -18,6 +18,11 @@
items: DeployableItem[]
selectedItems: string[]
selectablePredicate?: (item: DeployableItem) => boolean
/** For non-selectable items, an optional warning shown in the checkbox
* slot explaining why it can't be selected. When provided the row keeps
* full opacity (the warning icon carries the meaning); other
* non-selectable rows dim as before. */
nonSelectableTooltip?: (item: DeployableItem) => string | undefined
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
allSelected?: boolean
emptyMessage?: string
@@ -40,6 +45,7 @@
items,
selectedItems,
selectablePredicate = () => true,
nonSelectableTooltip = () => undefined,
deploymentStatus,
allSelected = false,
emptyMessage = 'No items to deploy',
@@ -95,11 +101,13 @@
{@const isSelected = selectedItems.includes(item.key)}
{@const status = deploymentStatus[item.key]}
{@const isDeployed = status?.status === 'deployed'}
{@const softReason = !isSelectable ? nonSelectableTooltip(item) : undefined}
<Row
isSelectable={isSelectable && !isDeployed}
alignWithSelectable={true}
disabled={!isSelectable}
selectTooltip={softReason}
disabled={!isSelectable && !softReason}
selected={isSelected && !isDeployed}
onSelect={() => onToggleItem?.(item)}
path={item.kind !== 'resource' &&
@@ -2,7 +2,8 @@
import { untrack } from 'svelte'
import Star from '$lib/components/Star.svelte'
import RowIcon from './RowIcon.svelte'
import { BellOff } from 'lucide-svelte'
import { BellOff, TriangleAlert } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import { goto } from '$lib/navigation'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
@@ -15,6 +16,9 @@
canFavorite?: boolean
isSelectable?: boolean
alignWithSelectable?: boolean
/** When the row isn't selectable, show an info tooltip with this text in
* the checkbox slot (instead of an empty placeholder), explaining why. */
selectTooltip?: string | undefined
errorHandlerMuted?: boolean
aiId?: string | undefined
aiDescription?: string | undefined
@@ -63,6 +67,7 @@
canFavorite = true,
isSelectable = false,
alignWithSelectable = false,
selectTooltip = undefined,
errorHandlerMuted = false,
aiId = undefined,
aiDescription = undefined,
@@ -118,6 +123,10 @@
>
{#if isSelectable}
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
{:else if selectTooltip}
<div class="max-w-4 w-full flex items-center justify-center">
<Tooltip small Icon={TriangleAlert} class="text-yellow-500">{selectTooltip}</Tooltip>
</div>
{:else if alignWithSelectable}
<div class="rounded max-w-4 w-full"></div>
{/if}
@@ -6,6 +6,7 @@
ArrowRight,
ChevronDown,
ChevronRight,
FilePen,
Folder,
GitFork,
GitMerge,
@@ -23,8 +24,14 @@
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 { WorkspaceService, type WorkspaceItemDiff } from '$lib/gen'
import { getItemValue } from '$lib/utils_workspace_deploy'
import {
augmentForkComparisonWithLocalDrafts,
getForkItemValue,
type AugmentedWorkspaceComparison,
type AugmentedWorkspaceItemDiff
} from './forkDraftDiff'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
@@ -34,7 +41,7 @@
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
let drawer: Drawer | undefined = $state(undefined)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let comparison: AugmentedWorkspaceComparison | undefined = $state(undefined)
let loading = $state(false)
let error: string | undefined = $state(undefined)
let searchQuery = $state('')
@@ -60,10 +67,13 @@
loading = true
error = undefined
try {
comparison = await WorkspaceService.compareWorkspaces({
const backend = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId
})
// Merge local (localStorage) drafts so uncommitted session changes
// show up alongside the backend fork-vs-parent diff.
comparison = await augmentForkComparisonWithLocalDrafts(backend, 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.
@@ -81,9 +91,13 @@
}
}
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict' | 'localDraft'
function statusOf(d: WorkspaceItemDiff): DiffStatus {
// Items that exist only as a new local draft (not on the fork server) get
// their own status — they're uncommitted session changes, not a
// fork-vs-parent version delta.
if ((d as AugmentedWorkspaceItemDiff).newLocalDraft) return 'localDraft'
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'
@@ -148,7 +162,9 @@
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
d.exists_in_fork
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
? // Fork side prefers the local draft (uncommitted session change)
// over the deployed value, so the diff reflects pending edits.
getForkItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
@@ -176,18 +192,24 @@
}
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' | 'violet' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
if (s === 'localDraft') return 'violet'
return 'blue'
}
function statusLabel(s: DiffStatus): string {
return s === 'localDraft' ? 'local draft' : s
}
const statusIcons = {
added: Plus,
removed: Minus,
modified: Pencil,
conflict: AlertTriangle
conflict: AlertTriangle,
localDraft: FilePen
}
// File tree built from the diff paths. Top-level rows mirror
@@ -526,7 +548,9 @@
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
: status === 'localDraft'
? 'bg-violet-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
@@ -686,9 +710,20 @@
{#if d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
{#if (d as AugmentedWorkspaceItemDiff).localChanges}
<Badge
color="yellow"
title="This {(
KIND_LABELS[d.kind] ?? d.kind
).toLowerCase()} has local changes; if you deploy it they will be dropped"
>
<AlertTriangle class="w-3 h-3 inline mr-0.5" />
local changes detected
</Badge>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
{statusLabel(status)}
</Badge>
</div>
</summary>
@@ -0,0 +1,245 @@
import { deepEqual } from 'fast-equals'
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { getItemValue } from '$lib/utils_workspace_deploy'
import type { Kind } from '$lib/utils_deployable'
import type { WorkspaceComparison, WorkspaceItemDiff } from '$lib/gen'
// The backend `compareWorkspaces` API diffs the fork's *committed* (deployed)
// state against its parent. It cannot see local drafts, which live only in
// the browser's localStorage (the `UserDraft` store, keyed by the fork
// workspace id). This module augments a `WorkspaceComparison` with those
// local drafts so the Fork Diff Viewer / compare page can surface
// uncommitted session changes for review.
//
// Local-draft items are flagged `localDraft: true`. They are NOT in the
// fork's backend DB, so they must be shown read-only and excluded from the
// compare page's deployable set.
export type ForkDiffKind = WorkspaceItemDiff['kind']
export type AugmentedWorkspaceItemDiff = WorkspaceItemDiff & {
/** Case 1: the item exists on the fork server (deployed) AND its local
* (localStorage) draft differs from that server value. Deploying drops the
* local changes — rendered with a warning + "show local changes" diff. */
localChanges?: boolean
/** Case 2: the item exists ONLY as a local draft (not on the fork server).
* It cannot be deployed until saved in the fork — rendered dimmed. */
newLocalDraft?: boolean
/** Row was synthesized from a local draft (no corresponding entry in the
* backend fork-vs-parent diff) → not deployable from the compare page. */
localOnly?: boolean
}
export type AugmentedWorkspaceComparison = Omit<WorkspaceComparison, 'diffs'> & {
diffs: AugmentedWorkspaceItemDiff[]
}
// UserDraft kind → compare-API kind. Kinds without a `WorkspaceItemDiff`
// equivalent (trigger_poll / cli / nextcloud / google / github) are omitted
// and skipped during augmentation.
const DRAFT_KIND_TO_FORK_KIND: Partial<Record<UserDraftItemKind, ForkDiffKind>> = {
script: 'script',
flow: 'flow',
app: 'app',
raw_app: 'raw_app',
resource: 'resource',
variable: 'variable',
trigger_schedule: 'schedule',
trigger_http: 'http_trigger',
trigger_websocket: 'websocket_trigger',
trigger_kafka: 'kafka_trigger',
trigger_nats: 'nats_trigger',
trigger_postgres: 'postgres_trigger',
trigger_mqtt: 'mqtt_trigger',
trigger_sqs: 'sqs_trigger',
trigger_gcp: 'gcp_trigger',
trigger_azure: 'azure_trigger',
trigger_email: 'email_trigger',
trigger_default_email: 'email_trigger'
}
// Reverse map (first draft kind wins for shared targets like email_trigger).
const FORK_KIND_TO_DRAFT_KIND: Partial<Record<ForkDiffKind, UserDraftItemKind>> = (() => {
const out: Partial<Record<ForkDiffKind, UserDraftItemKind>> = {}
for (const [draftKind, forkKind] of Object.entries(DRAFT_KIND_TO_FORK_KIND)) {
if (forkKind && !(forkKind in out)) out[forkKind] = draftKind as UserDraftItemKind
}
return out
})()
function diffKey(kind: string, path: string): string {
return `${kind}/${path}`
}
// Project a draft value and a deployed item value to the same comparable
// shape so a draft that merely mirrors the deployed item (e.g. from opening
// an item in the session preview without editing) is recognised as
// "no change". Best-effort for raw_app (deployed apps nest content under
// `value`, drafts keep it flat).
function comparableProjection(kind: ForkDiffKind, v: any): unknown {
if (v == null) return v
if (kind === 'script') {
return { content: v.content, language: v.language, summary: v.summary, schema: v.schema }
}
if (kind === 'flow') {
return { value: v.value, schema: v.schema, summary: v.summary }
}
if (kind === 'raw_app') {
const value = v.value ?? v
return {
files: value?.files ?? v.files,
runnables: value?.runnables ?? v.runnables,
summary: v.summary
}
}
return v
}
// JSON round-trip both sides (drops `undefined`-valued keys, normalizes) then
// deep-compare — mirrors `normalizeForCompare` in userDraft.svelte.ts.
function normalize(v: unknown): unknown {
if (v === undefined) return undefined
try {
return JSON.parse(JSON.stringify(v))
} catch {
return v
}
}
// `getItemValue` resolves to an empty object `{}` (rather than throwing or
// returning null) when the item doesn't exist on the server. Treat that — and
// null/undefined — as "absent".
function isPresent(v: unknown): boolean {
if (v == null) return false
if (typeof v === 'object') return Object.keys(v as object).length > 0
return true
}
function draftDiffersFromDeployed(
kind: ForkDiffKind,
draftValue: unknown,
deployedValue: unknown
): boolean {
return !deepEqual(
normalize(comparableProjection(kind, draftValue)),
normalize(comparableProjection(kind, deployedValue))
)
}
/**
* Merge local drafts (browser localStorage, scoped to `forkWorkspaceId`) into
* a backend `WorkspaceComparison`:
* - a draft matching an existing diff flags that diff `localDraft` (its fork
* side should be read from the draft);
* - a draft with no matching diff is added as a synthetic `localDraft` entry,
* after filtering no-op baseline drafts (draft identical to the deployed
* item) for the loader-seeded kinds.
*
* Async: it fetches the fork's deployed value for draft-only candidates to
* decide whether the draft is a real change. The number of such fetches is
* bounded by how many items the session touched.
*/
export async function augmentForkComparisonWithLocalDrafts(
comparison: WorkspaceComparison,
forkWorkspaceId: string
): Promise<AugmentedWorkspaceComparison> {
const diffs: AugmentedWorkspaceItemDiff[] = comparison.diffs.map((d) => ({ ...d }))
const byKey = new Map<string, AugmentedWorkspaceItemDiff>()
for (const d of diffs) byKey.set(diffKey(d.kind, d.path), d)
const drafts = UserDraft.list({ workspace: forkWorkspaceId })
for (const entry of drafts) {
const forkKind = DRAFT_KIND_TO_FORK_KIND[entry.itemKind]
if (!forkKind) continue
// Skip "new item" scaffold drafts stored at an empty path — they aren't
// real workspace items yet and would otherwise render as a pathless,
// summary-less "local draft" row (duplicating the real, named entry).
if (!entry.path || !entry.path.trim()) continue
const key = diffKey(forkKind, entry.path)
// The fork's server (deployed) value, used to (a) tell a real local edit
// from a no-op baseline draft (the session loaders seed a draft equal to
// the loaded value on open) and (b) decide whether the item is on the
// fork server at all (Case 1 vs Case 2).
let serverValue: unknown
try {
serverValue = await getItemValue(forkKind as Kind, entry.path, forkWorkspaceId)
} catch {
serverValue = undefined
}
const onServer = isPresent(serverValue)
const differs = !onServer || draftDiffersFromDeployed(forkKind, entry.value, serverValue)
// Draft equals the server value → no local change. Leave any existing
// backend diff untouched and add no synthetic row.
if (onServer && !differs) continue
const existing = byKey.get(key)
if (existing) {
// Case 1: a backend fork-vs-parent diff that also carries a divergent
// local draft. Stays deployable (deploys the server value); the local
// changes would be dropped — flagged for a warning.
existing.localChanges = true
continue
}
if (onServer) {
// Case 1 with no fork-vs-parent delta (server == parent): review-only.
const synthetic: AugmentedWorkspaceItemDiff = {
kind: forkKind,
path: entry.path,
ahead: 1,
behind: 0,
has_changes: true,
exists_in_source: true,
exists_in_fork: true,
localChanges: true,
localOnly: true
}
diffs.push(synthetic)
byKey.set(key, synthetic)
} else {
// Case 2: brand-new local item, not on the fork server → cannot deploy.
const synthetic: AugmentedWorkspaceItemDiff = {
kind: forkKind,
path: entry.path,
ahead: 1,
behind: 0,
has_changes: true,
exists_in_source: false,
exists_in_fork: true,
newLocalDraft: true,
localOnly: true
}
diffs.push(synthetic)
byKey.set(key, synthetic)
}
}
const added = diffs.length - comparison.diffs.length
const summary = {
...comparison.summary,
total_diffs: comparison.summary.total_diffs + added,
total_ahead: comparison.summary.total_ahead + added
}
return { ...comparison, diffs, summary }
}
/**
* Fork-side value for a diff item: the local draft when one exists, else the
* deployed value from the backend. Use this (instead of `getItemValue` with
* the fork workspace) so the per-item diff shows pending local-draft content.
*/
export async function getForkItemValue(
kind: Kind,
path: string,
forkWorkspaceId: string
): Promise<unknown> {
const draftKind = FORK_KIND_TO_DRAFT_KIND[kind as ForkDiffKind]
if (draftKind) {
const draft = UserDraft.get(draftKind, path, { workspace: forkWorkspaceId })
if (draft != null) return draft
}
return getItemValue(kind, path, forkWorkspaceId)
}
@@ -10,8 +10,7 @@ import {
type AppWithLastVersion,
type Flow,
type NewScript,
type NewScriptWithDraft,
type WorkspaceComparison
type NewScriptWithDraft
} 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'
@@ -34,6 +33,10 @@ import {
setGetPreviewStatusHandler,
setOpenPreviewHandler
} from '$lib/components/copilot/chat/global/core'
import {
augmentForkComparisonWithLocalDrafts,
type AugmentedWorkspaceComparison
} from './forkDraftDiff'
export interface SessionRuntime {
readonly sessionId: string
@@ -111,7 +114,7 @@ export interface SessionRuntime {
// 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 forkComparison: { val: AugmentedWorkspaceComparison | undefined }
readonly loadingForkComparison: boolean
ensureForkComparison(parent: string, fork: string): Promise<void>
invalidateForkComparison(): void
@@ -184,7 +187,9 @@ function createRuntime(session: Session): SessionRuntime {
let notFoundRawApp = $state(false)
let loadedRawAppPath = $state<string | undefined>(undefined)
const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined })
const forkComparison: { val: AugmentedWorkspaceComparison | undefined } = $state({
val: undefined
})
let loadingForkComparison = $state(false)
let forkComparisonKey: string | undefined = undefined
@@ -491,10 +496,11 @@ function createRuntime(session: Session): SessionRuntime {
forkComparisonKey = key
loadingForkComparison = true
try {
forkComparison.val = await WorkspaceService.compareWorkspaces({
const backend = await WorkspaceService.compareWorkspaces({
workspace: parent,
targetWorkspaceId: fork
})
forkComparison.val = await augmentForkComparisonWithLocalDrafts(backend, fork)
} catch (e) {
console.error('SessionRuntime: forkComparison fetch failed', e)
forkComparison.val = undefined
@@ -525,10 +531,11 @@ function createRuntime(session: Session): SessionRuntime {
if (loadingForkComparison) return
loadingForkComparison = true
try {
forkComparison.val = await WorkspaceService.compareWorkspaces({
const backend = await WorkspaceService.compareWorkspaces({
workspace: parent,
targetWorkspaceId: fork
})
forkComparison.val = await augmentForkComparisonWithLocalDrafts(backend, fork)
} catch (e) {
console.error('SessionRuntime: forkComparison refresh failed', e)
} finally {