feat: chat-scoped session changes bar + unified diff drawer (#9762)

* feat(frontend): chat-scoped unified session changes bar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): drop diff-baseline toggle, show natural per-row diffs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): carry Draft marker to expanded raw-app file rows

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): show raw-app Draft badge once at tree root, not per file

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): reuse shared DraftBadge in session diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): show draft-author avatars in session diff badge, icon-only in sidebar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): keep badge pill around avatar in icon-only DraftBadge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): small draft marker = indigo pen + avatar; correct itemKind label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): drop package-lock churn from merge (match origin/main)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): dedup diff-button count for legacy fork sessions; test mask helper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): collapsible session diff panel + per-row open-diff action

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): shared sessionDeployModel for review & deploy (S1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): model-driven session review drawer, deploy inert (S2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): wire session deploy + on-behalf/conflict gating (S3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): behind banner + Update fork + deployment request (S4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): session changes dock opens drawer by filter (S5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): session review UI polish (badge, dock, In parent, tree width)

- draft rows show only the avatar DraftBadge, not a duplicate state pill
- drop redundant dock Review button (same as "N to review")
- rename Done -> In parent with a "deployed in parent workspace" tooltip
- widen the file tree

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): existence-gate In parent rows; Badge filters; badge hover

- drop discarded mask-only items from the In-parent segment (existence check)
- use the Badge component for the drawer filter segments and the changes dock
- soften the blue Badge hover (blue-50 base was jumping to blue-200)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): polish session diff drawer (layout, badges, actions)

