Files
windmill/frontend/src/lib/components/sessions/sessionDeployModel.ts
T
GuilhemandClaude Opus 4.8 a6c0b3756b 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>
2026-07-06 09:23:54 +02:00

275 lines
10 KiB
TypeScript

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
}
}