mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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:
co-authored by
Claude Opus 4.8
parent
234a7254d5
commit
a6c0b3756b
@@ -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
|
||||
|
||||
@@ -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
|
||||
* deployed↔draft; 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)
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
Reference in New Issue
Block a user