- remove empty fork-banner gap; uniform sidebar tree padding
- full-bleed diff list: drop card borders/side padding, separators between items
- clamp tree x-overflow; right-align tree badges (min-w-0 on the row button)
- brand-compliant selected filter badges; smaller draft badge
- hide per-row open-diff button when the panel is open
- rename "Delete draft" to "Discard draft" (destructive); remove header Review button
- larger sm deploy/discard action buttons; remove per-item diff-content collapse

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): session Edits dock — deploy gating + change-op tracking

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): session bar per-status badges; drop change-op tracking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): unwrap raw apps into per-file tree in session diff sidebar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): dot-parcours pipeline (badge-derived, melt tooltip) + discard confirm

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): behind-only session item reads as deployed, not bare

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(frontend): even sidebar tree margins; gutter-aware right padding

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): pass chat id as from_session; wire deploying flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): session diff drawer scroll-to-flush, ordering, spacer, deploy gating

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): preserve chat mask on compact; guard stale existence checks; clear poll timers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): refresh bar after drawer deploys; conflict hint over chip; plain conflict badge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(frontend): diff drawer card layout with flash ring and aligned insets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): hide stale deployed chip once row status badge reads deployed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(frontend): session dock to two states; drop parent deploy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): staged deploy animation in session edits drawer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): stale-draft warning in session edits drawer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): keep chat mask honest on deploy and discard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): map trigger_email deploy kind; serialize mask persists

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): reset mask on new chat; close review-flagged races

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(frontend): rename session drawer title to Edited during session

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): keep mask persist queue alive after a failed save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): sync session chatId on chat rotation; gate deploy on canWrite

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): keep compare handoff for deletion-only session edits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): hold deploy success beat across re-keyed rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-07-06 09:23:54 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 234a7254d5
commit a6c0b3756b
39 changed files with 3080 additions and 1448 deletions
@@ -14,7 +14,13 @@
import { editUrlFor } from './sessions/forkEditUrl'
import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy'
import {
getDraftDiffValues,
deployDraft,
discardDraft,
draftBaseIsStale
} from '$lib/utils_draft_deploy'
import { checkDeployPermission, type DeployPermission } from '$lib/utils_workspace_deploy'
import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import type { Kind as LayoutKind } from '$lib/utils_deployable'
import { userStore } from '$lib/stores'
@@ -35,6 +41,15 @@
deployCount?: number
updateCount?: number
draftCount?: number
/** When set (reached via a session's Review button), preselect only the
* rows this chat modified — `${UserDraftItemKind}:${path}` keys, matching
* Row.key. Undefined → preselect all deployable rows (the default). All rows
* are still shown either way. */
chatMask?: Set<string>
/** False while the (async) chatMask is still loading. The select-all default
* waits for this so it doesn't race the mask and select everything. Defaults
* to true for callers that don't pass a mask. */
chatMaskReady?: boolean
/** Selecting deploy_to/update asks the page to swap to CompareWorkspaces. */
onModeSelected?: (v: CompareMode) => void
/** Fired after a deploy/discard so the page can refresh the *fork*
@@ -52,6 +67,8 @@
deployCount = 0,
updateCount = 0,
draftCount = 0,
chatMask,
chatMaskReady = true,
onModeSelected,
onChanged
}: Props = $props()
@@ -216,27 +233,11 @@
path: item.path,
getDraft: true
}))) as any
// A draft is stale when the version it forked from no longer matches the
// current deployed head: a newer version was deployed after the draft began.
// Scripts compare `parent_hash` vs the deployed `hash`; flows the pinned
// `version_id` vs the deployed head `version_id`; apps the pinned
// `parent_version` vs the deployed head (`versions[last]`).
const draftBlob = r.draft as any
const appHead = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined
const stale =
item.draftKind === 'script'
? !!r.hash && !!draftBlob?.parent_hash && draftBlob.parent_hash !== r.hash
: item.draftKind === 'flow'
? r.version_id != null &&
draftBlob?.version_id != null &&
draftBlob.version_id !== r.version_id
: appHead != null &&
draftBlob?.parent_version != null &&
draftBlob.parent_version !== appHead
summaryCache[item.key] = {
deployed: r.summary,
draft: draftBlob?.summary,
stale,
stale: draftBaseIsStale(item.draftKind, r),
loading: false
}
} catch (error) {
@@ -263,6 +264,21 @@
let selectedItems = $state<string[]>([])
let deploying = $state(false)
// Whether the user may deploy drafts into this workspace — fills the
// `RestrictDeployToDeployers` (+ operator) gap via the shared util, same as the
// fork compare page and the session review drawer. Fail-open while resolving.
let deployPerm = $state<DeployPermission>({ ok: true })
$effect(() => {
const ws = currentWorkspaceId
// Reset to fail-open on workspace change, and drop a stale resolution —
// otherwise the previous workspace's verdict lingers (or lands last) and
// gates the wrong workspace.
deployPerm = { ok: true }
void checkDeployPermission(ws).then((p) => {
if (ws === currentWorkspaceId) deployPerm = p
})
})
// Select all on the first non-empty load (deploy-all is the common intent);
// only once, so a refetch after a deploy doesn't re-select the leftovers.
let hasAutoSelected = $state(false)
@@ -286,8 +302,13 @@
})
$effect(() => {
if (!hasAutoSelected && visibleItems.length > 0) {
selectedItems = visibleItems.filter(isSelectable).map((i) => i.key)
if (!hasAutoSelected && chatMaskReady && visibleItems.length > 0) {
// Default intent is deploy-all; when reached from a session's Review
// (chatMask set), preselect only that chat's items instead.
const selectable = visibleItems.filter(isSelectable)
selectedItems = (chatMask ? selectable.filter((i) => chatMask.has(i.key)) : selectable).map(
(i) => i.key
)
hasAutoSelected = true
}
})
@@ -695,15 +716,19 @@
{/snippet}
{#snippet footer()}
<div class="flex items-center justify-end">
<div class="flex flex-col items-end gap-2">
<Button
variant="accent"
disabled={selectedCount === 0 || deploying}
disabled={selectedCount === 0 || deploying || !deployPerm.ok}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
onClick={deploySelected}
>
Deploy {selectedCount} draft{selectedCount !== 1 ? 's' : ''}
</Button>
{#if !deployPerm.ok}
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
{/if}
</div>
{/snippet}
</WorkspaceDeployLayout>
@@ -37,10 +37,12 @@
import type { Kind } from '$lib/utils_deployable'
import {
checkDeployPermission,
deployItem,
deleteItemInWorkspace,
getItemValue,
getOnBehalfOf,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
import { isTriggerOrScheduleKind } from 'windmill-utils-internal'
@@ -58,6 +60,7 @@
import { base } from '$lib/base'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import { editUrlFor } from './sessions/forkEditUrl'
import { diffInMask } from './sessions/modifiedItemsMask'
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
interface Props {
@@ -77,6 +80,14 @@
* out of the default selection — deploying/updating moves the deployed
* version, not the draft. The page derives this from the fork drafts. */
draftKeys?: Set<string>
/** When set (reached via a session's Review button), preselect only the
* diffs this chat caused (matched via diffInMask). The deploy_to default
* narrows to these; the update direction (parent→fork) preselects nothing,
* since it's never chat-caused. All rows are still shown. */
chatMask?: Set<string>
/** False while the (async) chatMask is still loading. The select-all default
* waits for this so it doesn't race the mask. Defaults to true. */
chatMaskReady?: boolean
/** Selecting `draft` asks the page to swap us out for CompareDrafts;
* deploy_to/update are handled internally but reported so the page can
* remember the direction. */
@@ -95,6 +106,8 @@
updateCount = 0,
draftCount = 0,
draftKeys = new Set<string>(),
chatMask,
chatMaskReady = true,
onModeSelected,
onChanged
}: Props = $props()
@@ -752,11 +765,19 @@
// to parent" flow. The user picks them à la carte by clicking the row.
// Items with a pending draft are also left out by default: the deployed
// version (not the draft) is what moves, so we make the user opt in.
// The update direction (parent→fork) is never something the chat caused, so
// when scoped to a chat's items (chatMask set) preselect nothing there.
if (chatMask && !mergeIntoParent) {
selectedItems = []
return
}
const filtered = selectableDiffs.filter((d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d))
const conflictSafe = mergeIntoParent
? filtered
: filtered.filter((d) => !(d.ahead > 0 && d.behind > 0))
selectedItems = conflictSafe
// When reached from a session's Review, narrow the default to this chat's items.
const scoped = chatMask ? conflictSafe.filter((d) => diffInMask(d, chatMask)) : conflictSafe
selectedItems = scoped
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
@@ -798,6 +819,21 @@
fetchPermissions()
})
// Can the user actually deploy into the target workspace? Fills the frontend
// gap for the `RestrictDeployToDeployers` rule (+ operator), shared with the
// session review drawer via the same checkDeployPermission util. Cached per
// workspace; `deployPerm` tracks whichever side the current direction targets.
let deployPerms = $state<Record<string, DeployPermission>>({})
const deployPermFetched = new Set<string>()
$effect(() => {
for (const ws of [currentWorkspaceId, parentWorkspaceId]) {
if (!ws || deployPermFetched.has(ws)) continue
deployPermFetched.add(ws)
void checkDeployPermission(ws).then((p) => (deployPerms = { ...deployPerms, [ws]: p }))
}
})
let deployPerm = $derived(deployPerms[deployTargetWorkspace] ?? { ok: true })
// Fetch summaries and on_behalf_of_email when comparison data loads
$effect(() => {
if (comparison?.diffs) {
@@ -808,7 +844,7 @@
// Auto-select items on initial load
$effect(() => {
if (comparison?.diffs && !hasAutoSelected && selectableDiffs.length > 0) {
if (comparison?.diffs && !hasAutoSelected && chatMaskReady && selectableDiffs.length > 0) {
selectDefault()
hasAutoSelected = true
}
@@ -1361,7 +1397,9 @@
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
(mergeIntoParent && !canDeployToParent) ||
!deployPerm.ok ||
hasUnselectedOnBehalfOf}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
on:click={requestDeploy}
>
@@ -1372,6 +1410,9 @@
{/if}
</Button>
</div>
{#if !deployPerm.ok}
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
{/if}
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
+47 -27
View File
@@ -38,6 +38,10 @@
/** Offer "Load" alongside "View Diff" on other users' rows. The deploy /
* review page sets this false: loading into a fresh editor is moot there. */
allowFork?: boolean
/** Compact variant: render only the avatar circles (no "Draft" pill text),
* slightly smaller — for tight spots like the diff-tree sidebar. The hover
* popover is unchanged. */
iconOnly?: boolean
}
let {
@@ -49,7 +53,8 @@
itemKind = undefined,
path = undefined,
onMigrated = undefined,
allowFork = true
allowFork = true,
iconOnly = false
}: Props = $props()
// Authed user lands first; everyone else keeps the backend's ordering.
@@ -193,6 +198,30 @@
}
</script>
{#snippet circleStack()}
<!-- `-space-x-1` overlaps the circles; the indigo ring tint makes the overlap read intentional. -->
<span class="flex -space-x-1">
{#each visibleUsers as u, i (i)}
<span
class="inline-flex h-3 w-3 items-center justify-center rounded-full text-[8px] font-semibold text-white ring-1 ring-indigo-100 dark:ring-indigo-700/40 {colorFor(
u
)}"
title={u.username === currentUsername ? `${fullLabel(u)} (you)` : fullLabel(u)}
>
{initials(u)}
</span>
{/each}
{#if overflowCount > 0}
<span
class="inline-flex h-3 w-3 items-center justify-center rounded-full bg-gray-500 text-[8px] font-semibold text-white ring-1 ring-indigo-100 dark:ring-indigo-700/40"
title="{overflowCount} more"
>
+{overflowCount}
</span>
{/if}
</span>
{/snippet}
{#if showBadge}
<!-- inline-flex/items-center so the trigger button hugs the badge and lines up
with sibling badges (a plain button is taller, dropping the pill ~2px). -->
@@ -204,32 +233,23 @@
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<Badge small color="indigo">
{#if orderedUsers.length > 0}
<!-- `-space-x-1` overlaps the circles; the indigo ring tint makes the overlap read intentional. -->
<span class="flex -space-x-1">
{#each visibleUsers as u, i (i)}
<span
class="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-[8px] font-semibold text-white ring-1 ring-indigo-100 dark:ring-indigo-700/40 {colorFor(
u
)}"
title={u.username === currentUsername ? `${fullLabel(u)} (you)` : fullLabel(u)}
>
{initials(u)}
</span>
{/each}
{#if overflowCount > 0}
<span
class="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full bg-gray-500 text-[8px] font-semibold text-white ring-1 ring-indigo-100 dark:ring-indigo-700/40"
title="{overflowCount} more"
>
+{overflowCount}
</span>
{/if}
</span>
{/if}
{draft_only ? 'Draft only' : 'Draft'}
</Badge>
{#if iconOnly}
<!-- Compact "has a draft" marker for tight spots like the diff-tree
sidebar: a small indigo pencil + the author avatar(s). -->
<span class="inline-flex items-center gap-0.5" title={draft_only ? 'Draft only' : 'Draft'}>
<Pencil class="h-3 w-3 shrink-0 text-indigo-500 dark:text-indigo-400" />
{#if orderedUsers.length > 0}
{@render circleStack()}
{/if}
</span>
{:else}
<Badge small color="indigo" class="px-1 py-0 gap-0.5">
{#if orderedUsers.length > 0}
{@render circleStack()}
{/if}
{draft_only ? 'Draft only' : 'Draft'}
</Badge>
{/if}
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2 min-w-[16rem] text-xs p-4 pb-1">
@@ -90,7 +90,7 @@
const hovers: Partial<Record<BadgeColor, string>> = {
gray: 'hover:bg-surface-hover',
blue: 'hover:bg-blue-200 dark:hover:bg-blue-700/40',
blue: 'hover:bg-blue-100 dark:hover:bg-blue-700/60',
red: 'hover:bg-red-200 dark:hover:bg-red-500/25',
green: 'hover:bg-green-200 dark:hover:bg-green-500/25',
yellow: 'hover:bg-yellow-200 dark:hover:bg-yellow-500/25',
@@ -44,6 +44,9 @@ import {
buildSummaryMessageContent
} from './compactionPrompt'
import { dfs } from '$lib/components/flows/previousResults'
import { SvelteSet } from 'svelte/reactivity'
import type { UserDraftItemKind } from '$lib/gen'
import { maskKey } from '$lib/components/sessions/modifiedItemsMask'
import { getStringError } from './utils'
import { type PasteAttachment } from './pasteTokens'
import { chatDraft, expanded } from './chatDraft'
@@ -343,6 +346,75 @@ export class AIChatManager {
// session rather than the UI-active one — keeps backgrounded sessions isolated.
sessionId: string | undefined = undefined
// Fired whenever the active chat id changes away from the one the consumer
// knows (a "/clear" rotation or a history switch). Session runtimes wire this
// to keep the session record's chatId aligned — the compare-page handoff
// (`from_session`) reads it, and a stale id would preselect the previous
// chat's items. Set here (not imported) to avoid a copilot→sessions cycle.
onChatRotated: ((chatId: string) => void) | undefined = undefined
// Workspace items the CURRENT chat modified via AI tool calls, as
// `${UserDraftItemKind}:${storagePath}` keys (see modifiedItemsMask.ts).
// undefined = untracked: the global side-panel chat (never initialised) and
// loaded legacy chats with no stored mask, both of which fall back to the
// show-all bar. A SvelteSet (even empty) = tracked. Reactive so the session
// bar updates as tools record mid-turn.
modifiedItems = $state<SvelteSet<string> | undefined>(undefined)
// Start tracking for a brand-new session chat (empty = "tracked, nothing yet").
initModifiedItemsTracking() {
this.modifiedItems = new SvelteSet()
}
// Record an item an AI tool call created/edited/deleted. No-op when untracked
// (the global singleton never initialises the set), so it stays unaffected.
recordModifiedItem(itemKind: UserDraftItemKind, storagePath: string) {
this.modifiedItems?.add(maskKey(itemKind, storagePath))
}
// Un-record an item whose chat-made change was discarded — without this the
// still-existing deployed item would keep reading as this chat's "Deployed"
// edit. Persisted immediately: unlike recordModifiedItem (whose persistence
// rides on the turn's saveChat), a discard can fire from the review dock
// outside any turn, and waiting would resurrect the entry on reload.
async removeModifiedItem(itemKind: UserDraftItemKind, storagePath: string) {
if (!this.modifiedItems?.delete(maskKey(itemKind, storagePath))) return
await this.#persistModifiedItems()
}
// Move a mask entry to the path a draft actually deployed to. A draft-only
// flow/app parks at a synthetic `draft_{uuid}` storage path and deploys to
// its chosen path — without the move, the existence check at the synthetic
// path fails after reload and the deployed row vanishes from the dock.
async renameModifiedItem(itemKind: UserDraftItemKind, fromPath: string, toPath: string) {
if (fromPath === toPath) return
if (!this.modifiedItems?.delete(maskKey(itemKind, fromPath))) return
this.modifiedItems.add(maskKey(itemKind, toPath))
await this.#persistModifiedItems()
}
// Serialized, snapshot-at-write-time persistence: two rapid dock actions
// would otherwise race their saveChat writes, and the earlier (staler)
// snapshot could land last — dropping the later mutation until the next
// turn-end save.
#maskPersistQueue: Promise<void> = Promise.resolve()
#persistModifiedItems(): Promise<void> {
this.#maskPersistQueue = this.#maskPersistQueue.then(() =>
this.historyManager
.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
// Swallow (and log) a failed write so it can't wedge the queue as a
// rejected link — the next persist snapshots the full current set, so
// a lost write self-heals on the next mutation or turn-end save.
.catch((e) => console.error('Failed to persist modified-items mask', e))
)
return this.#maskPersistQueue
}
// Workspace AI skills (name + description) advertised in the GLOBAL system
// prompt and surfaced as slash commands in session chat. Loaded
// asynchronously when entering GLOBAL mode; the system message is rebuilt
@@ -659,7 +731,12 @@ export class AIChatManager {
)
switch (result) {
case 'ok':
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
sendUserToast('Conversation compacted.')
break
case 'empty':
@@ -1636,7 +1713,12 @@ export class AIChatManager {
const projectedContextTokens = this.contextTokens + this.estimateMessagesTokens([userMessage])
this.messages.push(userMessage)
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
this.currentReply = ''
this.currentReasoning = ''
@@ -1672,7 +1754,12 @@ export class AIChatManager {
this.contextUsage = Math.max(0, this.contextUsage - freed)
}
}
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
}
}
// Rollback anchors for restoreUnsentTurn: captured after compaction so
@@ -1758,7 +1845,10 @@ export class AIChatManager {
},
requestConfirmation: this.requestConfirmation,
shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive,
requestUserQuestion: this.requestUserQuestion
requestUserQuestion: this.requestUserQuestion,
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path)
}
}
@@ -1794,7 +1884,12 @@ export class AIChatManager {
this.contextUsage = result?.lastIterationUsage
? result.lastIterationUsage.prompt + result.lastIterationUsage.completion
: undefined
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
// Still counts as the saved first turn — skipping the hook here would
// permanently miss it (the next turn isn't "first" anymore).
if (isFirstUserTurn && this.afterFirstTurnSaved) {
@@ -1824,7 +1919,12 @@ export class AIChatManager {
// user message on reload. Remove it instead.
this.historyManager.deletePastChat(this.historyManager.getCurrentChatId())
} else {
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
}
if (!wasAborted) {
sendUserToast('The model returned no response — your message was restored to the input.')
@@ -1843,7 +1943,12 @@ export class AIChatManager {
if (this.autoAcceptEditsActive) {
this.acceptPendingFlowEdits()
}
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
// Only this branch is a clean send: the queued-message flush below
// auto-sends the next message after it (set after saveChat so a
// persistence failure falls through to the restore path instead).
@@ -1867,7 +1972,12 @@ export class AIChatManager {
// compaction on the next send instead of failing the same way again.
this.contextUsage = undefined
try {
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
} catch (saveErr) {
console.error('Failed to persist partial chat after error', saveErr)
}
@@ -1996,15 +2106,25 @@ export class AIChatManager {
// Drop any message queued in this conversation so it can't auto-send into
// the fresh chat or linger as a card across the switch.
this.queuedMessage = ''
await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage)
await this.historyManager.save(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
this.displayMessages = []
this.messages = []
this.contextUsage = undefined
// The mask belongs to the conversation just saved — the fresh chat starts
// its own (empty) tracking; carrying entries over would claim the previous
// conversation's edits for the new one. Untracked chats stay untracked.
if (this.modifiedItems) this.modifiedItems = new SvelteSet()
// In an AI session, linked files are session-scoped: they persist across conversations
// (cleared only when the session is deleted). The ephemeral global side-panel chat has no
// session, so "New chat" must clear them — otherwise the next, unrelated conversation
// would still get the previous file roster and could read/search it.
if (!this.isSessionChat) this.attachedFiles.clear()
this.onChatRotated?.(this.historyManager.getCurrentChatId())
}
loadPastChat = async (id: string) => {
@@ -2019,7 +2139,16 @@ export class AIChatManager {
this.displayMessages = chat.displayMessages
this.messages = chat.actualMessages
this.contextUsage = normalizeContextUsage(chat.contextUsage)
// Seed the modified-items mask from the stored chat. A stored array
// (even empty) → tracked; a legacy chat with no field stays untracked
// (undefined) so the session bar falls back to showing all drafts. The
// global side-panel chat never tracks, so leave it untouched there.
if (this.isSessionChat) {
const stored = this.historyManager.getModifiedItems(id)
this.modifiedItems = stored !== undefined ? new SvelteSet(stored) : undefined
}
this.#automaticScroll = true
this.onChatRotated?.(id)
}
}
@@ -693,7 +693,9 @@ describe('AIChatManager context compaction', () => {
expect(manager.messages[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) })
// Mid-turn, the report is debited by the freed estimate (visible in the
// compaction-time save) so a rolled-back turn keeps a consistent value
expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000)
// 4th arg: the modified-items mask rides on every save (undefined here —
// this bare manager never initialised tracking).
expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000, undefined)
// At commit, the no-report turn clears the stored value; the readable
// number falls back to estimating the now-tiny compacted history
expect(manager.contextUsage).toBeUndefined()
@@ -25,6 +25,12 @@ interface ChatSchema extends IDBSchema {
// New writes store the plain reported token count; chats persisted by
// earlier versions may still hold the legacy anchor object.
contextUsage?: PersistedContextUsage
// Workspace items this chat modified via AI tool calls, as
// `${UserDraftItemKind}:${storagePath}` keys. Persisted out-of-band from
// the message arrays so it survives compaction. Absent (undefined) on
// chats predating this feature → consumers fall back to showing all
// workspace drafts; a defined array (even empty) means "tracked".
modifiedItems?: string[]
}
}
}
@@ -80,6 +86,29 @@ export function __resetLegacyChatClaimForTesting(): void {
legacyChatClaim = undefined
}
// Read a chat's modified-items mask by chatId WITHOUT mounting an AIChatManager,
// for the standalone /forks/compare route. Returns undefined for a legacy chat
// (no field) so the page falls back to selecting all items; a defined array
// (even empty) narrows the preselection. Opens a throwaway user-scoped handle;
// the `get` is O(1) on the `id` keyPath.
export async function readChatModifiedItems(chatId: string): Promise<string[] | undefined> {
const dbh = userScopedDb<ChatSchema>(DB_NAME, {
version: 1,
upgrade: createChatStore,
migrate: migrateLegacyChatDb
})
try {
const db = await dbh.whenReady()
const chat = await db?.get('chats', chatId)
return chat?.modifiedItems
} catch (err) {
console.error('Could not read chat modified items', err)
return undefined
} finally {
dbh.close()
}
}
export default class HistoryManager {
// Per-instance handle to the shared per-user DB lifecycle. There is one
// HistoryManager per AIChatManager (the singleton + one per session runtime),
@@ -100,6 +129,7 @@ export default class HistoryManager {
lastModified: number
sessionId?: string
contextUsage?: PersistedContextUsage
modifiedItems?: string[]
}
> = $state({})
@@ -173,10 +203,15 @@ export default class HistoryManager {
return Object.values(this.savedChats)
}
getModifiedItems(id: string): string[] | undefined {
return this.savedChats[id]?.modifiedItems
}
async saveChat(
displayMessages: DisplayMessage[],
messages: ChatCompletionMessageParam[],
contextUsage?: number
contextUsage?: number,
modifiedItems?: string[]
) {
if (displayMessages.length > 0) {
// Compaction replaces the original first message with a summary boundary.
@@ -203,7 +238,18 @@ export default class HistoryManager {
id: this.currentChatId,
lastModified: Date.now(),
...(this.sessionId ? { sessionId: this.sessionId } : {}),
...(contextUsage !== undefined ? { contextUsage } : {})
...(contextUsage !== undefined ? { contextUsage } : {}),
// Only persist when the caller passes a defined array. Loaded legacy
// chats keep their accumulator undefined, so we never retroactively
// stamp them with [] (which would flip them to the filtered view).
// But since `put` replaces the whole record, a caller that omits the
// argument must not ERASE a tracked chat's stored mask — fall back to
// the previously saved field.
...(modifiedItems !== undefined
? { modifiedItems }
: this.savedChats[this.currentChatId]?.modifiedItems !== undefined
? { modifiedItems: this.savedChats[this.currentChatId].modifiedItems }
: {})
}
this.savedChats = {
...this.savedChats,
@@ -218,9 +264,10 @@ export default class HistoryManager {
async save(
displayMessages: DisplayMessage[],
messages: ChatCompletionMessageParam[],
contextUsage?: number
contextUsage?: number,
modifiedItems?: string[]
) {
await this.saveChat(displayMessages, messages, contextUsage)
await this.saveChat(displayMessages, messages, contextUsage, modifiedItems)
this.currentChatId = createLongHash()
}
@@ -141,3 +141,30 @@ describe('HistoryManager title across compaction', () => {
expect(hm.getAllSavedChats().find((c) => c.id === id)?.title).toBe('original first question')
})
})
describe('HistoryManager modified-items mask persistence', () => {
const msgs = [{ role: 'user', content: 'hello', index: 0 }] as DisplayMessage[]
it('a save without the argument preserves a previously stored mask', async () => {
const hm = new HistoryManager()
await hm.init()
const id = hm.getCurrentChatId()
await hm.saveChat(msgs, [] as ChatCompletionMessageParam[], undefined, ['script:u/a/x'])
expect(hm.getModifiedItems(id)).toEqual(['script:u/a/x'])
// e.g. manual compaction re-saving the transcript: the whole record is
// rewritten, but the tracked mask must survive.
await hm.saveChat(msgs, [] as ChatCompletionMessageParam[])
expect(hm.getModifiedItems(id)).toEqual(['script:u/a/x'])
})
it('never retroactively stamps an untracked chat', async () => {
const hm = new HistoryManager()
await hm.init()
const id = hm.getCurrentChatId()
await hm.saveChat(msgs, [] as ChatCompletionMessageParam[])
expect(hm.getModifiedItems(id)).toBeUndefined()
})
})
@@ -114,6 +114,7 @@ import {
getEphemeralSecretVariableDraftValue,
getGlobalDraft,
getGlobalDraftStoragePath,
itemKindFor,
listGlobalDrafts,
persistGlobalDraft,
readGlobalDraftValue,
@@ -2779,6 +2780,7 @@ function finishAppDraftWrite(
): string {
const failure = draftWriteFailure(result, ctx)
if (failure) return failure
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
const { content, message } = onSaved()
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content, result: 'Saved as draft' })
return JSON.stringify({ success: true, message }, null, 2)
@@ -2791,6 +2793,7 @@ function finishDraftWrite(
): string {
const failure = draftWriteFailure(result, ctx)
if (failure) return failure
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
const stored = result.item
const verb = existed ? 'Updated' : 'Created'
// Don't echo the flow value back: the model just sent it in the write call,
@@ -3898,6 +3901,16 @@ async function discardLocalDraft(
await deleteGlobalDraft(workspace, type, path, triggerKind)
// The chat's touch on the item is undone — drop it from the mask so a
// pre-existing deployed item doesn't keep reading as this chat's edit.
const discardedKind = itemKindFor(type, triggerKind)
if (discardedKind) {
toolCallbacks.onItemDiscarded?.(
discardedKind,
getGlobalDraftStoragePath(workspace, type, path, triggerKind)
)
}
toolCallbacks.setToolStatus(toolId, {
content: `Discarded ${type} "${path}" draft`,
result: 'Draft discarded'
@@ -4237,6 +4250,9 @@ async function deployDraft(
})
let actions: ToolDisplayAction[] | undefined
// Where the deploy actually lands — the app branch can resolve a different
// target from the draft's own path fields; the mask rename below must track it.
let deployedPath = path
if (type === 'script' || type === 'flow') {
// Promote the full persisted draft via the shared deploy module — the same
@@ -4429,6 +4445,7 @@ async function deployDraft(
throw e
}
}
deployedPath = targetPath
if (await AppService.existsApp({ workspace, path: targetPath })) {
// Omit custom_path on update for now. The backend preserves it when absent, while
// sending it requires admin privileges; this chat deploy path does not yet mirror
@@ -4478,6 +4495,18 @@ async function deployDraft(
await deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
// Move the chat's mask entry to the deployed path: a draft-only item's
// synthetic storage key never exists deployed, so the entry would otherwise
// stop matching anything after the draft is gone.
const deployedKind = itemKindFor(type, triggerKind)
if (deployedKind) {
toolCallbacks.onItemDeployed?.(
deployedKind,
getGlobalDraftStoragePath(workspace, type, path, triggerKind),
deployedPath
)
}
// Reload the session preview if it's open on the deployed item. Map the
// deploy type to the preview kind — a raw app deploys under 'app' but the
// preview addresses it as 'raw_app'; non-previewable types map to undefined.
@@ -4548,6 +4577,17 @@ async function deleteWorkspaceItem(
await deleteGlobalDraft(workspace, type, path, triggerKind)
// Record the deletion in the chat's modified-items mask. In a fork this leaves a
// reviewable "removed" diff vs the parent that stays scoped to this chat. Keyed
// by the same (itemKind, storagePath) as writes so it joins the draft/fork lists.
const deletedKind = itemKindFor(type, triggerKind)
if (deletedKind) {
toolCallbacks.onItemModified?.(
deletedKind,
getGlobalDraftStoragePath(workspace, type, path, triggerKind)
)
}
toolCallbacks.setToolStatus(toolId, {
content: `Deleted ${type} "${path}"`,
result: 'Deleted'
@@ -108,7 +108,7 @@ function clearEphemeralSecretVariableDraftValues(workspace: string): void {
secretVariableDraftValues.delete(workspace)
}
function itemKindFor(
export function itemKindFor(
type: WorkspaceItemType,
triggerKind?: TriggerKind
): UserDraftItemKind | undefined {
@@ -377,10 +377,25 @@ export async function readGlobalDraftValue<V>(
return (await fetchBackendDraftValue(workspace, itemKind, storagePath)) as V | undefined
}
// `itemKind` + `storagePath` are the canonical identity of the persisted draft
// (NOT item.path, which is the friendly display path). Callers use them to record
// the chat's modified-items mask.
export type DraftPersistResult =
| { status: 'saved'; item: WorkspaceItem }
| { status: 'conflict'; item: WorkspaceItem; serverTimestamp?: string }
| { status: 'error'; item: WorkspaceItem; message: string }
| { status: 'saved'; item: WorkspaceItem; itemKind: UserDraftItemKind; storagePath: string }
| {
status: 'conflict'
item: WorkspaceItem
itemKind: UserDraftItemKind
storagePath: string
serverTimestamp?: string
}
| {
status: 'error'
item: WorkspaceItem
itemKind: UserDraftItemKind
storagePath: string
message: string
}
// Persist a built draft value. `UserDraft.seed` reflects it into an open editor's
// cell WITHOUT a double-POST (no-ops if no cell; its seedNextWrite suppresses the
@@ -417,14 +432,20 @@ export async function persistGlobalDraft(
// the chat "saved" while the DB-backed source of truth was never updated.
const saveState = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath })
if (saveState.state === 'failed') {
return { status: 'error', item, message: saveState.failureMessage ?? 'Draft save failed' }
return {
status: 'error',
item,
itemKind,
storagePath,
message: saveState.failureMessage ?? 'Draft save failed'
}
}
const conflict = opts.force
? undefined
: UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict
return conflict
? { status: 'conflict', item, serverTimestamp: conflict.serverTimestamp }
: { status: 'saved', item }
? { status: 'conflict', item, itemKind, storagePath, serverTimestamp: conflict.serverTimestamp }
: { status: 'saved', item, itemKind, storagePath }
}
export async function getGlobalDraft(
@@ -3,6 +3,7 @@ import type {
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam
} from 'openai/resources/chat/completions.mjs'
import type { UserDraftItemKind } from '$lib/gen'
/**
* Special module IDs used throughout the flow system
@@ -759,6 +760,15 @@ export interface ToolCallbacks {
toolId: string,
question: UserQuestionDisplay
) => Promise<string | undefined>
/** Records a workspace item the tool call created/edited/deleted, by its
* canonical (itemKind, storagePath). Session chats wire this to accumulate the
* chat's modified-items mask; the global side-panel chat omits it (no-op). */
onItemModified?: (itemKind: UserDraftItemKind, storagePath: string) => void
/** A tool deployed a draft: the mask entry moves from the draft's storage path
* to the deployed path (they differ for synthetic draft-only storage keys). */
onItemDeployed?: (itemKind: UserDraftItemKind, storagePath: string, deployedPath: string) => void
/** A tool discarded a draft: the chat's touch on the item is undone. */
onItemDiscarded?: (itemKind: UserDraftItemKind, storagePath: string) => void
}
export function createToolDef(
@@ -1,204 +0,0 @@
<script lang="ts">
import WorkspaceDiffDrawer, { type DiffRow } from './WorkspaceDiffDrawer.svelte'
import { Pencil } from 'lucide-svelte'
import { DraftService, ScriptService, type WorkspaceItemDiff } from '$lib/gen'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
import { getDraftDiffValues, type DraftKind } from '$lib/utils_draft_deploy'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
// Draft rows carry the user-draft itemKind (`trigger_schedule`, `trigger_http`…),
// but the shared row icon/label and the edit-link builder speak the deploy-style
// kinds (`schedule`, `http_trigger`…) the fork drawer and compare page use. Map
// to deploy-style for display, and back for the draft-value getter.
const DEPLOY_KIND_BY_DRAFT_KIND: Partial<Record<DraftKind, string>> = {
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'
}
const DRAFT_KIND_BY_DEPLOY_KIND = Object.fromEntries(
Object.entries(DEPLOY_KIND_BY_DRAFT_KIND).map(([d, p]) => [p, d])
) as Record<string, DraftKind>
// Thin wrapper: supplies the deployed ↔ draft data source (server `draft`
// table, same as the compare page) to the generic WorkspaceDiffDrawer.
// Read-only, mirroring ForkDiffDrawer; deploy/discard live on the Review page.
let { workspaceId }: { workspaceId: string } = $props()
let inner: WorkspaceDiffDrawer | undefined = $state(undefined)
let rows: DiffRow[] = $state([])
let loading = $state(false)
let error: string | undefined = $state(undefined)
// draft_only per item — drives the "added" rendering (empty before).
let draftOnlyByKey: Record<string, boolean> = $state({})
// Pre-resolved before/after for exploded pipeline-node rows (see below),
// keyed by `${kind}/${path}`. Their content lives in the bundle, not a
// per-item draft endpoint, so `loadValues` reads it from here instead of
// calling `getDraftDiffValues`.
let pipelineNodeValues: Record<string, { before?: unknown; after: unknown }> = $state({})
const ws = $derived($userWorkspaces.find((w) => w.id === workspaceId))
export function open() {
void fetchDrafts()
inner?.open()
}
type ScriptBody = { language?: string; content?: string }
type PipelineBundle = {
drafts?: Array<[string, { script?: { language?: string; content?: string; summary?: string } }]>
}
// Explode a `data_pipeline` bundle draft into one row per node, nested under
// the bundle's folder so they read as the pipeline's subitems. Each node is a
// script with its own draft body; the deployed body (if the node is already
// deployed) is the "before" so its line changes show. Returns undefined to let
// the caller fall back to a single bundle row if the bundle can't be read.
async function explodePipelineBundle(bundlePath: string): Promise<DiffRow[] | undefined> {
let bundle: PipelineBundle
try {
const row = (await DraftService.getOwnDraft({
workspace: workspaceId,
kind: 'data_pipeline',
path: bundlePath
})) as { value?: PipelineBundle }
bundle = row?.value ?? {}
} catch (e) {
console.warn('Draft diff: pipeline bundle load failed', bundlePath, e)
return undefined
}
const entries = Array.isArray(bundle.drafts) ? bundle.drafts : []
// The bundle is keyed at `f/<folder>/data_pipeline`; nodes live in `f/<folder>`.
const folder = bundlePath.replace(/\/data_pipeline$/, '')
const out: DiffRow[] = []
await Promise.all(
entries.map(async ([nodePath, d]) => {
const after: ScriptBody = {
language: d?.script?.language,
content: d?.script?.content ?? ''
}
let before: ScriptBody | undefined
try {
const dep = await ScriptService.getScriptByPath({
workspace: workspaceId,
path: nodePath
})
before = { language: dep.language, content: dep.content }
} catch {
// Node not deployed yet → no "before" (renders as added).
}
const rel = nodePath.startsWith(`${folder}/`) ? nodePath.slice(folder.length + 1) : nodePath
// Nest under the bundle folder (`…/data_pipeline/<node>`) so the nodes
// group as the pipeline's subitems in the tree.
const displayPath = `${bundlePath}/${rel}`
// A pipeline node shares `script/<nodePath>` with a standalone script
// draft at the same path, so identify the row by its distinct
// bundle-nested path — `path` stays the real node for the edit link.
const key = `pipeline:${displayPath}`
pipelineNodeValues[key] = { before, after }
out.push({
kind: 'script',
path: nodePath,
displayPath,
key,
summary: d?.script?.summary || undefined,
status: before ? 'modified' : 'added'
})
})
)
return out
}
async function fetchDrafts() {
loading = true
error = undefined
try {
// One source of truth (Workspace Drafts) — same list the compare page and
// the count use.
const items = await getDraftItems(workspaceId)
const donly: Record<string, boolean> = {}
pipelineNodeValues = {}
const out: DiffRow[] = []
for (const it of items) {
if (it.kind === 'data_pipeline') {
// A pipeline bundle isn't a single diffable item — explode it into its
// node-script subitems. An unreadable bundle is skipped (it has no
// single-item diff to show).
const nodes = await explodePipelineBundle(it.path)
if (nodes) out.push(...nodes)
continue
}
// Raw apps must surface as `raw_app` so the row's edit link points at the
// raw-app editor (mirrors CompareDrafts); `getDraftItems` carries the flag.
const baseKind = it.raw_app ? 'raw_app' : it.kind
const kind = DEPLOY_KIND_BY_DRAFT_KIND[baseKind] ?? baseKind
donly[`${kind}/${it.path}`] = it.draft_only
// A never-deployed app/raw_app is parked at a synthetic `…/draft_<uuid>`
// storage path with the user's typed name in `draft_path`; show that
// (matches the home list) while `path` stays the storage key for loading.
// `summary` comes straight from the draft row, so it shows for every kind
// up front instead of only after the diff value loads.
out.push({
kind,
path: it.path,
displayPath: it.draft_path ?? it.path,
summary: it.summary,
status: it.draft_only ? 'added' : 'modified'
})
}
rows = out
draftOnlyByKey = donly
} catch (e) {
console.error('Draft diff: list failed', e)
error = `Failed to load drafts: ${e}`
rows = []
} finally {
loading = false
}
}
async function loadValues(d: DiffRow): Promise<{ before: unknown; after: unknown }> {
// Exploded pipeline-node rows carry their content from the bundle, not a
// per-item draft endpoint — keyed by the row's unique `key` so a standalone
// script draft at the same path can't read a node's cached value.
const pipelineNode = d.key ? pipelineNodeValues[d.key] : undefined
if (pipelineNode) return { before: pipelineNode.before, after: pipelineNode.after }
const draftOnly = draftOnlyByKey[`${d.kind}/${d.path}`] ?? false
// getDraftDiffValues keys on the draft itemKind: `raw_app` must stay
// `raw_app` (the helper sends rawApp:true only for that exact kind, which a
// never-deployed raw app needs, else it hits the normal app endpoint and
// 404s). Only the trigger display kinds map back from their deploy-style names.
const kind = (DRAFT_KIND_BY_DEPLOY_KIND[d.kind] ?? d.kind) as DraftKind
const { deployed, draft } = await getDraftDiffValues(kind, d.path, workspaceId, draftOnly)
// draft_only items have never been deployed → render as "added" (empty
// before), matching how the fork drawer renders added items.
return { before: draftOnly ? undefined : deployed, after: draft }
}
</script>
<WorkspaceDiffDrawer
bind:this={inner}
diffs={rows}
{loadValues}
{loading}
{error}
emptyMessage="No drafts in this workspace."
title="Drafts"
reviewHref={`/forks/compare?workspace_id=${encodeURIComponent(workspaceId)}&mode=draft`}
editUrlFor={(d) => buildEditUrl(d as unknown as WorkspaceItemDiff, workspaceId)}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<Pencil class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{ws?.name ?? workspaceId}</span>
</div>
{/snippet}
</WorkspaceDiffDrawer>
@@ -1,125 +0,0 @@
<script lang="ts">
import WorkspaceDiffDrawer, { type DiffRow } from './WorkspaceDiffDrawer.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { AlertTriangle, ArrowRight, GitFork } 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'
// Thin wrapper: supplies the deployed-parent ↔ deployed-fork data source to
// the generic WorkspaceDiffDrawer. Display/behavior unchanged from before.
let {
forkWorkspaceId,
parentWorkspaceId
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
let inner: WorkspaceDiffDrawer | undefined = $state(undefined)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let loading = $state(false)
let error: string | undefined = $state(undefined)
const forkWs = $derived($userWorkspaces.find((w) => w.id === forkWorkspaceId))
const parentWs = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
function statusOf(d: WorkspaceItemDiff): DiffRow['status'] {
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'
}
// Keep the original diffs by key so loadValues can honor exists flags
// (skip the non-existent side for added/removed items).
let diffByKey: Record<string, WorkspaceItemDiff> = $state({})
const rows = $derived.by<DiffRow[]>(() =>
(comparison?.diffs ?? []).map((d) => ({
kind: d.kind,
path: d.path,
status: statusOf(d),
ahead: d.ahead,
behind: d.behind
}))
)
function skipNotice(c: WorkspaceComparison | undefined): string | undefined {
return c?.skipped_comparison
? 'This fork was created before change tracking was added — diffs are not available.'
: undefined
}
const notice = $derived(skipNotice(comparison))
export function open() {
void fetchComparison()
inner?.open()
}
async function fetchComparison() {
loading = true
error = undefined
try {
comparison = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId
})
diffByKey = Object.fromEntries(
(comparison?.diffs ?? []).map((d) => [`${d.kind}/${d.path}`, d])
)
} catch (e) {
console.error('Fork diff: comparison failed', e)
error = `Failed to load comparison: ${e}`
comparison = undefined
} finally {
loading = false
}
}
async function loadValues(d: DiffRow): Promise<{ before: unknown; after: unknown }> {
const od = diffByKey[`${d.kind}/${d.path}`]
const existsSource = od ? od.exists_in_source !== false : true
const existsFork = od ? od.exists_in_fork !== false : true
const [before, after] = await Promise.all([
existsSource
? getItemValue(d.kind as any, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
existsFork
? getItemValue(d.kind as any, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
return { before, after }
}
</script>
<WorkspaceDiffDrawer
bind:this={inner}
diffs={rows}
{loadValues}
{loading}
{error}
{notice}
emptyMessage="No changes between this fork and its parent."
title="Fork changes"
reviewHref={`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`}
editUrlFor={(d) => buildEditUrl(d as unknown as WorkspaceItemDiff, forkWorkspaceId)}
>
{#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}
</WorkspaceDiffDrawer>
@@ -0,0 +1,264 @@
<script lang="ts">
import {
Archive,
ExternalLink,
GitPullRequestClosed,
MoveRight,
Pencil,
Trash2
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { canCreateFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import SessionDiffDrawer from './SessionDiffDrawer.svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { badgeCounts, buildDeployItems } from './sessionDeployModel'
import { useExistingMaskKeys } from './sessionDeployModel.svelte'
// Unified session bar: surfaces what the CURRENT chat changed — pending
// drafts and deployed items — as one count badge per status that opens the
// session diff drawer.
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()
// Only meaningful once the session committed to a workspace (post first send).
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. On cloud,
// forking is a premium-only feature (backend caps it per paid seat).
const forksAllowed = $derived(
(!isCloudHosted() || $isPremiumStore) &&
canCreateFork($userStore) &&
$workspaceStore !== 'admins'
)
const runtime = $derived(getRuntime(session.id))
// The chat's modified-items mask (`${UserDraftItemKind}:${storagePath}`).
// undefined = legacy/untracked chat → fall back to showing every draft.
// A Set (even empty) → filter to just this chat's items.
const mask = $derived(runtime?.manager.modifiedItems)
// Workspace Drafts (fetches on mount and on every Server-Draft invalidation);
// scoped to the chat's mask inside the deploy model below.
const drafts = useWorkspaceDrafts(() => committedId)
// The committed workspace vanished from the user's list (deleted, archived,
// or access revoked) — the session can't operate in it anymore.
const isUnavailable = $derived(!!committedId && !sessionWorkspace)
// The committed workspace is gone, so we can't read its parent to know if
// it was a fork — fall back to the fork-id convention for the wording.
const committedIsFork = $derived(committedId?.startsWith('wm-fork-') ?? false)
// Full dock refresh: re-run existence checks + draft list. Both are
// stale-while-revalidate — a hard reset would blank dockItems for the
// round-trip, hiding the bar (and unmounting the drawer) mid-deploy.
function refreshDock() {
existing.refresh()
drafts.refresh()
}
// Refresh the draft list when the AI finishes a turn (loading true → false):
// tool calls may have created/edited/deleted items. Deploys happen
// server-side, so the frontend only has this coarse signal.
let wasLoading = $state(false)
$effect(() => {
const isLoading = runtime?.manager.loading ?? false
if (wasLoading && !isLoading) refreshDock()
wasLoading = isLoading
})
// Refresh when the tab regains visibility — covers edits in another tab / by
// another user while we were away.
$effect(() => {
if (!committedId) return
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
if (sessionState.currentSessionId !== session.id) return
refreshDock()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
})
let diffDrawer: SessionDiffDrawer | undefined = $state(undefined)
// The dock counts are computed from the same pure model over this chat's
// drafts; the readout mirrors the drawer's item states.
// Existence check for mask-only (deployed) items — without it they'd be
// dropped from the dock counts while the drawer (which runs the same check)
// still shows them as Deployed.
const existing = useExistingMaskKeys(() => ({
draftItems: drafts.items,
mask: committedId ? mask : undefined,
workspaceId: committedId ?? ''
}))
const dockItems = $derived(
committedId
? buildDeployItems({ draftItems: drafts.items, mask, existingKeys: existing.keys })
: []
)
const dockCounts = $derived(badgeCounts(dockItems))
// Deletion-only fork chat: the mask has entries but every one failed the
// (resolved) existence check — the chat's edits were deletions, which have no
// dock row by design. The pending fork→parent removal is still reviewable on
// the compare page, so the bar must keep that doorway instead of vanishing.
// Gated on a resolved check (undefined = still loading) so the bar doesn't
// flash this state while deployed rows are being confirmed, and on isFork —
// a non-fork deletion is immediate and final, with nothing left to review.
const deletionOnly = $derived(
isFork && (mask?.size ?? 0) > 0 && existing.keys !== undefined && dockItems.length === 0
)
const compareHref = $derived(
committedId
? `/forks/compare?workspace_id=${encodeURIComponent(committedId)}&mode=fork` +
(session.chatId ? `&from_session=${encodeURIComponent(session.chatId)}` : '')
: undefined
)
// One "Edits" bar for both fork and non-fork sessions, shown when this chat
// edited anything. The fork's identity lives in the modal (SessionDiffDrawer's
// title), not on the bar. Fork sessions still need the forking gate + a
// resolvable fork/parent pair.
const showBar = $derived(
!!committedId &&
(dockItems.length > 0 || deletionOnly) &&
(!isFork || (forksAllowed && !!sessionWorkspace && !!parentWorkspace && !!parentWorkspaceId))
)
</script>
{#snippet dock()}
<!-- One count badge per row status, same vocabulary/colors as the drawer's
badges (draft/deployed) so the bar reads at a glance. -->
<div class="flex items-center gap-1 shrink-0">
{#if dockCounts.draft > 0}
<Badge small clickable color="indigo" onclick={() => diffDrawer?.open()}>
{dockCounts.draft} draft{dockCounts.draft === 1 ? '' : 's'}
</Badge>
{/if}
{#if dockCounts.deployed > 0}
<Badge small clickable color="green" onclick={() => diffDrawer?.open()}>
{dockCounts.deployed} deployed
</Badge>
{/if}
{#if deletionOnly && compareHref}
<!-- Deleted items have no dock row; the pending fork→parent removal is
reviewed on the compare page, so keep that doorway on the bar. -->
<a
href={compareHref}
target="_blank"
rel="noopener noreferrer"
title="This chat's edits were deletions — review and promote them on the compare page"
class="inline-flex items-center gap-1 text-2xs font-medium text-accent hover:underline shrink-0"
>
Review deletions on compare page
<ExternalLink class="w-3 h-3" />
</a>
{/if}
</div>
{/snippet}
{#if committedId && isUnavailable}
<!-- Committed 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. Shown even
for an archived session — unarchiving in place can't help when the
workspace is gone, so move/discard is the only real recovery path. -->
<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 {committedIsFork ? 'fork' : 'workspace'} 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?.() }]}
onclick={() => onArchive?.()}
>
Archive
</Button>
</div>
</div>
{:else if showBar && committedId}
<!-- The "Edits" bar: what the AI changed this session. Fork identity / sync
status lives inside the modal, not here. -->
<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" title="Edited by the chat during this session">
<Pencil class="w-3.5 h-3.5 shrink-0 text-secondary" />
<span class="truncate text-secondary font-medium">Edits</span>
</div>
{@render dock()}
</div>
{/if}
<!-- Mounted independently of the bar: a transient dip of the dock counts to
zero (mid-refresh after a deploy) must not unmount an open drawer. -->
{#if committedId && !isUnavailable}
<SessionDiffDrawer
bind:this={diffDrawer}
workspaceId={committedId}
{parentWorkspaceId}
chatId={session.chatId}
keys={mask}
onDataChanged={refreshDock}
onItemDeployed={(item) =>
void runtime?.manager.renameModifiedItem(item.draftKind, item.path, item.displayPath)}
onItemDiscarded={(item) => void runtime?.manager.removeModifiedItem(item.draftKind, item.path)}
/>
{/if}
@@ -0,0 +1,107 @@
<script lang="ts">
import WorkspaceDiffDrawer from './WorkspaceDiffDrawer.svelte'
import { ArrowRight, GitFork, Pencil } from 'lucide-svelte'
import { userWorkspaces } from '$lib/stores'
import { useSessionDeployModel } from './sessionDeployModel.svelte'
import type { DeployItem } from './sessionDeployModel'
// Session "Edits" drawer. Builds the deploy model over the session
// workspace's drafts and hands it to WorkspaceDiffDrawer, which renders the
// tree + scroll-through diff column. The parent workspace only informs the
// title identity and the compare-page link — deploys land in the session
// workspace itself.
let {
workspaceId,
parentWorkspaceId,
chatId,
keys,
onDataChanged,
onItemDeployed,
onItemDiscarded
}: {
workspaceId: string
parentWorkspaceId?: string
/** CHAT id (Session.chatId — the IndexedDB `chats` key, NOT the session id),
* threaded to the footer's compare-page link as `from_session`: the compare
* page resolves it via readChatModifiedItems to preselect this chat's edits. */
chatId?: string
keys?: Set<string>
/** Notified after a deploy/discard mutated workspace state, so the owner
* (session bar) can refresh its cached draft sources. */
onDataChanged?: () => void
/** Mask maintenance, forwarded to the deploy model (see its args' docs):
* a deploy moves the item's mask entry to its deployed path, a discard
* drops it. */
onItemDeployed?: (item: DeployItem) => void
onItemDiscarded?: (item: DeployItem) => void
} = $props()
const isFork = $derived(!!parentWorkspaceId)
const ws = $derived($userWorkspaces.find((w) => w.id === workspaceId))
const parentWs = $derived(
parentWorkspaceId ? $userWorkspaces.find((w) => w.id === parentWorkspaceId) : undefined
)
let inner: WorkspaceDiffDrawer | undefined = $state(undefined)
const model = useSessionDeployModel(() => ({
workspaceId,
mask: keys,
onDataChanged,
onItemDeployed,
onItemDiscarded
}))
// Editor URL for a row (every item lives in the session workspace).
function editUrlFor(item: DeployItem): string | undefined {
const ws = encodeURIComponent(workspaceId)
const path = item.path
if (item.deployKind === 'flow') return `/flows/edit/${path}?workspace=${ws}`
if (item.deployKind === 'script') return `/scripts/edit/${path}?workspace=${ws}`
if (item.deployKind === 'app') return `/apps/edit/${path}?workspace=${ws}`
if (item.deployKind === 'raw_app') return `/apps_raw/edit/${path}?workspace=${ws}`
return undefined
}
const title = 'Edited during session'
// Compare page = the batch/PR surface. The footer hands off THIS session's
// edits via `from_session`, so the compare page opens preselected.
const compareSessionHref = $derived(
`/forks/compare?workspace_id=${encodeURIComponent(workspaceId)}&mode=${isFork ? 'fork' : 'draft'}` +
(chatId ? `&from_session=${encodeURIComponent(chatId)}` : '')
)
export function open() {
inner?.open()
}
</script>
<WorkspaceDiffDrawer
bind:this={inner}
{model}
{title}
{editUrlFor}
{compareSessionHref}
workspaceLabel={ws?.name ?? workspaceId}
>
{#snippet titleExtra()}
<div class="flex items-center gap-1.5 text-xs text-secondary min-w-0">
{#if isFork}
<GitFork class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate" title={ws?.name ?? workspaceId}>
{ws?.name ?? workspaceId}
</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate" title={parentWs?.name ?? parentWorkspaceId}>
{parentWs?.name ?? parentWorkspaceId}
</span>
{:else}
<Pencil class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate" title={ws?.name ?? workspaceId}>
{ws?.name ?? workspaceId}
</span>
{/if}
</div>
{/snippet}
</WorkspaceDiffDrawer>
@@ -1,77 +0,0 @@
<script lang="ts">
import { Pencil } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { goto } from '$lib/navigation'
import { sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import DraftDiffDrawer from './DraftDiffDrawer.svelte'
import SessionDiffButton from './SessionDiffButton.svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
let { session }: { session: Session } = $props()
// Only meaningful once the session committed to a workspace (post first send).
// The Draft Count comes from the shared Workspace Drafts resource: it fetches
// on mount and whenever a Server-Draft mutation invalidates the workspace, so
// the count is fresh on every (re)open with no per-session caching.
const committedId = $derived(session.workspace_id)
const drafts = useWorkspaceDrafts(() => committedId)
const count = $derived(drafts.count)
// Deploys/saves from the Preview editor invalidate the workspace directly
// (ScriptEditorView / FlowEditorView / RawAppEditorView call
// invalidateWorkspaceDrafts). The chat itself can also deploy items, but
// those happen server-side and the frontend never sees the individual calls —
// so refresh on the same coarse signals the fork bar uses: the AI turn ending
// and the tab regaining visibility.
const runtime = $derived(getRuntime(session.id))
let wasLoading = $state(false)
$effect(() => {
const isLoading = runtime?.manager.loading ?? false
if (wasLoading && !isLoading) drafts.refresh()
wasLoading = isLoading
})
$effect(() => {
if (!committedId) return
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
if (sessionState.currentSessionId !== session.id) return
drafts.refresh()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
})
let drawer: DraftDiffDrawer | undefined = $state(undefined)
function openReview() {
if (!committedId) return
goto(`/forks/compare?workspace_id=${encodeURIComponent(committedId)}&mode=draft`)
}
</script>
{#if committedId && count > 0}
<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 class="inline-flex shrink-0"><Pencil class="w-3.5 h-3.5 text-secondary" /></span>
<Tooltip placement="top" class="truncate text-secondary">
{count} draft{count === 1 ? '' : 's'}
{#snippet text()}
Tracks all unsaved draft changes in this workspace — including edits made outside this
chat (e.g. in the editor), not only changes made by the assistant.
{/snippet}
</Tooltip>
</div>
<div class="flex items-center gap-1 shrink-0">
<SessionDiffButton {count} onclick={() => drawer?.open()} />
<Button variant="default" unifiedSize="xs" onclick={openReview}>Review</Button>
</div>
</div>
<DraftDiffDrawer bind:this={drawer} workspaceId={committedId} />
{/if}
@@ -1,212 +0,0 @@
<script lang="ts">
import {
Archive,
ArrowRight,
GitCompareArrows,
GitFork,
GitPullRequestArrow,
GitPullRequestClosed,
MoveRight,
Trash2
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { goto } from '$lib/navigation'
import { canCreateFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import ForkDiffDrawer from './ForkDiffDrawer.svelte'
import SessionDiffButton from './SessionDiffButton.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. On cloud, forking is a
// premium-only feature (backend caps it per paid seat).
const forksAllowed = $derived(
(!isCloudHosted() || $isPremiumStore) &&
canCreateFork($userStore) &&
$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')
// The committed workspace is gone, so we can't read its parent to know if
// it was a fork — fall back to the fork-id convention for the wording.
const committedIsFork = $derived(committedId?.startsWith('wm-fork-') ?? false)
$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}
<!-- Committed 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. Shown even
for an archived session — unarchiving in place can't help when the
workspace is gone, so move/discard is the only real recovery path. -->
<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 {committedIsFork ? 'fork' : 'workspace'} 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?.() }]}
onclick={() => 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">
<SessionDiffButton
count={totalDiffs}
disabled={totalDiffs === 0}
onclick={() => diffDrawer?.open()}
/>
<Button variant="default" unifiedSize="xs" onclick={openReview}>Review</Button>
</div>
</div>
<ForkDiffDrawer bind:this={diffDrawer} forkWorkspaceId={committedId} {parentWorkspaceId} />
{/if}
@@ -21,7 +21,6 @@
import { slide } from 'svelte/transition'
import {
createSession,
deriveForkStatus,
deleteSessionsForWorkspace,
isForkSession,
reconcileAfterWorkspaceChange,
@@ -53,15 +52,6 @@
import { sendUserToast } from '$lib/toast'
import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte'
// 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)
}
@@ -124,8 +114,8 @@
}
// Flat list passing the archive + scope filters. Grouping for display happens
// in `sessionGroups`; this flat view drives the runtime / fork-comparison
// effects, the unread total, and keyboard navigation.
// in `sessionGroups`; this flat view drives the runtime effect, the unread
// total, and keyboard navigation.
const visibleSessions = $derived(
sessionState.sessions.filter((s) => {
if (s.transient) return false
@@ -211,23 +201,6 @@
}
})
// 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)
}
@@ -241,10 +214,6 @@
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
@@ -436,7 +405,7 @@
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
unavailable={isUnavailableFork(session)}
/>
<span
class={twMerge(
@@ -587,7 +556,7 @@
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
unavailable={isUnavailableFork(session)}
/>
<!-- svelte-ignore a11y_autofocus -->
<input
@@ -619,7 +588,7 @@
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
unavailable={isUnavailableFork(session)}
/>
<span class="truncate flex-1">{session.summary ?? 'Untitled session'}</span>
{#if draft || unread > 0}
@@ -3,19 +3,16 @@
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()
unavailable = false
}: { status: SessionChatStatus; isFork: boolean; unavailable?: boolean } = $props()
const statusTooltip: Record<SessionChatStatus, string> = {
idle: 'No chat activity',
@@ -26,13 +23,6 @@
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.
@@ -41,7 +31,11 @@
)
const persistentTitle = $derived(
isFork ? (forkStatus ? forkTooltip[forkStatus] : 'Fork session') : 'Root workspace session'
isFork
? unavailable
? 'Fork — no longer available'
: 'Fork session'
: 'Root workspace session'
)
const title = $derived(liveOverride ? statusTooltip[status] : persistentTitle)
@@ -59,11 +53,7 @@
{: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'}
{#if unavailable}
<GitPullRequestClosed class="w-3 h-3 text-red-500" />
{:else}
<GitFork class="w-3 h-3 text-tertiary" />
@@ -30,8 +30,7 @@
import RawAppEditorView from './RawAppEditorView.svelte'
import PipelineEditorView from './PipelineEditorView.svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import SessionForkBar from './SessionForkBar.svelte'
import SessionDraftBar from './SessionDraftBar.svelte'
import SessionChangesBar from './SessionChangesBar.svelte'
import {
createSession,
deleteSessionsForWorkspace,
@@ -235,7 +234,7 @@
// 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.
// disabled and SessionChangesBar shows a move/discard banner.
const isUnavailable = $derived(
!!session?.workspace_id && !$userWorkspaces.find((w) => w.id === session!.workspace_id)
)
@@ -274,16 +273,16 @@
{#if !hasFirstUserMessage}
<SessionWorkspaceBar {session} />
{/if}
<!-- gap-1 (4px) spaces the fork bar and draft bar when both are visible.
Each bar renders a single in-flow root (or nothing); the draft drawer
is position:fixed, so it doesn't count as a flex item — no stray gap
when only one bar shows. -->
<!-- gap-1 (4px) spaces the archived banner and the changes bar when both
are visible. Each renders a single in-flow root (or nothing); the diff
drawer is position:fixed, so it doesn't count as a flex item — no stray
gap when only one shows. -->
<div class="flex flex-col gap-1">
{#if session.archived && !isUnavailable}
<!-- Unarchive is only meaningful when the workspace is still live:
putSession refuses to resurrect a session whose workspace is gone,
and reconcile would re-archive a workspace-archived one anyway. When
the workspace is unavailable the SessionForkBar below shows the
the workspace is unavailable the SessionChangesBar below shows the
move/discard banner instead (its actions are the real recovery path). -->
<div
class="flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary"
@@ -302,14 +301,13 @@
</Button>
</div>
{/if}
<SessionForkBar
<SessionChangesBar
{session}
onMove={(workspaceId) => moveAndActivate(workspaceId)}
onCreateForkAndMove={(fork) => createForkAndMove(fork)}
onArchive={() => archiveAndReset()}
onDelete={() => (deleteConfirmOpen = true)}
/>
<SessionDraftBar {session} />
</div>
{/snippet}
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,43 @@ describe('buildDiffTree — structure', () => {
expect(t.root.children[0]).toMatchObject({ type: 'file', name: 'orphan', key: 'orphan' })
})
it('app roots sort as items among leaves — same position before and after unwrap', () => {
const apps = new Map<string, AppRootMeta>([['u/admin/beta_app', { summaryKey: 'raw_app/b' }]])
// Unloaded: the app is a plain leaf between alpha and zeta.
const before = buildDiffTree(
[
item('a', 'u/admin/alpha'),
item('raw_app/b', 'u/admin/beta_app'),
item('z', 'u/admin/zeta')
],
apps
)
const scopeBefore = before.root.children[0]
if (scopeBefore.type !== 'folder') throw new Error('expected scope')
expect(scopeBefore.children.map((c) => `${c.type}:${c.name}`)).toEqual([
'file:alpha',
'file:beta_app',
'file:zeta'
])
// Loaded: the leaf is replaced by the app-root folder — same position,
// not hoisted to the folder group.
const after = buildDiffTree(
[
item('a', 'u/admin/alpha'),
item('f', 'u/admin/beta_app/App.tsx'),
item('z', 'u/admin/zeta')
],
apps
)
const scopeAfter = after.root.children[0]
if (scopeAfter.type !== 'folder') throw new Error('expected scope')
expect(scopeAfter.children.map((c) => `${c.type}:${c.name}`)).toEqual([
'file:alpha',
'folder:beta_app',
'file:zeta'
])
})
it('tags the folder matching an app root and leaves others untagged', () => {
const apps = new Map<string, AppRootMeta>([
['u/admin/my_app', { summaryKey: 'raw_app/x', summary: 'My app' }]
@@ -29,7 +29,14 @@ export type DiffTreeItem<T> = {
/** Marks a folder as a raw app's root so the drawer renders its header as the
* raw-app row instead of a plain folder. Keyed by the app's friendly path. */
export type AppRootMeta = { summaryKey: string; summary?: string }
export type AppRootMeta = {
summaryKey: string
summary?: string
hasDraft?: boolean
draftOnly?: boolean
draftUsers?: { username?: string | null }[]
draftItemKind?: import('$lib/gen').UserDraftItemKind
}
export type FolderNode<T> = {
type: 'folder'
@@ -57,7 +64,7 @@ export type DiffTreeModel<T> = {
firstChildKeyOf(folderKey: string): string | undefined
}
function folderKeyFor(fullPath: string): string {
export function folderKeyFor(fullPath: string): string {
return `folder:${fullPath}`
}
@@ -119,21 +126,28 @@ export function buildDiffTree<T>(
addFile(parent, rest[rest.length - 1], it)
}
// Tag the folder matching each raw app's friendly path as its app root.
// Must happen before sorting: app roots sort as items, not as folders.
for (const [fp, meta] of appMeta) {
const f = folderCache.get(fp)
if (f) f.app = meta
}
// Path folders group first; everything item-like — leaves AND app roots —
// sorts together by name, so a raw app keeps its position when expanding
// turns its leaf row into an app-root folder.
const isGrouping = (n: TreeNode<T>) => n.type === 'folder' && !n.app
const sortRec = (n: FolderNode<T>) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
const ga = isGrouping(a) ? 0 : 1
const gb = isGrouping(b) ? 0 : 1
if (ga !== gb) return ga - gb
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
// Tag the folder matching each raw app's friendly path as its app root.
for (const [fp, meta] of appMeta) {
const f = folderCache.get(fp)
if (f) f.app = meta
}
return {
root,
order(isOpen) {
@@ -1,15 +1,15 @@
import { describe, it, expect } from 'vitest'
// Documents the cache-invalidation contract that ForkDiffDrawer.fetchComparison
// relies on. The bug (cubic P2): per-item raw diffs (`loadedDiffs[key]`) and
// per-item `summaries` are component-local $state that persist for the
// drawer's lifetime. fetchComparison re-fetches the comparison on every
// open(), but loadDiffFor short-circuits on `loadedDiffs[key]` — so an
// edit-then-reopen would show fresh counts but the prior open's cached
// expanded raw content. The fix clears both records at the top of
// fetchComparison before the await. This test pins the contract from the
// outside: nothing is allowed to read a stale per-item value across a
// fetchComparison invocation.
// Documents the cache-invalidation contract behind the session fork diff. The
// bug (cubic P2): per-item raw diffs (`loadedDiffs[key]`) and per-item
// `summaries` are component-local $state in WorkspaceDiffDrawer that persist for
// the drawer's lifetime. The comparison is re-fetched on every open(), but
// loadDiffFor short-circuits on `loadedDiffs[key]` — so an edit-then-reopen
// would show fresh counts but the prior open's cached expanded raw content. The
// fix clears both records on open() (and, for an in-place data-source swap like
// the draft↔fork toggle, on a `resetKey` change). This test pins the contract
// from the outside: nothing is allowed to read a stale per-item value across a
// re-fetch.
type LoadedDiff = { state: 'ready'; parentRaw: string; forkRaw: string }
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { maskKey, forkDiffKindToUserDraftKind, diffInMask } from './modifiedItemsMask'
import type { WorkspaceItemDiff } from '$lib/gen'
// The fork-diff → user-draft kind bridge must stay the inverse of
// CompareDrafts.toLayoutKind (different taxonomies: `http_trigger` vs
// `trigger_http`, `schedule` vs `trigger_schedule`). A drift here silently breaks
// the chat-scoped mask join, so pin the table.
describe('maskKey', () => {
it('joins kind and path with a colon', () => {
expect(maskKey('script', 'u/admin/foo')).toBe('script:u/admin/foo')
expect(maskKey('raw_app', 'f/bar/app')).toBe('raw_app:f/bar/app')
})
})
describe('forkDiffKindToUserDraftKind', () => {
it('maps trigger/schedule/app fork-diff kinds to their user-draft kind', () => {
const cases: Array<[WorkspaceItemDiff['kind'], string]> = [
['schedule', 'trigger_schedule'],
['http_trigger', 'trigger_http'],
['websocket_trigger', 'trigger_websocket'],
['kafka_trigger', 'trigger_kafka'],
['nats_trigger', 'trigger_nats'],
['postgres_trigger', 'trigger_postgres'],
['mqtt_trigger', 'trigger_mqtt'],
['sqs_trigger', 'trigger_sqs'],
['gcp_trigger', 'trigger_gcp'],
['azure_trigger', 'trigger_azure'],
['email_trigger', 'trigger_default_email'],
['app', 'raw_app']
]
for (const [forkKind, expected] of cases) {
expect(forkDiffKindToUserDraftKind(forkKind)).toBe(expected)
}
})
it('passes identity kinds through unchanged', () => {
for (const k of ['script', 'flow', 'raw_app', 'resource', 'variable'] as const) {
expect(forkDiffKindToUserDraftKind(k)).toBe(k)
}
})
it('returns undefined for kinds with no user-draft equivalent', () => {
expect(forkDiffKindToUserDraftKind('resource_type')).toBeUndefined()
expect(forkDiffKindToUserDraftKind('folder')).toBeUndefined()
})
})
describe('diffInMask', () => {
const diff = (kind: WorkspaceItemDiff['kind'], path: string): WorkspaceItemDiff =>
({ kind, path }) as WorkspaceItemDiff
it('matches a fork diff against the mask via the bridged kind', () => {
const mask = new Set(['trigger_http:f/foo/route', 'script:u/me/s'])
expect(diffInMask(diff('http_trigger', 'f/foo/route'), mask)).toBe(true)
expect(diffInMask(diff('script', 'u/me/s'), mask)).toBe(true)
})
it('does not match when path or kind differ, or kind has no equivalent', () => {
const mask = new Set(['trigger_http:f/foo/route'])
expect(diffInMask(diff('http_trigger', 'f/other/route'), mask)).toBe(false)
expect(diffInMask(diff('script', 'f/foo/route'), mask)).toBe(false)
expect(diffInMask(diff('folder', 'f/foo/route'), mask)).toBe(false)
})
})
@@ -0,0 +1,52 @@
import type { UserDraftItemKind, WorkspaceItemDiff } from '$lib/gen'
// The "modified items mask" tracks which workspace items an AI chat touched via
// tool calls. Each key is `${UserDraftItemKind}:${storagePath}`. UserDraftItemKind
// is canonical because it is what `itemKindFor`/`persistGlobalDraft` emit at capture
// time and what `DraftItem.kind` (GET /drafts/list) already uses — so the mask joins
// directly against the draft list. The fork comparison (`compareWorkspaces`) uses a
// DIFFERENT kind taxonomy (`http_trigger` vs `trigger_http`, `schedule` vs
// `trigger_schedule`), so `forkDiffKindToUserDraftKind` bridges the two.
export function maskKey(kind: UserDraftItemKind, path: string): string {
return `${kind}:${path}`
}
type ForkDiffKind = WorkspaceItemDiff['kind']
// Inverse of the draft→layout naming used in CompareDrafts.toLayoutKind. Only the
// non-identity cases need an entry; everything else falls through to identity.
// `resource_type`/`folder` have no UserDraftItemKind (never AI-authored) → undefined.
const FORK_DIFF_KIND_TO_USER_DRAFT_KIND: Partial<Record<ForkDiffKind, UserDraftItemKind>> = {
schedule: 'trigger_schedule',
http_trigger: 'trigger_http',
websocket_trigger: 'trigger_websocket',
kafka_trigger: 'trigger_kafka',
nats_trigger: 'trigger_nats',
postgres_trigger: 'trigger_postgres',
mqtt_trigger: 'trigger_mqtt',
sqs_trigger: 'trigger_sqs',
gcp_trigger: 'trigger_gcp',
azure_trigger: 'trigger_azure',
email_trigger: 'trigger_default_email',
app: 'raw_app'
}
const IDENTITY_FORK_DIFF_KINDS = new Set<ForkDiffKind>([
'script',
'flow',
'raw_app',
'resource',
'variable'
])
export function forkDiffKindToUserDraftKind(kind: ForkDiffKind): UserDraftItemKind | undefined {
if (IDENTITY_FORK_DIFF_KINDS.has(kind)) return kind as UserDraftItemKind
return FORK_DIFF_KIND_TO_USER_DRAFT_KIND[kind]
}
// True when a fork-comparison diff names an item present in the chat-modified mask.
export function diffInMask(diff: WorkspaceItemDiff, mask: Set<string>): boolean {
const kind = forkDiffKindToUserDraftKind(diff.kind)
return kind !== undefined && mask.has(maskKey(kind, diff.path))
}
@@ -0,0 +1,351 @@
import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import {
checkDeployPermission,
checkItemExists,
getItemValue,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
import {
deployDraft,
discardDraft,
fetchDraftBaseStale,
getDraftDiffValues
} from '$lib/utils_draft_deploy'
import { untrack } from 'svelte'
import {
buildDeployItems,
deployPlanFor,
diffBaseFor,
discardPlanFor,
maskOnlyCandidates,
type DeployItem,
type DeployPlanEntry
} from './sessionDeployModel'
import { maskKey } from './modifiedItemsMask'
export type DeploymentStatus = { status: 'loading' | 'failed'; error?: string }
// Reactive wrapper over the pure sessionDeployModel: owns the async draft list
// and builds the unified item list. The diff-value loader lives here too so both
// the tree and the diff column read one resolver. The dock is granular per-item
// deploy (deployRow/discardRow); batch flows and fork→parent promotion are the
// compare page's concern.
export interface SessionDeployModelArgs {
workspaceId: string
/** Called after a deploy/discard mutated workspace state. Lets the consumer
* refresh ITS OWN data sources without this, the bar shows stale counts
* until the next turn end / tab refocus / reload. */
onDataChanged?: () => void
/** A draft deployed: the consumer owning the mask moves the item's entry to
* its deployed path (`displayPath` differs from the synthetic storage
* `path` for draft-only items, which never exists deployed). */
onItemDeployed?: (item: DeployItem) => void
/** A draft was discarded: the chat's touch is undone the consumer drops
* the item's mask entry so a pre-existing deployed item doesn't keep
* reading as this chat's "Deployed" edit. */
onItemDiscarded?: (item: DeployItem) => void
/** Chat-modified-items mask; undefined shows every draft. */
mask?: Set<string>
}
export interface DiffValues {
before: unknown
after: unknown
}
/** Resolves which mask-only candidates still exist in the session workspace
* (deployed) vs are gone (discarded drafts / deleted items) the
* `existingKeys` input of `buildDeployItems`. Undefined while resolving, so
* deployed rows only appear once confirmed. Shared by the drawer model and the
* bar's dock counts. */
export function useExistingMaskKeys(
getInput: () => Omit<Parameters<typeof buildDeployItems>[0], 'existingKeys'> & {
workspaceId: string
}
) {
let existingKeys = $state<Set<string> | undefined>(undefined)
// Existence is checked once per distinct candidate set; the signature avoids
// re-firing while results stream back.
let lastCandidateSig = ''
// Bumped by refresh() to force a re-check even when the candidate set (the
// effect's other dependency) is unchanged.
let recheck = $state(0)
$effect(() => {
void recheck
const { workspaceId, ...buildInput } = getInput()
const cands = maskOnlyCandidates(buildInput)
// The workspace is part of the identity: the same candidate keys in a
// different workspace must re-check, not reuse the previous verdict.
const sig =
workspaceId +
'|' +
cands
.map((c) => c.key)
.sort()
.join(',')
untrack(() => {
if (sig === lastCandidateSig) return
lastCandidateSig = sig
if (cands.length === 0) {
existingKeys = new Set()
return
}
// A mask-only item that was deployed still exists in the session
// workspace; a discarded or deleted one doesn't.
void Promise.all(
cands.map((c) =>
checkItemExists(c.deployKind, c.path, workspaceId)
.then((exists) => (exists ? c.key : null))
.catch(() => null)
)
).then((keys) => {
// A slower batch for a superseded candidate set (or one voided by
// reset()) must not overwrite the current result.
if (sig !== lastCandidateSig) return
existingKeys = new Set(keys.filter((k): k is string => !!k))
})
})
})
return {
get keys() {
return existingKeys
},
/** Force a fresh existence check (an item may have been deleted since).
* Hard invalidate: current keys are dropped until the check resolves, so
* rows derived from them disappear meanwhile use on a fresh surface
* (drawer open), not on a live one. */
reset() {
lastCandidateSig = ''
existingKeys = undefined
},
/** Re-check while keeping the current keys (stale-while-revalidate), so a
* live surface doesn't blank out during the round-trip. */
refresh() {
lastCandidateSig = ''
recheck++
}
}
}
export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
let draftItems = $state<DraftItem[]>([])
let loading = $state(false)
let error = $state<string | undefined>(undefined)
// Mask keys confirmed to still exist → allowed to show as terminal deployed
// rows (a discarded draft is a mask entry with no item, so it stays out).
const existing = useExistingMaskKeys(() => ({
draftItems,
mask: getArgs().mask,
workspaceId: getArgs().workspaceId
}))
// Optimistic knowledge from this model's own deploys: a deployed key exists
// in the session workspace before the async existence check confirms it.
// Without this a deployed row falls out of the list for ~1s, unmounting its
// card.
let deployedKeys = $state<ReadonlySet<string>>(new Set())
const items = $derived.by(() => {
const existingKeys =
deployedKeys.size === 0 ? existing.keys : new Set([...(existing.keys ?? []), ...deployedKeys])
return buildDeployItems({ draftItems, mask: getArgs().mask, existingKeys })
})
async function fetchDrafts() {
loading = true
error = undefined
try {
draftItems = await getDraftItems(getArgs().workspaceId)
} catch (e) {
console.error('Session deploy model: draft list failed', e)
error = `Failed to load drafts: ${e}`
draftItems = []
} finally {
loading = false
}
}
/** (Re)fetch. Called on open. */
function load() {
// Re-check existence fresh (an item may have been deleted since last open).
existing.reset()
// Drop the optimistic bridge with it: it only covers the gap until the
// fresh check resolves, and a stale entry would keep a since-deleted item
// reading as deployed forever.
deployedKeys = new Set()
// Staleness can change between opens (a deploy elsewhere moves the head).
staleFetched.clear()
staleKeys = new Set()
void fetchDrafts()
}
// ── Stale drafts (base ≠ deployed head) ──────────────────────────────────
// A draft forked from a version that is no longer the deployed head: someone
// deployed in between, so deploying the draft would silently revert them.
// Same detection as the compare page; surfaced as a warning, deploy stays
// allowed. Only own drafts on deployed script/flow/app items carry a base.
let staleKeys = $state<ReadonlySet<string>>(new Set())
const staleFetched = new Set<string>()
$effect(() => {
const list = items
const ws = getArgs().workspaceId
untrack(() => {
for (const it of list) {
if (!it.hasDraft || it.draftOnly || !it.mine) continue
if (!['script', 'flow', 'app', 'raw_app'].includes(it.draftKind)) continue
if (staleFetched.has(it.key)) continue
staleFetched.add(it.key)
void fetchDraftBaseStale(it.draftKind, it.path, ws).then((stale) => {
if (stale) staleKeys = new Set(staleKeys).add(it.key)
})
}
})
})
// ── Deploy permission ────────────────────────────────────────────────────
// Preflight the shared checkDeployPermission (operator / RestrictDeployToDeployers)
// for the session workspace so the button disables with a reason instead of
// failing on click. `ok` defaults true while resolving (fail-open).
let deployPerm = $state<DeployPermission>({ ok: true })
let deployPermFetchedFor = ''
$effect(() => {
const ws = getArgs().workspaceId
untrack(() => {
if (!ws || deployPermFetchedFor === ws) return
deployPermFetchedFor = ws
// Reset to fail-open for the new workspace and drop a stale resolution
// (a slower fetch for the previous workspace must not gate this one).
deployPerm = { ok: true }
void checkDeployPermission(ws).then((perm) => {
if (deployPermFetchedFor === ws) deployPerm = perm
})
})
})
// ── Deploy execution ─────────────────────────────────────────────────────
// Per-item transient deploy state (keyed by DeployItem.key): loading or
// failed. Success has no status — the row's badge flipping to Deployed is
// the feedback. Reassigned, not mutated.
let deploymentStatus = $state<Record<string, DeploymentStatus>>({})
let deploying = $state(false)
function setStatus(key: string, s: DeploymentStatus | undefined) {
const next = { ...deploymentStatus }
if (s) next[key] = s
else delete next[key]
deploymentStatus = next
}
async function runPlan(entry: DeployPlanEntry): Promise<DeployResult> {
const cur = getArgs().workspaceId
switch (entry.op) {
case 'deploy_draft':
return deployDraft(entry.draftKind, entry.path, cur, {
draftOnly: entry.draftOnly,
rawApp: entry.rawApp
})
case 'discard':
return discardDraft(entry.draftKind, entry.path, cur, entry.draftOnly, entry.legacy)
}
}
function refreshData() {
void fetchDrafts()
getArgs().onDataChanged?.()
}
/** Deploy (or discard) a single item; returns whether it succeeded. */
async function deployOne(item: DeployItem, discard = false): Promise<boolean> {
const plan = discard ? discardPlanFor(item) : deployPlanFor(item)
if (!plan) return false
// Don't attempt a deploy we know the user can't make (no write permission
// on the path, or blocked by the operator / deployer rule) — the UI
// disables it too; this is the guard behind that.
if (!discard && (!item.canWrite || !deployPerm.ok)) return false
setStatus(item.key, { status: 'loading' })
deploying = true
try {
const res = await runPlan(plan)
setStatus(item.key, res.success ? undefined : { status: 'failed', error: res.error })
if (res.success) {
if (discard) {
getArgs().onItemDiscarded?.(item)
} else {
// Bridge under the DEPLOYED path's key too: the mask rewrite
// (onItemDeployed) re-keys a synthetic-storage row to displayPath.
deployedKeys = new Set(deployedKeys)
.add(item.key)
.add(maskKey(item.draftKind, item.displayPath))
getArgs().onItemDeployed?.(item)
}
}
return res.success
} finally {
deploying = false
}
}
/** Returns success so the UI can stage its post-deploy animation. */
async function deployRow(item: DeployItem): Promise<boolean> {
const ok = await deployOne(item)
if (ok) refreshData()
return ok
}
async function discardRow(item: DeployItem) {
if (await deployOne(item, true)) refreshData()
}
// ── Diff values (one resolver for tree + column) ─────────────────────────
async function loadDiffValues(item: DeployItem): Promise<DiffValues> {
const base = diffBaseFor(item, getArgs().workspaceId)
if (base.kind === 'draft') {
const { deployed, draft } = await getDraftDiffValues(
base.draftKind,
item.path,
base.workspaceId,
base.draftOnly
)
return { before: base.draftOnly ? undefined : deployed, after: draft }
}
const v = await getItemValue(base.deployKind, base.path, base.workspaceId).catch(
() => undefined
)
return { before: v, after: v }
}
return {
get items() {
return items
},
get loading() {
return loading
},
get error() {
return error
},
load,
loadDiffValues,
get deploying() {
return deploying
},
statusOf(key: string): DeploymentStatus | undefined {
return deploymentStatus[key]
},
/** Whether a draft row's base is stale (deployed head moved since). */
staleOf(key: string): boolean {
return staleKeys.has(key)
},
/** Whether the user may deploy into the session workspace. */
get deployPermission(): DeployPermission {
return deployPerm
},
deployRow,
discardRow
}
}
export type SessionDeployModel = ReturnType<typeof useSessionDeployModel>
@@ -0,0 +1,232 @@
import { describe, it, expect } from 'vitest'
import type { UserDraftItemKind } from '$lib/gen'
import type { DraftItem } from '$lib/workspaceDrafts.svelte'
import {
buildDeployItems,
maskOnlyCandidates,
badgeOf,
actionFor,
diffBaseFor,
badgeCounts,
deployPlanFor,
discardPlanFor
} from './sessionDeployModel'
import { maskKey } from './modifiedItemsMask'
// ── Fixtures ────────────────────────────────────────────────────────────────
function draft(kind: UserDraftItemKind, path: string, over: Partial<DraftItem> = {}): DraftItem {
return {
kind,
path,
summary: undefined,
draft_path: undefined,
draft_only: false,
legacy_draft: false,
raw_app: kind === 'raw_app',
can_write: true,
draft_users: undefined,
mine: true,
...over
}
}
// ── buildDeployItems ─────────────────────────────────────────────────────────
describe('buildDeployItems', () => {
it('a draft is a live (not done) row carrying the draft flags', () => {
const items = buildDeployItems({
draftItems: [draft('script', 'u/a/foo', { draft_only: true })]
})
expect(items).toHaveLength(1)
expect(items[0].hasDraft).toBe(true)
expect(items[0].done).toBe(false)
expect(items[0].draftOnly).toBe(true)
expect(items[0].key).toBe(maskKey('script', 'u/a/foo'))
})
it('maps the trigger taxonomy to the deploy kind (trigger_schedule → schedule)', () => {
const items = buildDeployItems({ draftItems: [draft('trigger_schedule', 'u/a/sched')] })
expect(items[0].deployKind).toBe('schedule')
expect(items[0].draftKind).toBe('trigger_schedule')
})
it('maps both email draft kinds to the email_trigger deploy kind', () => {
const items = buildDeployItems({
draftItems: [draft('trigger_email', 'u/a/em'), draft('trigger_default_email', 'u/a/dem')]
})
expect(items.map((i) => i.deployKind)).toEqual(['email_trigger', 'email_trigger'])
})
it('mask scopes drafts to touched items', () => {
const mask = new Set([maskKey('script', 'u/a/keep')])
const items = buildDeployItems({
draftItems: [draft('script', 'u/a/keep'), draft('flow', 'u/a/other')],
mask
})
expect(items.map((i) => i.path)).toEqual(['u/a/keep'])
})
it('mask-only entries that still exist surface as terminal deployed rows', () => {
const key = maskKey('script', 'u/a/done')
const items = buildDeployItems({
draftItems: [],
mask: new Set([key]),
existingKeys: new Set([key])
})
expect(items).toHaveLength(1)
expect(items[0].done).toBe(true)
expect(items[0].hasDraft).toBe(false)
expect(items[0].path).toBe('u/a/done')
})
it('a mask-only entry that no longer exists (discarded or deleted) is dropped', () => {
const key = maskKey('script', 'u/a/gone')
// Existence unresolved → no mask-only rows yet.
expect(buildDeployItems({ draftItems: [], mask: new Set([key]) })).toHaveLength(0)
// Existence resolved to gone → dropped.
expect(
buildDeployItems({ draftItems: [], mask: new Set([key]), existingKeys: new Set() })
).toHaveLength(0)
})
it('a draft covers its mask entry (no duplicate deployed row)', () => {
const key = maskKey('script', 'u/a/foo')
const items = buildDeployItems({
draftItems: [draft('script', 'u/a/foo')],
mask: new Set([key]),
existingKeys: new Set([key])
})
expect(items).toHaveLength(1)
expect(items[0].hasDraft).toBe(true)
})
it('maskOnlyCandidates returns keys not covered by a draft', () => {
const cands = maskOnlyCandidates({
draftItems: [draft('script', 'u/a/draft')],
mask: new Set([maskKey('script', 'u/a/draft'), maskKey('trigger_schedule', 'u/a/done')])
})
expect(cands.map((c) => c.key)).toEqual([maskKey('trigger_schedule', 'u/a/done')])
expect(cands[0].deployKind).toBe('schedule')
})
it('no mask lists every draft', () => {
const items = buildDeployItems({
draftItems: [draft('script', 'u/a/x'), draft('flow', 'u/a/y')]
})
expect(items).toHaveLength(2)
})
})
// ── badge ────────────────────────────────────────────────────────────────────
describe('badgeOf / badgeCounts', () => {
it('draft row → draft; deployed row → deployed', () => {
const key = maskKey('script', 'u/a/done')
const items = buildDeployItems({
draftItems: [draft('script', 'u/a/d')],
mask: new Set([maskKey('script', 'u/a/d'), key]),
existingKeys: new Set([key])
})
const by = new Map(items.map((i) => [i.path, i]))
expect(badgeOf(by.get('u/a/d')!)).toBe('draft')
expect(badgeOf(by.get('u/a/done')!)).toBe('deployed')
expect(badgeCounts(items)).toEqual({ draft: 1, deployed: 1 })
})
})
// ── action ──────────────────────────────────────────────────────────────────
describe('actionFor', () => {
it('draft → Deploy + discard secondary', () => {
const [item] = buildDeployItems({ draftItems: [draft('script', 'u/a/f')] })
const a = actionFor(item)
expect(a.op).toBe('deploy_draft')
expect(a.label).toBe('Deploy')
expect(a.secondary?.[0].op).toBe('discard')
})
it('draft-only discard is labelled "Discard draft"', () => {
const [item] = buildDeployItems({
draftItems: [draft('script', 'u/a/f', { draft_only: true })]
})
expect(actionFor(item).secondary?.[0].label).toBe('Discard draft')
})
it('deployed (done) → op none', () => {
const key = maskKey('script', 'u/a/done')
const [item] = buildDeployItems({
draftItems: [],
mask: new Set([key]),
existingKeys: new Set([key])
})
expect(actionFor(item).op).toBe('none')
})
})
// ── diff base ───────────────────────────────────────────────────────────────
describe('diffBaseFor', () => {
it('draft row → deployed↔draft base', () => {
const [item] = buildDeployItems({
draftItems: [draft('script', 'u/a/f', { draft_only: true })]
})
const b = diffBaseFor(item, 'ws')
expect(b.kind).toBe('draft')
if (b.kind === 'draft') {
expect(b.draftKind).toBe('script')
expect(b.draftOnly).toBe(true)
expect(b.workspaceId).toBe('ws')
}
})
it('deployed row → self base in the session workspace', () => {
const key = maskKey('script', 'u/a/done')
const [item] = buildDeployItems({
draftItems: [],
mask: new Set([key]),
existingKeys: new Set([key])
})
const b = diffBaseFor(item, 'ws')
expect(b.kind).toBe('self')
if (b.kind === 'self') {
expect(b.workspaceId).toBe('ws')
expect(b.path).toBe('u/a/done')
}
})
})
// ── deploy plan ─────────────────────────────────────────────────────────────
describe('deployPlanFor / discardPlanFor', () => {
it('draft → deploy_draft carrying rawApp/draftOnly', () => {
const [item] = buildDeployItems({
draftItems: [draft('raw_app', 'u/a/app', { raw_app: true, draft_only: true })]
})
const p = deployPlanFor(item)!
expect(p.op).toBe('deploy_draft')
expect(p.rawApp).toBe(true)
expect(p.draftOnly).toBe(true)
})
it('deployed (done) rows have no plan', () => {
const key = maskKey('script', 'u/a/done')
const [item] = buildDeployItems({
draftItems: [],
mask: new Set([key]),
existingKeys: new Set([key])
})
expect(item.done).toBe(true)
expect(deployPlanFor(item)).toBeUndefined()
expect(discardPlanFor(item)).toBeUndefined()
})
it('discardPlanFor carries the legacy flag', () => {
const [d] = buildDeployItems({
draftItems: [draft('script', 'u/a/f', { legacy_draft: true })]
})
const p = discardPlanFor(d)!
expect(p.op).toBe('discard')
expect(p.legacy).toBe(true)
})
})
@@ -0,0 +1,274 @@
import type { UserDraftItemKind } from '$lib/gen'
import type { DraftItem } from '$lib/workspaceDrafts.svelte'
import type { Kind } from '$lib/utils_deployable'
import { maskKey } from './modifiedItemsMask'
// Unified item model for the session Review & Deploy surface. This is the pure,
// UI-free core: every item the session touched is either a pending **draft** or
// **deployed** in the session workspace (terminal). Deploying always lands in
// the session's own workspace — promoting a fork's changes to its parent is the
// compare page's concern, not modeled here.
//
// Kept free of Svelte runes and async so it unit-tests in the node project. The
// reactive wrapper (data loading, deploy execution) lives in the sibling
// `.svelte.ts` and consumes these functions.
// ── Item taxonomy bridge ────────────────────────────────────────────────────
// The draft list speaks UserDraftItemKind (`trigger_schedule`, `raw_app`); the
// deploy utils speak the layout `Kind` (`schedule`, `raw_app`, `http_trigger`).
// This maps draft→deploy to key deploy calls and row icons. Only non-identity
// cases listed; everything else is identity.
const DEPLOY_KIND_BY_DRAFT_KIND: Partial<Record<UserDraftItemKind, Kind>> = {
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',
// Both email draft kinds deploy as the one email trigger kind: the editor
// saves per-path email-trigger drafts as `trigger_email`, the fork diff
// speaks `trigger_default_email`.
trigger_default_email: 'email_trigger',
trigger_email: 'email_trigger',
raw_app: 'raw_app'
}
function deployKindOf(draftKind: UserDraftItemKind, rawApp: boolean): Kind {
if (rawApp) return 'raw_app'
return DEPLOY_KIND_BY_DRAFT_KIND[draftKind] ?? (draftKind as unknown as Kind)
}
// ── Public types ────────────────────────────────────────────────────────────
export interface DeployItem {
/** Canonical identity `${UserDraftItemKind}:${path}`. */
key: string
/** Layout/deploy kind (script, flow, raw_app, http_trigger, schedule…). */
deployKind: Kind
/** UserDraftItemKind for draft ops. */
draftKind: UserDraftItemKind
path: string
/** Friendly path for display; storage `path` stays the op/edit key. */
displayPath: string
summary?: string
/** Deployed with no pending draft — the terminal state. */
done: boolean
hasDraft: boolean
draftOnly: boolean
draftUsers?: { username?: string | null }[]
/** Own draft (deployable/discardable). */
mine: boolean
canWrite: boolean
/** Legacy workspace-level draft (drives discard's `legacy` flag). */
legacy: boolean
rawApp: boolean
}
export interface BuildInput {
draftItems: DraftItem[]
/** Chat-modified-items mask (`UserDraftItemKind:path`). When set, only masked
* items are included, and mask-only entries (no draft) surface as terminal
* deployed rows but only when confirmed to still exist (see `existingKeys`). */
mask?: Set<string>
/** Mask keys confirmed to still exist in the workspace (deployed). A mask-only
* key that isn't here is treated as gone (a discarded draft or a deleted item)
* and dropped, so it doesn't masquerade as deployed. Undefined no mask-only
* rows are emitted yet (existence still resolving). */
existingKeys?: Set<string>
}
// ── Build: draft list + mask-only deployed rows into unified items ──────────
export function buildDeployItems(input: BuildInput): DeployItem[] {
const { draftItems, mask, existingKeys } = input
const scopedDrafts = mask
? draftItems.filter((it) => mask.has(maskKey(it.kind, it.path)))
: draftItems
const out: DeployItem[] = []
const seen = new Set<string>()
// 1. Pending drafts.
for (const it of scopedDrafts) {
const canonical = maskKey(it.kind, it.path)
if (seen.has(canonical)) continue
seen.add(canonical)
out.push({
key: canonical,
deployKind: deployKindOf(it.kind, it.raw_app),
draftKind: it.kind,
path: it.path,
displayPath: it.draft_path ?? it.path,
summary: it.summary,
done: false,
hasDraft: true,
draftOnly: it.draft_only,
draftUsers: it.draft_users,
mine: it.mine,
canWrite: it.can_write,
legacy: it.legacy_draft,
rawApp: it.raw_app
})
}
// 2. Mask-only items (touched by the chat, no pending draft) → terminal
// deployed rows, but ONLY when confirmed to still exist. A discarded draft
// or a deleted item leaves a mask entry with no item; without this guard it
// would masquerade as deployed.
if (mask) {
for (const k of mask) {
if (seen.has(k)) continue
if (!existingKeys?.has(k)) continue
const sep = k.indexOf(':')
if (sep < 0) continue
const udk = k.slice(0, sep) as UserDraftItemKind
const path = k.slice(sep + 1)
seen.add(k)
out.push({
key: k,
deployKind: deployKindOf(udk, udk === 'raw_app'),
draftKind: udk,
path,
displayPath: path,
summary: undefined,
done: true,
hasDraft: false,
draftOnly: false,
draftUsers: undefined,
mine: true,
canWrite: true,
legacy: false,
rawApp: udk === 'raw_app'
})
}
}
return out
}
/** Mask keys not covered by a current draft the candidates for a terminal
* deployed row. The reactive layer existence-checks these (in the session
* workspace) to tell a deployed item from a discarded/deleted one, then feeds
* the survivors back as `existingKeys`. */
export function maskOnlyCandidates(
input: Omit<BuildInput, 'existingKeys'>
): { key: string; deployKind: Kind; draftKind: UserDraftItemKind; path: string }[] {
const { draftItems, mask } = input
if (!mask) return []
const covered = new Set<string>()
for (const it of draftItems) {
const k = maskKey(it.kind, it.path)
if (mask.has(k)) covered.add(k)
}
const out: { key: string; deployKind: Kind; draftKind: UserDraftItemKind; path: string }[] = []
for (const k of mask) {
if (covered.has(k)) continue
const sep = k.indexOf(':')
if (sep < 0) continue
const udk = k.slice(0, sep) as UserDraftItemKind
const path = k.slice(sep + 1)
out.push({ key: k, draftKind: udk, deployKind: deployKindOf(udk, udk === 'raw_app'), path })
}
return out
}
// ── Status badge ─────────────────────────────────────────────────────────────
export type BadgeKind = 'draft' | 'deployed'
/** The single status badge a row shows: a pending draft, or deployed (green,
* terminal). Every built row is one of the two. */
export function badgeOf(item: DeployItem): BadgeKind {
return item.done ? 'deployed' : 'draft'
}
/** Per-status tallies over `badgeOf` the bar's at-a-glance readout, so its
* counts always agree with the badges the drawer rows show. */
export function badgeCounts(items: DeployItem[]): Record<BadgeKind, number> {
const out: Record<BadgeKind, number> = { draft: 0, deployed: 0 }
for (const it of items) out[badgeOf(it)]++
return out
}
// ── Action ──────────────────────────────────────────────────────────────────
export type DeployOp = 'deploy_draft' | 'discard' | 'none'
export interface DeployAction {
op: DeployOp
label: string
secondary?: DeployAction[]
}
/** The primary (and secondary) action for a row: a draft deploys into the
* session workspace, a deployed row has nothing left to do. */
export function actionFor(item: DeployItem): DeployAction {
if (item.done) return { op: 'none', label: 'Done' }
return {
op: 'deploy_draft',
label: 'Deploy',
secondary: [{ op: 'discard', label: item.draftOnly ? 'Discard draft' : 'Discard' }]
}
}
// ── Diff base ───────────────────────────────────────────────────────────────
export type DiffBase =
| { kind: 'draft'; draftKind: UserDraftItemKind; workspaceId: string; draftOnly: boolean }
| { kind: 'self'; deployKind: Kind; path: string; workspaceId: string }
/** Where a row's before/after diff values come from. A draft row diffs
* deployeddraft; a deployed row has nothing to diff but its value is still
* loadable (`self`) the sidebar unwraps a deployed raw app into its files.
* The executor maps this to getDraftDiffValues / getItemValue. */
export function diffBaseFor(item: DeployItem, workspaceId: string): DiffBase {
if (item.hasDraft) {
return { kind: 'draft', draftKind: item.draftKind, workspaceId, draftOnly: item.draftOnly }
}
return { kind: 'self', deployKind: item.deployKind, path: item.path, workspaceId }
}
// ── Deploy plan (executed by the reactive layer) ─────────────────────────────
export interface DeployPlanEntry {
key: string
op: Exclude<DeployOp, 'none'>
draftKind: UserDraftItemKind
path: string
draftOnly: boolean
rawApp: boolean
legacy: boolean
}
/** Concrete util arguments for deploying one item's draft into the session
* workspace. Returns undefined for deployed (terminal) rows. Mirrors actionFor. */
export function deployPlanFor(item: DeployItem): DeployPlanEntry | undefined {
if (!item.hasDraft) return undefined
return {
key: item.key,
op: 'deploy_draft',
draftKind: item.draftKind,
path: item.path,
draftOnly: item.draftOnly,
rawApp: item.rawApp,
legacy: item.legacy
}
}
/** Discard plan for a draft row (secondary action). */
export function discardPlanFor(item: DeployItem): DeployPlanEntry | undefined {
if (!item.hasDraft) return undefined
return {
key: item.key,
op: 'discard',
draftKind: item.draftKind,
path: item.path,
draftOnly: item.draftOnly,
rawApp: item.rawApp,
legacy: item.legacy
}
}
@@ -7,12 +7,10 @@ import {
AppService,
FlowService,
ScriptService,
WorkspaceService,
type Flow,
type NewScript,
type Script,
type UserDraftOverlay,
type WorkspaceComparison
type UserDraftOverlay
} from '$lib/gen'
// `get_draft=true` does NOT merge: the top-level fields stay the deployed
@@ -141,37 +139,13 @@ export interface SessionRuntime {
requestRuntimeLogs(limit: number): Promise<RawAppRuntimeLogEntry[] | undefined>
setAppRunsProvider(provider: RawAppRunsProvider | undefined): void
getAppRuns(): RawAppRunSummary[] | undefined
// Discard the local draft + refresh the fork diff + force-reload the editor,
// so the preview matches the deployed version. Used by editor onDeploy + the
// chat deploy handler.
// Discard the local draft + force-reload the editor, so the preview matches
// the deployed version. Used by editor onDeploy + the chat deploy handler.
syncPreviewWithDeployed(
workspace: string,
kind: 'script' | 'flow' | 'raw_app',
path: string
): 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>
// Re-fetch the comparison shortly after a local mutation (e.g. an
// editor "Save draft"). The backend tally that registers the change
// lands asynchronously, so an immediate refresh races it — this
// schedules a couple of delayed refreshes so the fork-bar count
// updates without waiting for an AI turn or a tab refocus.
scheduleForkComparisonRefresh(): void
// Cancel pending fork-comparison refresh timers. Called by disposeRuntime so
// a torn-down (e.g. LRU-evicted, deleted) runtime can't fire a stray
// refreshForkComparisonNow / compareWorkspaces after it's gone.
dispose(): void
}
const runtimes = new SvelteMap<string, SessionRuntime>()
@@ -320,34 +294,6 @@ function createRuntime(session: Session): SessionRuntime {
let runtimeLogRequester: RawAppRuntimeLogRequester | undefined = undefined
let appRunsProvider: RawAppRunsProvider | undefined = undefined
const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined })
let loadingForkComparison = $state(false)
let forkComparisonKey: string | undefined = undefined
let forkRefreshTimers: ReturnType<typeof setTimeout>[] = []
// Stale-while-revalidate re-fetch against the last (parent, fork) pair.
// Shared by refreshForkComparison() and scheduleForkComparisonRefresh().
async function refreshForkComparisonNow() {
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)
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
}
}
return {
sessionId: session.id,
manager,
@@ -645,7 +591,6 @@ function createRuntime(session: Session): SessionRuntime {
},
syncPreviewWithDeployed(workspace, kind, path) {
this.scheduleForkComparisonRefresh()
// After deploy the editor state equals the deployed value; the reload
// below re-seeds the cell from it, which must NOT POST as a fresh draft.
// The full-page editor guards this with discardDraftAfterDeploy, but the
@@ -671,63 +616,6 @@ function createRuntime(session: Session): SessionRuntime {
},
getAppRuns() {
return appRunsProvider ? appRunsProvider() : undefined
},
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
},
// 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.
refreshForkComparison() {
return refreshForkComparisonNow()
},
scheduleForkComparisonRefresh() {
// The backend fork tally registers a draft save (createScript
// draft_only / DraftService.createDraft) asynchronously — ~300ms
// after the API call returns — so an immediate refresh races it.
// Fetch once after the tally typically lands, then again as a
// backstop for slower backends. Coalesce rapid saves by clearing
// any still-pending timers first.
for (const t of forkRefreshTimers) clearTimeout(t)
forkRefreshTimers = [
setTimeout(() => void refreshForkComparisonNow(), 700),
setTimeout(() => void refreshForkComparisonNow(), 2200)
]
},
dispose() {
for (const t of forkRefreshTimers) clearTimeout(t)
forkRefreshTimers = []
}
}
}
@@ -741,11 +629,21 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
await manager.attachedFiles.restore(session.id, !session.transient)
await ensureChatIdsSeeded(manager.historyManager)
// Keep the session record's chatId following the manager's active chat: a
// "/clear" rotation or a history switch would otherwise leave it pointing at
// the previous chat, and the compare-page handoff (`from_session`) would
// preselect the wrong chat's items.
manager.onChatRotated = (chatId) => setSessionChatId(session.id, chatId)
if (session.chatId) {
manager.historyManager.setCurrentChatId(session.chatId)
await manager.historyManager.tagChatWithSession(session.chatId, session.id)
await manager.loadPastChat(session.chatId)
} else {
// Brand-new session chat: start tracking modified items now (empty mask)
// so the session bar filters to this chat's changes from the first turn.
// (loadPastChat handles seeding for the existing-chat branch above.)
manager.initModifiedItemsTracking()
setSessionChatId(session.id, manager.historyManager.getCurrentChatId())
}
}
@@ -763,7 +661,6 @@ export function getOrCreateRuntime(session: Session): SessionRuntime {
export function disposeRuntime(sessionId: string) {
const runtime = runtimes.get(sessionId)
if (!runtime) return
runtime.dispose()
runtime.manager.cancel('runtime disposed')
runtime.manager.historyManager.close()
runtimes.delete(sessionId)
@@ -32,7 +32,7 @@ export function syncWorkspaceTo(workspaceId: string | undefined): void {
if (workspaceId === get(workspaceStore)) return
switchWorkspace(workspaceId)
}
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte'
import { onUserChange } from '$lib/userScopedStorage'
@@ -52,23 +52,9 @@ export const EDITOR_TARGET_KINDS: ReadonlySet<SessionTarget['kind']> = new Set([
'pipeline'
])
// 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.
// has a parent). Used by the sidebar to pick between a root (Building)
// icon and a fork icon.
//
// 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
@@ -81,29 +67,6 @@ export function isForkSession(session: Session, allWorkspaces: UserWorkspace[]):
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
@@ -3,7 +3,6 @@ import { get } from 'svelte/store'
import {
commitSessionWorkspace,
decideSessionLifecycle,
deriveForkStatus,
isForkSession,
renameSession,
setGeneratedSessionSummary,
@@ -16,7 +15,7 @@ import {
workspaceStore,
type UserWorkspace
} from '$lib/stores'
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { WorkspaceService } from '$lib/gen'
// Force createWorkspaceFork to fail so we can pin commitSessionWorkspace's
// failure contract (the invariant the beforeSend abort fix relies on).
@@ -40,9 +39,6 @@ function session(over: Partial<Session> = {}): Session {
function ws(id: string, parent?: string): UserWorkspace {
return { id, name: id, parent_workspace_id: parent } as unknown as UserWorkspace
}
function comparison(total_ahead: number, total_behind: number): WorkspaceComparison {
return { summary: { total_ahead, total_behind } } as unknown as WorkspaceComparison
}
describe('isForkSession', () => {
it('is false for a draft with no workspace at all', () => {
@@ -72,60 +68,6 @@ describe('isForkSession', () => {
})
})
describe('deriveForkStatus', () => {
it('is undefined for a draft with no workspace', () => {
expect(deriveForkStatus(session(), [], undefined)).toBeUndefined()
})
it('is unavailable when a committed workspace is no longer in the list', () => {
expect(deriveForkStatus(session({ workspace_id: 'gone' }), [], comparison(0, 0))).toBe(
'unavailable'
)
})
it('is undefined when a draft pending workspace is missing (not yet committed)', () => {
expect(
deriveForkStatus(session({ pending_workspace_id: 'gone' }), [], undefined)
).toBeUndefined()
})
it('is undefined for a non-fork (root) workspace', () => {
expect(
deriveForkStatus(session({ workspace_id: 'root' }), [ws('root')], comparison(3, 3))
).toBeUndefined()
})
it('is undefined for a fork before the comparison has loaded', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], undefined)
).toBeUndefined()
})
it('is diverged when the fork is both ahead and behind', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(2, 1))
).toBe('diverged')
})
it('is ahead when the fork has unmerged changes and the parent has not moved', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(2, 0))
).toBe('ahead')
})
it('is in_sync when neither side is ahead', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(0, 0))
).toBe('in_sync')
})
it('is in_sync when only the parent moved (behind-only, fork has no local changes)', () => {
expect(
deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(0, 2))
).toBe('in_sync')
})
})
describe('commitSessionWorkspace — fork-creation failure', () => {
it('returns undefined and drops pending_fork (so beforeSend aborts the send)', async () => {
const id = 'test-commit-fork-fail'
@@ -351,9 +293,12 @@ describe('decideSessionLifecycle — the never-orphaned rule (pure)', () => {
})
it('active workspace → unarchive only the ones WE archived (archivedByWorkspace)', () => {
expect(decideSessionLifecycle(mk({ archived: true, archivedByWorkspace: true }), 'active')).toEqual(
{ action: 'unarchive', patch: { archived: undefined, archivedByWorkspace: undefined } }
)
expect(
decideSessionLifecycle(mk({ archived: true, archivedByWorkspace: true }), 'active')
).toEqual({
action: 'unarchive',
patch: { archived: undefined, archivedByWorkspace: undefined }
})
// user-archived (no archivedByWorkspace flag) is left alone
expect(decideSessionLifecycle(mk({ archived: true }), 'active')).toEqual({ action: 'noop' })
expect(decideSessionLifecycle(mk(), 'active')).toEqual({ action: 'noop' })
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { draftBaseIsStale } from './utils_draft_deploy'
// draftBaseIsStale compares a draft's base pointer against the deployed head
// of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts
// and the session Edits drawer — a regression here silently hides (or
// fabricates) the "started from an older deployed version" warning.
describe('draftBaseIsStale', () => {
it('script: stale iff the draft parent_hash differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v1' } })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v2' } })).toBe(false)
})
it('script: no base pointer or no head → not stale (nothing to compare)', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: {} })).toBe(false)
expect(draftBaseIsStale('script', { draft: { parent_hash: 'v1' } })).toBe(false)
})
it('flow: compares the pinned version_id against the deployed head', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 5 } })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 7 } })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7, draft: {} })).toBe(false)
})
it('app/raw_app: compares parent_version against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft: { parent_version: 2 } })).toBe(
true
)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft: { parent_version: 3 } })).toBe(
false
)
expect(draftBaseIsStale('app', { versions: [], draft: { parent_version: 2 } })).toBe(false)
})
it('no draft on the response → not stale', () => {
expect(draftBaseIsStale('script', { hash: 'v2' })).toBe(false)
expect(draftBaseIsStale('script', undefined)).toBe(false)
})
})
+50
View File
@@ -262,6 +262,56 @@ export async function getDraftDiffValues(
}
}
/**
* Whether a draft's base is stale: the deployed version the draft forked from
* no longer matches the current deployed head a newer version was deployed
* after the draft began, so deploying the draft would silently revert it.
* Scripts compare the draft's `parent_hash` vs the deployed `hash`; flows the
* pinned `version_id` vs the deployed head `version_id`; apps (incl. raw) the
* pinned `parent_version` vs the head of `versions`. `r` is the item fetched
* with `get_draft=true`; only script/flow/app kinds carry a base pointer.
*/
export function draftBaseIsStale(draftKind: UserDraftItemKind, r: any): boolean {
const draft = r?.draft
if (!draft) return false
if (draftKind === 'script') {
return !!r.hash && !!draft.parent_hash && draft.parent_hash !== r.hash
}
if (draftKind === 'flow') {
return r.version_id != null && draft.version_id != null && draft.version_id !== r.version_id
}
const head = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined
return head != null && draft.parent_version != null && draft.parent_version !== head
}
/** Fetch-and-test wrapper over `draftBaseIsStale` for one draft item. Returns
* false for kinds without a base pointer and on fetch errors (warn, not block). */
export async function fetchDraftBaseStale(
draftKind: UserDraftItemKind,
path: string,
workspace: string
): Promise<boolean> {
try {
if (draftKind === 'script') {
const r = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
return draftBaseIsStale(draftKind, r)
}
if (draftKind === 'flow') {
const r = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
return draftBaseIsStale(draftKind, r)
}
if (draftKind === 'app' || draftKind === 'raw_app') {
// The apps endpoint auto-detects a raw app and overlays its draft.
const r = await AppService.getAppByPath({ workspace, path, getDraft: true })
return draftBaseIsStale(draftKind, r)
}
return false
} catch (e) {
console.error(`Stale-draft check failed for ${draftKind}:${path}`, e)
return false
}
}
/**
* Deploy a script/flow draft's trigger changes the same way the editors do.
* Scripts and flows can carry `draft_triggers`; the create/update call below
@@ -14,9 +14,14 @@ import {
ScheduleService,
ScriptService,
SqsTriggerService,
UserService,
VariableService,
WebsocketTriggerService
} from '$lib/gen'
import {
fetchProtectionRulesForWorkspace,
canUserBypassRuleKindInRulesets
} from '$lib/workspaceProtectionRules.svelte'
import {
existsTrigger,
getTriggersDeployData,
@@ -382,3 +387,42 @@ export async function getOnBehalfOf(
}
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
}
export type DeployPermission = { ok: boolean; reason?: string }
/**
* Whether the current user may deploy into `workspace`. Mirrors the server-side
* deploy authorization (`check_user_against_rule` in windmill-common) so the UI
* can disable the action with a reason instead of letting the click 403:
* - operators can never deploy;
* - when the `RestrictDeployToDeployers` protection rule is active, only
* admins, `wm_deployers` members (implicitly), and per-ruleset bypass
* users/groups may deploy.
* Fails open on any error the server still enforces on the actual deploy.
* Shared by the session dock and the compare page so both gate identically.
*/
export async function checkDeployPermission(workspace: string): Promise<DeployPermission> {
try {
const me = await UserService.whoami({ workspace })
if (me.operator) {
return { ok: false, reason: "You're an operator in this workspace — operators can't deploy" }
}
// Admins and wm_deployers members always satisfy RestrictDeployToDeployers
// (the backend allows wm_deployers implicitly, so check it before the
// per-ruleset bypass_users/bypass_groups fallback).
const isDeployer = me.is_admin || (me.groups ?? []).includes('wm_deployers')
if (!isDeployer) {
const rulesets = await fetchProtectionRulesForWorkspace(workspace)
const userInfo = { is_admin: !!me.is_admin, username: me.username, groups: me.groups ?? [] }
if (!canUserBypassRuleKindInRulesets(rulesets, 'RestrictDeployToDeployers', userInfo)) {
return {
ok: false,
reason: 'Only workspace admins and members of wm_deployers can deploy here'
}
}
}
return { ok: true }
} catch {
return { ok: true }
}
}
@@ -1,6 +1,11 @@
import { WorkspaceService, type ProtectionRuleset, type ProtectionRuleKind } from './gen'
import type { UserExt } from './stores'
// The slice of the user identity the bypass checks read — structural, so
// callers can pass a whoami response (normalised groups) as well as the
// UserExt store value.
export type RuleBypassUser = Pick<UserExt, 'is_admin' | 'username' | 'groups'>
/**
* Internal reactive state using Svelte 5 $state rune
*/
@@ -94,7 +99,7 @@ export async function fetchProtectionRulesForWorkspace(
* @param userInfo The user information
* @returns true if user can bypass (is admin, in bypass_users, or has group in bypass_groups)
*/
export function canUserBypassRule(ruleset: ProtectionRuleset, userInfo: UserExt): boolean {
export function canUserBypassRule(ruleset: ProtectionRuleset, userInfo: RuleBypassUser): boolean {
// Admin always bypasses
if (userInfo.is_admin) {
return true
@@ -134,7 +139,7 @@ export function isRuleActive(ruleKind: ProtectionRuleKind): boolean {
*/
export function canUserBypassRuleKind(
ruleKind: ProtectionRuleKind,
userInfo: UserExt | undefined
userInfo: RuleBypassUser | undefined
): boolean {
// If no user info, default to permissive
if (!userInfo) {
@@ -187,7 +192,7 @@ export function isRuleActiveInRulesets(
export function canUserBypassRuleKindInRulesets(
rulesets: ProtectionRuleset[],
ruleKind: ProtectionRuleKind,
userInfo: UserExt | undefined
userInfo: RuleBypassUser | undefined
): boolean {
// If no user info, default to not allowing bypass
if (!userInfo) {
@@ -19,6 +19,7 @@
import { sendUserToast } from '$lib/toast'
import { switchWorkspace } from '$lib/storeUtils'
import { goto } from '$lib/navigation'
import { readChatModifiedItems } from '$lib/components/copilot/chat/HistoryManager.svelte'
type CompareMode = 'fork' | 'draft'
@@ -48,6 +49,37 @@
// selection here; the page only swaps which comparison component is shown.
let forkDirection = $state<'deploy_to' | 'update'>('deploy_to')
// When reached via a session's Review button (`from_session=<chatId>`), preselect
// only the items that chat modified. The mask is the chat's stored
// `${UserDraftItemKind}:${storagePath}` set; undefined for a legacy chat (no
// stored mask) → the children fall back to selecting all deployable items.
// Derived from the live URL: an in-app navigation to this route with a
// different from_session must reload the mask, not keep the first one.
const fromChatId = $derived(page.url.searchParams.get('from_session'))
let chatMask = $state<Set<string> | undefined>(undefined)
// The mask loads asynchronously, while the resolved value can legitimately be
// undefined (legacy chat). The children must not run their select-all default
// until the mask is known, else they'd race it and select everything. Ready
// immediately when there's no session to read from.
let chatMaskReady = $state(!page.url.searchParams.get('from_session'))
$effect(() => {
const id = fromChatId
chatMask = undefined
chatMaskReady = !id
if (!id) return
untrack(() => {
void readChatModifiedItems(id)
.then((arr) => {
// A slower read for a superseded chat id must not win.
if (id !== untrack(() => fromChatId)) return
chatMask = arr ? new Set(arr) : undefined
})
.finally(() => {
if (id === untrack(() => fromChatId)) chatMaskReady = true
})
})
})
function selectMode(v: 'deploy_to' | 'update' | 'draft') {
if (v === 'draft') {
mode = 'draft'
@@ -252,6 +284,8 @@
{deployCount}
{updateCount}
{draftCount}
{chatMask}
{chatMaskReady}
onModeSelected={selectMode}
/>
{:else if parentWorkspaceId}
@@ -264,6 +298,8 @@
{updateCount}
{draftCount}
{draftKeys}
{chatMask}
{chatMaskReady}
onChanged={refreshCounts}
onModeSelected={selectMode}
/>
@@ -51,17 +51,12 @@
// 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.
// so a value-trigger would re-run dozens of times per turn. We only
// want to react when the user actually arrives at a new session.
let lastArrivedSessionId: string | undefined
$effect(() => {
const session = activeSession
@@ -76,8 +71,7 @@
// (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()
getOrCreateRuntime(session)
})
})
@@ -0,0 +1,194 @@
<script lang="ts">
import WorkspaceDiffDrawer from '$lib/components/sessions/WorkspaceDiffDrawer.svelte'
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import type { DeployItem } from '$lib/components/sessions/sessionDeployModel'
import type {
DeploymentStatus,
SessionDeployModel
} from '$lib/components/sessions/sessionDeployModel.svelte'
// Playground for the session Edits drawer deploy animation: the REAL
// WorkspaceDiffDrawer driven by a mock model with adjustable latency,
// outcome, data-flip lag, and content size — no backend involved.
let deployMs = $state(800)
let flipDelayMs = $state(300)
let holdMs = $state(1200)
let diffLines = $state(25)
let itemCount = $state(3)
let failDeploys = $state(false)
let permOk = $state(true)
let staleDrafts = $state(false)
function makeItem(i: number, done = false): DeployItem {
return {
key: `script:u/dev/demo_${i}`,
deployKind: 'script',
draftKind: 'script',
path: `u/dev/demo_${i}`,
displayPath: `u/dev/demo_${i}`,
summary: `Demo script ${i}`,
done,
hasDraft: !done,
draftOnly: i % 2 === 0,
draftUsers: undefined,
mine: true,
canWrite: true,
legacy: false,
rawApp: false
}
}
let items = $state<DeployItem[]>([])
let statuses = $state<Record<string, DeploymentStatus>>({})
let deploying = $state(false)
function reset() {
statuses = {}
items = [...Array.from({ length: itemCount }, (_, i) => makeItem(i)), makeItem(99, true)]
}
reset()
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function genContent(seed: string, lines: number, mutated: boolean): string {
const out = [`// ${seed}`, 'export async function main() {']
for (let i = 0; i < lines; i++) {
out.push(
mutated && i % 4 === 0
? `\tconst v${i} = ${i} * 2 // changed by the chat`
: `\tconst v${i} = ${i}`
)
}
if (mutated) out.push('\tconst added = "new trailing logic"')
out.push(`\treturn v0`, '}')
return out.join('\n')
}
function fakeScript(content: string) {
return { summary: 'Demo', description: '', content, language: 'bun', schema: {} }
}
function setStatus(key: string, s: DeploymentStatus | undefined) {
const next = { ...statuses }
if (s) next[key] = s
else delete next[key]
statuses = next
}
const model: SessionDeployModel = {
get items() {
return items
},
get loading() {
return false
},
get error() {
return undefined
},
load() {},
async loadDiffValues(item: DeployItem) {
await sleep(250)
const after = fakeScript(genContent(item.key, diffLines, true))
if (item.done) return { before: after, after }
return {
before: item.draftOnly ? undefined : fakeScript(genContent(item.key, diffLines, false)),
after
}
},
get deploying() {
return deploying
},
statusOf(key: string) {
return statuses[key]
},
staleOf(key: string) {
// Only non-draft_only rows carry a base pointer in production.
return staleDrafts && !items.find((it) => it.key === key)?.draftOnly
},
get deployPermission() {
return permOk
? { ok: true as const }
: { ok: false as const, reason: 'Deploy disabled by the playground toggle' }
},
async deployRow(item: DeployItem) {
setStatus(item.key, { status: 'loading' })
deploying = true
await sleep(deployMs)
deploying = false
if (failDeploys) {
setStatus(item.key, { status: 'failed', error: 'Simulated failure from the playground' })
return false
}
setStatus(item.key, undefined)
// Mimic the production lag between deploy success and the draft
// refetch flipping the item to done.
setTimeout(() => {
items = items.map((it) =>
it.key === item.key ? { ...it, done: true, hasDraft: false, draftOnly: false } : it
)
}, flipDelayMs)
return true
},
async discardRow(item: DeployItem) {
await sleep(deployMs)
items = items.filter((it) => it.key !== item.key)
}
}
let drawer: WorkspaceDiffDrawer | undefined = $state(undefined)
</script>
<div class="p-8 flex flex-col gap-6 max-w-2xl">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-primary">Deploy animation playground</h1>
<DarkModeToggle forcedDarkMode={false} />
</div>
<p class="text-xs text-secondary">
Drives the real <code>WorkspaceDiffDrawer</code> with a mock deploy model. Tune the knobs, open the
drawer, hit Deploy, feel the transition. Reset re-arms the drafts.
</p>
<div class="grid grid-cols-2 gap-x-8 gap-y-4 text-xs text-primary">
<label class="flex flex-col gap-1">
<span>Deploy latency: <b>{deployMs}ms</b> (spinner phase)</span>
<input type="range" min="0" max="5000" step="100" bind:value={deployMs} />
</label>
<label class="flex flex-col gap-1">
<span>Success hold: <b>{holdMs}ms</b> (green check beat)</span>
<input type="range" min="0" max="4000" step="100" bind:value={holdMs} />
</label>
<label class="flex flex-col gap-1">
<span>Data flip lag: <b>{flipDelayMs}ms</b> (mock draft refetch)</span>
<input type="range" min="0" max="3000" step="100" bind:value={flipDelayMs} />
</label>
<label class="flex flex-col gap-1">
<span>Diff size: <b>{diffLines}</b> lines</span>
<input type="range" min="3" max="200" step="1" bind:value={diffLines} />
</label>
<label class="flex flex-col gap-1">
<span>Items: <b>{itemCount}</b> drafts (+1 deployed)</span>
<input type="range" min="1" max="10" step="1" bind:value={itemCount} onchange={reset} />
</label>
<div class="flex flex-col gap-2 justify-center">
<Toggle bind:checked={failDeploys} options={{ right: 'Fail deploys' }} size="xs" />
<Toggle bind:checked={permOk} options={{ right: 'Deploy permission' }} size="xs" />
<Toggle bind:checked={staleDrafts} options={{ right: 'Stale drafts' }} size="xs" />
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="accent" unifiedSize="sm" onclick={() => drawer?.open()}>Open drawer</Button>
<Button variant="default" unifiedSize="sm" onclick={reset}>Reset items</Button>
</div>
</div>
<WorkspaceDiffDrawer
bind:this={drawer}
{model}
title="Deploy animation playground"
workspaceLabel="playground-workspace"
successHoldMs={holdMs}
/>