feat(description): add deployment version descriptions (#3048)

* feat(description): added version description for deployments table

* feat(description): refactor to tanstack query and remove useEffect

* add wand to generate diff

* ack comments

* removed redundant logic, kept single source of truth for diff

* updated docs

* use consolidated sse parsing util, add loops & parallels check

* DRY
This commit is contained in:
Waleed
2026-01-28 13:52:40 -08:00
committed by GitHub
parent c00f05c346
commit 8b2404752b
18 changed files with 11562 additions and 308 deletions

View File

@@ -9,13 +9,24 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
const logger = createLogger('WorkflowDeploymentVersionAPI')
const patchBodySchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less'),
})
const patchBodySchema = z
.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less')
.optional(),
description: z
.string()
.trim()
.max(500, 'Description must be 500 characters or less')
.nullable()
.optional(),
})
.refine((data) => data.name !== undefined || data.description !== undefined, {
message: 'At least one of name or description must be provided',
})
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -88,33 +99,46 @@ export async function PATCH(
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
}
const { name } = validation.data
const { name, description } = validation.data
const updateData: { name?: string; description?: string | null } = {}
if (name !== undefined) {
updateData.name = name
}
if (description !== undefined) {
updateData.description = description
}
const [updated] = await db
.update(workflowDeploymentVersion)
.set({ name })
.set(updateData)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name })
.returning({
id: workflowDeploymentVersion.id,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
})
if (!updated) {
return createErrorResponse('Deployment version not found', 404)
}
logger.info(
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
)
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
name: updateData.name,
description: updateData.description,
})
return createSuccessResponse({ name: updated.name })
return createSuccessResponse({ name: updated.name, description: updated.description })
} catch (error: any) {
logger.error(
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`,
`[${requestId}] Error updating deployment version ${version} for workflow ${id}`,
error
)
return createErrorResponse(error.message || 'Failed to rename deployment version', 500)
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
}
}

View File

@@ -26,6 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy,

View File

@@ -0,0 +1,170 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import {
useGenerateVersionDescription,
useUpdateDeploymentVersion,
} from '@/hooks/queries/deployments'
interface VersionDescriptionModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
version: number
versionName: string
currentDescription: string | null | undefined
}
export function VersionDescriptionModal({
open,
onOpenChange,
workflowId,
version,
versionName,
currentDescription,
}: VersionDescriptionModalProps) {
const initialDescriptionRef = useRef(currentDescription || '')
const [description, setDescription] = useState(initialDescriptionRef.current)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const updateMutation = useUpdateDeploymentVersion()
const generateMutation = useGenerateVersionDescription()
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
const isGenerating = generateMutation.isPending
const handleCloseAttempt = useCallback(() => {
if (updateMutation.isPending || isGenerating) {
return
}
if (hasChanges) {
setShowUnsavedChangesAlert(true)
} else {
onOpenChange(false)
}
}, [hasChanges, updateMutation.isPending, isGenerating, onOpenChange])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
onOpenChange(false)
}, [onOpenChange])
const handleGenerateDescription = useCallback(() => {
generateMutation.mutate({
workflowId,
version,
onStreamChunk: (accumulated) => {
setDescription(accumulated)
},
})
}, [workflowId, version, generateMutation])
const handleSave = useCallback(() => {
if (!workflowId) return
updateMutation.mutate(
{
workflowId,
version,
description: description.trim() || null,
},
{
onSuccess: () => {
onOpenChange(false)
},
}
)
}, [workflowId, version, description, updateMutation, onOpenChange])
return (
<>
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>
<span>Version Description</span>
</ModalHeader>
<ModalBody className='space-y-[10px]'>
<div className='flex items-center justify-between'>
<p className='text-[12px] text-[var(--text-secondary)]'>
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
</p>
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleGenerateDescription}
disabled={isGenerating || updateMutation.isPending}
>
{isGenerating ? 'Generating...' : 'Generate'}
</Button>
</div>
<Textarea
placeholder='Describe the changes in this deployment version...'
className='min-h-[120px] resize-none'
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
disabled={isGenerating}
/>
<div className='flex items-center justify-between'>
{(updateMutation.error || generateMutation.error) && (
<p className='text-[12px] text-[var(--text-error)]'>
{updateMutation.error?.message || generateMutation.error?.message}
</p>
)}
{!updateMutation.error && !generateMutation.error && <div />}
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={handleCloseAttempt}
disabled={updateMutation.isPending || isGenerating}
>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSave}
disabled={updateMutation.isPending || isGenerating || !hasChanges}
>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='max-w-[400px]'>
<ModalHeader>
<span>Unsaved Changes</span>
</ModalHeader>
<ModalBody>
<p className='text-[14px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleDiscardChanges}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,26 +1,31 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { formatDateTime } from '@/lib/core/utils/formatting'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
import { VersionDescriptionModal } from './version-description-modal'
const logger = createLogger('Versions')
/** Shared styling constants aligned with terminal component */
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
const COLUMN_BASE_CLASS = 'flex-shrink-0'
/** Column width configuration */
const COLUMN_WIDTHS = {
VERSION: 'w-[180px]',
DEPLOYED_BY: 'w-[140px]',
TIMESTAMP: 'flex-1',
ACTIONS: 'w-[32px]',
ACTIONS: 'w-[56px]',
} as const
interface VersionsProps {
@@ -31,34 +36,6 @@ interface VersionsProps {
onSelectVersion: (version: number | null) => void
onPromoteToLive: (version: number) => void
onLoadDeployment: (version: number) => void
fetchVersions: () => Promise<void>
}
/**
* Formats a timestamp into a readable string.
* @param value - The date string or Date object to format
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
*/
const formatDate = (value: string | Date): string => {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const timePart = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
})
const datePart = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return `${timePart} on ${datePart}`
}
/**
@@ -73,14 +50,15 @@ export function Versions({
onSelectVersion,
onPromoteToLive,
onLoadDeployment,
fetchVersions,
}: VersionsProps) {
const [editingVersion, setEditingVersion] = useState<number | null>(null)
const [editValue, setEditValue] = useState('')
const [isRenaming, setIsRenaming] = useState(false)
const [openDropdown, setOpenDropdown] = useState<number | null>(null)
const [descriptionModalVersion, setDescriptionModalVersion] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const renameMutation = useUpdateDeploymentVersion()
useEffect(() => {
if (editingVersion !== null && inputRef.current) {
inputRef.current.focus()
@@ -94,7 +72,8 @@ export function Versions({
setEditValue(currentName || `v${version}`)
}
const handleSaveRename = async (version: number) => {
const handleSaveRename = (version: number) => {
if (renameMutation.isPending) return
if (!workflowId || !editValue.trim()) {
setEditingVersion(null)
return
@@ -108,25 +87,21 @@ export function Versions({
return
}
setIsRenaming(true)
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editValue.trim() }),
})
if (res.ok) {
await fetchVersions()
setEditingVersion(null)
} else {
logger.error('Failed to rename version')
renameMutation.mutate(
{
workflowId,
version,
name: editValue.trim(),
},
{
onSuccess: () => {
setEditingVersion(null)
},
onError: () => {
// Keep editing state open on error so user can retry
},
}
} catch (error) {
logger.error('Error renaming version:', error)
} finally {
setIsRenaming(false)
}
)
}
const handleCancelRename = () => {
@@ -149,6 +124,16 @@ export function Versions({
onLoadDeployment(version)
}
const handleOpenDescriptionModal = (version: number) => {
setOpenDropdown(null)
setDescriptionModalVersion(version)
}
const descriptionModalVersionData =
descriptionModalVersion !== null
? versions.find((v) => v.version === descriptionModalVersion)
: null
if (versionsLoading && versions.length === 0) {
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
@@ -179,7 +164,14 @@ export function Versions({
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[160px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
<div
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex justify-end gap-[2px]'
)}
>
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
</div>
</div>
@@ -257,7 +249,7 @@ export function Versions({
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
)}
maxLength={100}
disabled={isRenaming}
disabled={renameMutation.isPending}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
@@ -289,14 +281,40 @@ export function Versions({
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
{formatDate(v.createdAt)}
{formatDateTime(new Date(v.createdAt))}
</span>
</div>
<div
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex items-center justify-end gap-[2px]'
)}
onClick={(e) => e.stopPropagation()}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={clsx(
'!p-1',
!v.description &&
'text-[var(--text-quaternary)] hover:text-[var(--text-tertiary)]'
)}
onClick={() => handleOpenDescriptionModal(v.version)}
>
<FileText className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[240px]'>
{v.description ? (
<p className='line-clamp-3 text-[12px]'>{v.description}</p>
) : (
<p className='text-[12px]'>Add description</p>
)}
</Tooltip.Content>
</Tooltip.Root>
<Popover
open={openDropdown === v.version}
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
@@ -311,6 +329,10 @@ export function Versions({
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
<PopoverItem onClick={() => handleOpenDescriptionModal(v.version)}>
<FileText className='h-3 w-3' />
<span>{v.description ? 'Edit description' : 'Add description'}</span>
</PopoverItem>
{!v.isActive && (
<PopoverItem onClick={() => handlePromote(v.version)}>
<RotateCcw className='h-3 w-3' />
@@ -328,6 +350,20 @@ export function Versions({
)
})}
</div>
{workflowId && descriptionModalVersionData && (
<VersionDescriptionModal
key={descriptionModalVersionData.version}
open={descriptionModalVersion !== null}
onOpenChange={(open) => !open && setDescriptionModalVersion(null)}
workflowId={workflowId}
version={descriptionModalVersionData.version}
versionName={
descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
}
currentDescription={descriptionModalVersionData.description}
/>
)}
</div>
)
}

View File

@@ -32,7 +32,6 @@ interface GeneralDeployProps {
versionsLoading: boolean
onPromoteToLive: (version: number) => Promise<void>
onLoadDeploymentComplete: () => void
fetchVersions: () => Promise<void>
}
type PreviewMode = 'active' | 'selected'
@@ -48,7 +47,6 @@ export function GeneralDeploy({
versionsLoading,
onPromoteToLive,
onLoadDeploymentComplete,
fetchVersions,
}: GeneralDeployProps) {
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
@@ -229,7 +227,6 @@ export function GeneralDeploy({
onSelectVersion={handleSelectVersion}
onPromoteToLive={handlePromoteToLive}
onLoadDeployment={handleLoadDeployment}
fetchVersions={fetchVersions}
/>
</div>
</div>

View File

@@ -135,11 +135,9 @@ export function DeployModal({
refetch: refetchDeploymentInfo,
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
const {
data: versionsData,
isLoading: versionsLoading,
refetch: refetchVersions,
} = useDeploymentVersions(workflowId, { enabled: open })
const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, {
enabled: open,
})
const {
isLoading: isLoadingChat,
@@ -450,10 +448,6 @@ export function DeployModal({
deleteTrigger?.click()
}, [])
const handleFetchVersions = useCallback(async () => {
await refetchVersions()
}, [refetchVersions])
const isSubmitting = deployMutation.isPending
const isUndeploying = undeployMutation.isPending
@@ -512,7 +506,6 @@ export function DeployModal({
versionsLoading={versionsLoading}
onPromoteToLive={handlePromoteToLive}
onLoadDeploymentComplete={handleCloseModal}
fetchVersions={handleFetchVersions}
/>
</ModalTabsContent>

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { readSSEStream } from '@/lib/core/utils/sse'
import type { GenerationType } from '@/blocks/types'
import { subscriptionKeys } from '@/hooks/queries/subscription'
@@ -184,52 +185,10 @@ export function useWand({
throw new Error('Response body is null')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let accumulatedContent = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const lineData = line.substring(6)
if (lineData === '[DONE]') {
continue
}
try {
const data = JSON.parse(lineData)
if (data.error) {
throw new Error(data.error)
}
if (data.chunk) {
accumulatedContent += data.chunk
if (onStreamChunk) {
onStreamChunk(data.chunk)
}
}
if (data.done) {
break
}
} catch (parseError) {
logger.debug('Failed to parse SSE line', { line, parseError })
}
}
}
}
} finally {
reader.releaseLock()
}
const accumulatedContent = await readSSEStream(response.body, {
onChunk: onStreamChunk,
signal: abortControllerRef.current?.signal,
})
if (accumulatedContent) {
onGeneratedContent(accumulatedContent)