Test Workflow Grid Rebuild - ADR
Status: Proposed Date: 2025-12-07 Author: System Architecture Supersedes: Current GenerationGrid and deprecated Outputs components Related: apps/emprops-studio/components/studio/v2/GenerationGrid
Architecture Decision
Rebuild the Test Workflow panel grid from scratch with a single source of truth architecture, eliminating the current dual-data-source pattern that causes duplication, flickering, and inconsistent UI transitions.
Key Design Principles
- Single Array of Items - No merging two data sources in the component
- Deduplication at Source - Items deduplicated in the hook before reaching the component
- Stable IDs - Each item has a stable ID that doesn't change when status changes
- CSS Transitions Only - No Framer Motion complexity, simple opacity/transform transitions
- Fixed Grid Positions - Slots are containers that never move; only content inside transitions
Context
Current Architecture Problems
The Test Workflow panel in EmProps Studio (apps/emprops-studio/pages/studio/apps/v2/[id].tsx) displays a grid of generation outputs. The current implementation has severe architectural issues causing:
- Duplicate images appearing in the grid
- Placeholders appearing mid-grid instead of at top
- Images disappearing when they shouldn't
- Flickering and janky transitions
- Inconsistent state between active generation and historical assets
Two Competing Implementations
1. GenerationGrid component (Current - apps/emprops-studio/components/studio/v2/GenerationGrid/index.tsx)
- Uses Framer Motion with nested AnimatePresence
- Receives two separate data sources that are merged in the component
- No deduplication between completed slots and existing assets
2. Outputs component (Deprecated - apps/emprops-studio/components/studio/v2/Outputs/index.tsx)
- Marked as deprecated with console.error on render
- Uses WebSocket monkey-patching (anti-pattern)
- Complex placeholder-to-asset mapping with timestamp comparisons
- Multiple
useStatecalls that get out of sync
Root Cause Analysis
Issue #1: Two Separate Data Sources Merged in Component
// Current GenerationGrid receives:
<GenerationGrid
generationSlots={generationSlots} // From reducer - active generation
existingAssets={existingAssets} // From reducer - loaded from API
...
/>
// Then merges them in useMemo:
const gridItems = useMemo(() => {
const items: GridItem[] = [];
generationSlots.forEach(slot => items.push({ type: "generation", slot }));
existingAssets.forEach(asset => items.push({ type: "existing", asset }));
return items; // NO DEDUPLICATION!
}, [generationSlots, existingAssets]);Result: When generation completes, the same image appears twice - once from the completed slot, once from the refreshed assets list.
Issue #2: Slots Never Get Cleaned Up
// Reducer actions:
GENERATION_START → Creates N slots with status: "pending"
GENERATION_SLOT_COMPLETE → Marks slot as status: "completed" (slot stays!)
GENERATION_RESET → Clears slots (only called manually)
// Timeline:
1. User runs 4 generations → 4 slots created
2. All complete → 4 slots remain with status: "completed"
3. Assets refresh → 4 assets loaded into existingAssets
4. Grid shows 8 items (4 slots + 4 assets) = DUPLICATESIssue #3: Nested AnimatePresence Mode Conflict
// Grid level - popLayout mode (removes from flow immediately)
<AnimatePresence mode="popLayout">
{gridItems.map((item) => (
<motion.div layout key={item.id}>
<GenerationSlotComponent ... />
</motion.div>
))}
</AnimatePresence>
// Slot level - wait mode (waits for exit before enter)
<AnimatePresence mode="wait">
{slot.status === "pending" && <PendingSlot key="pending" />}
{slot.status === "generating" && <GeneratingSlot key="generating" />}
...
</AnimatePresence>Result: The outer layout animation conflicts with inner wait mode timing, causing elements to flash or appear in wrong positions.
Issue #4: Missing LayoutGroup
From Framer Motion docs: "When mixing exit and layout animations, it might be necessary to wrap the group in LayoutGroup."
The current implementation has no LayoutGroup wrapper, causing uncoordinated animations.
Issue #5: Animation Duration Mismatches
// Grid item enters in 0.3s
gridAnimations.item.transition.duration = 0.3
// But completed slot content enters in 0.6s total
slotTransitions.completed.transition.duration = 0.5
slotTransitions.completed.transition.delay = 0.1This creates visible gaps where the grid item is visible but empty.
Issue #6: WebSocket Debounce vs Animation Timing
// 50ms debounce for progress updates
progressUpdateTimeouts.current[generationId] = setTimeout(() => {
dispatch({ type: 'GENERATION_SLOT_PROGRESS', payload });
}, 50);
// But animations are 300-600ms
// Multiple debounced updates cause re-renders mid-animationDecision
Replace the entire Test Workflow grid with a clean, single-source-of-truth implementation.
New Architecture
┌─────────────────────────────────────────────────────────────┐
│ TestWorkflowPanel │
├─────────────────────────────────────────────────────────────┤
│ useTestWorkflowGrid(collectionId) │
│ - Single unified data source │
│ - Manages both active generations AND historical assets │
│ - Handles WebSocket events │
│ - Returns: { items: GridItem[], isLoading, hasMore, ... } │
├─────────────────────────────────────────────────────────────┤
│ WorkflowOutputGrid │
│ - Pure presentational component │
│ - Simple CSS grid with CSS transitions (no Framer Motion) │
│ - Each item is a WorkflowOutputCard │
├─────────────────────────────────────────────────────────────┤
│ WorkflowOutputCard │
│ - Shows placeholder, progress, or completed image │
│ - State machine: pending → generating → completed │
│ - CSS opacity transitions between states │
└─────────────────────────────────────────────────────────────┘How It Will Work
When user clicks "Run Test":
- Immediately create N placeholder slots at the TOP of the grid
- Each slot has a fixed position - it never moves
- Slot transitions through states:
pending → generating → completed - Only the content inside the slot changes (opacity transition)
- Image fades IN on top of progress indicator
- Existing assets below never shift (slots above are always same size)
Before Run:
┌─────────┬─────────┐
│ Asset 1 │ Asset 2 │ ← existing assets
├─────────┼─────────┤
│ Asset 3 │ Asset 4 │
└─────────┴─────────┘
After Run (4 generations):
┌─────────┬─────────┐
│ Slot 0 │ Slot 1 │ ← NEW slots (pending/generating)
├─────────┼─────────┤
│ Slot 2 │ Slot 3 │ ← NEW slots (pending/generating)
├─────────┼─────────┤
│ Asset 1 │ Asset 2 │ ← pushed down, but STABLE
├─────────┼─────────┤
│ Asset 3 │ Asset 4 │
└─────────┴─────────┘
As generations complete (Slot 0 done):
┌─────────┬─────────┐
│ [IMAGE] │ ░░░░░░░ │ ← Image fades in INSIDE the slot
├─────────┼─────────┤
│ ░░░░░░░ │ ░░░░░░░ │
├─────────┼─────────┤
│ Asset 1 │ Asset 2 │ ← Never moved
└─────────┴─────────┘Proposed Implementation
1. New Hook: useTestWorkflowGrid
// apps/emprops-studio/hooks/use-test-workflow-grid.ts
interface GridItem {
id: string; // Stable ID (never changes)
type: 'slot' | 'asset'; // Source type
status: 'pending' | 'generating' | 'completed' | 'error';
progress: number; // 0-100
currentNode?: string; // Node name being processed
imageUrl?: string; // Final image URL when completed
asset?: FlatFile; // Full asset data for existing assets
output?: GenerationV2Response; // Full output data
}
interface GridState {
activeSlots: GridItem[]; // Current generation session
historicalAssets: GridItem[]; // Previously generated (from API)
isGenerating: boolean;
sessionId: string | null;
}
type GridAction =
| { type: 'START_GENERATION'; payload: { sessionId: string; count: number } }
| { type: 'PROGRESS'; payload: { gid: number; progress: number; node?: string } }
| { type: 'COMPLETE'; payload: { gid: number; imageUrl: string; output: any } }
| { type: 'ERROR'; payload: { gid: number; error: string } }
| { type: 'SESSION_END' } // Move completed slots to historical
| { type: 'LOAD_ASSETS'; payload: { assets: FlatFile[]; append: boolean } };
function gridReducer(state: GridState, action: GridAction): GridState {
switch (action.type) {
case 'START_GENERATION':
// Create N slots, clear previous active slots
const slots = Array.from({ length: action.payload.count }, (_, i) => ({
id: `${action.payload.sessionId}-${i}`,
type: 'slot' as const,
status: 'pending' as const,
progress: 0,
}));
return {
...state,
activeSlots: slots,
isGenerating: true,
sessionId: action.payload.sessionId,
};
case 'PROGRESS':
return {
...state,
activeSlots: state.activeSlots.map((slot, i) =>
i === action.payload.gid
? { ...slot, status: 'generating', progress: action.payload.progress, currentNode: action.payload.node }
: slot
),
};
case 'COMPLETE':
return {
...state,
activeSlots: state.activeSlots.map((slot, i) =>
i === action.payload.gid
? { ...slot, status: 'completed', progress: 100, imageUrl: action.payload.imageUrl, output: action.payload.output }
: slot
),
};
case 'SESSION_END':
// Move completed slots to historical, deduplicate
const completedSlots = state.activeSlots.filter(s => s.status === 'completed');
const completedIds = new Set(completedSlots.map(s => s.output?.id));
const deduplicatedHistorical = state.historicalAssets.filter(
a => !completedIds.has(a.output?.id)
);
return {
...state,
activeSlots: [],
historicalAssets: [...completedSlots, ...deduplicatedHistorical],
isGenerating: false,
sessionId: null,
};
case 'LOAD_ASSETS':
const newAssets = action.payload.assets.map(asset => ({
id: `asset-${asset.id}`,
type: 'asset' as const,
status: 'completed' as const,
progress: 100,
imageUrl: extractImageUrl(asset),
asset,
output: asset.gen_out_data as GenerationV2Response,
}));
// Deduplicate against active slots
const activeOutputIds = new Set(state.activeSlots.map(s => s.output?.id));
const filteredAssets = newAssets.filter(a => !activeOutputIds.has(a.output?.id));
return {
...state,
historicalAssets: action.payload.append
? [...state.historicalAssets, ...filteredAssets]
: filteredAssets,
};
default:
return state;
}
}
export function useTestWorkflowGrid(collectionId: string) {
const [state, dispatch] = useReducer(gridReducer, initialState);
const { data: socket } = useSocket();
// WebSocket listeners
useEffect(() => {
if (!socket) return;
const onProgress = (payload: { context: any; value: number }) => {
if (payload.context.uid !== state.sessionId) return;
dispatch({
type: 'PROGRESS',
payload: {
gid: payload.context.gid,
progress: payload.value,
node: payload.context.currentNodeName,
},
});
};
const onComplete = (output: GenerationV2Response) => {
if (!output.generation?.id) return;
dispatch({
type: 'COMPLETE',
payload: {
gid: output.generation.id,
imageUrl: extractImageUrl(output),
output,
},
});
};
const onSessionEnd = () => {
dispatch({ type: 'SESSION_END' });
};
socket.on('node_progress', onProgress);
socket.on('image_generated', onComplete);
socket.on('generation_completed', onSessionEnd);
return () => {
socket.off('node_progress', onProgress);
socket.off('image_generated', onComplete);
socket.off('generation_completed', onSessionEnd);
};
}, [socket, state.sessionId]);
// Single unified array - active slots first, then historical
const items = useMemo(() => {
return [...state.activeSlots, ...state.historicalAssets];
}, [state.activeSlots, state.historicalAssets]);
return {
items,
isGenerating: state.isGenerating,
startGeneration: (count: number) => {
const sessionId = uuid();
dispatch({ type: 'START_GENERATION', payload: { sessionId, count } });
return sessionId;
},
loadAssets: (assets: FlatFile[], append = false) => {
dispatch({ type: 'LOAD_ASSETS', payload: { assets, append } });
},
};
}2. New Component: WorkflowOutputGrid
// apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/index.tsx
interface WorkflowOutputGridProps {
items: GridItem[];
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onRecoverGeneration?: (asset: FlatFile) => void;
}
export function WorkflowOutputGrid({
items,
isLoading,
hasMore,
onLoadMore,
onRecoverGeneration,
}: WorkflowOutputGridProps) {
if (items.length === 0 && !isLoading) {
return (
<div className="p-8 text-center text-sm text-muted-foreground">
No outputs yet. Run the workflow to generate outputs!
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-2">
{items.map((item) => (
<WorkflowOutputCard
key={item.id}
item={item}
onRecoverGeneration={onRecoverGeneration}
/>
))}
</div>
{hasMore && (
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading}
className="self-center"
>
{isLoading ? 'Loading...' : 'Load more'}
</Button>
)}
</div>
);
}3. New Component: WorkflowOutputCard
// apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/WorkflowOutputCard.tsx
interface WorkflowOutputCardProps {
item: GridItem;
onRecoverGeneration?: (asset: FlatFile) => void;
}
export function WorkflowOutputCard({ item, onRecoverGeneration }: WorkflowOutputCardProps) {
return (
<div className="relative aspect-square overflow-hidden rounded-md bg-slate-100">
{/* All states rendered, visibility controlled by CSS */}
{/* Pending state */}
<div
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-300",
item.status === 'pending' ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 rounded-full border-2 border-dashed border-slate-300 animate-pulse" />
<span className="text-xs text-slate-400">Waiting</span>
</div>
</div>
{/* Generating state */}
<div
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-300",
item.status === 'generating' ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
<div className="flex flex-col items-center gap-2">
<ProgressRing percentage={item.progress} />
{item.currentNode && (
<span className="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded-full truncate max-w-[80%]">
{item.currentNode}
</span>
)}
</div>
</div>
{/* Completed state */}
<div
className={cn(
"absolute inset-0 transition-opacity duration-300",
item.status === 'completed' ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
{item.imageUrl && (
<img
src={item.imageUrl}
alt="Generated output"
className="h-full w-full object-cover"
/>
)}
</div>
{/* Error state */}
<div
className={cn(
"absolute inset-0 flex items-center justify-center bg-red-50 transition-opacity duration-300",
item.status === 'error' ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
<div className="flex flex-col items-center gap-2 text-red-600">
<XCircle className="h-8 w-8" />
<span className="text-xs">Failed</span>
</div>
</div>
</div>
);
}Implementation Plan
Phase 1: Create New Components (1 day)
- Create
apps/emprops-studio/hooks/use-test-workflow-grid.ts - Create
apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/index.tsx - Create
apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/WorkflowOutputCard.tsx - Create
apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/ProgressRing.tsx
Phase 2: Integration (0.5 day)
- Update
apps/emprops-studio/pages/studio/apps/v2/[id].tsxto use new components - Wire up WebSocket events to new hook
- Connect to existing generation trigger
Phase 3: Testing & Cleanup (0.5 day)
- Test generation flow end-to-end
- Verify no duplicates
- Verify smooth transitions
- Remove old
GenerationGridcomponent - Remove deprecated
Outputscomponent
Consequences
Benefits
- No Duplicates - Single source of truth with deduplication at source
- Stable Grid Positions - Slots are fixed containers, content transitions inside
- Predictable Behavior - Simple state machine: pending → generating → completed
- No Animation Conflicts - CSS transitions only, no Framer Motion complexity
- Easier Debugging - Single reducer handles all state, clear action types
- Better Performance - No Framer Motion layout calculations, simpler DOM
Drawbacks
- Less "fancy" animations - CSS transitions are simpler than Framer Motion
- Migration effort - Need to replace existing components
- Testing required - New implementation needs thorough testing
Mitigations
- CSS transitions can still look smooth with proper easing
- Old components remain until new ones are proven stable
- Unit tests for reducer, integration tests for full flow
Files to Create
apps/emprops-studio/
├── hooks/
│ └── use-test-workflow-grid.ts # NEW
├── components/studio/v2/
│ └── WorkflowOutputGrid/
│ ├── index.tsx # NEW
│ ├── WorkflowOutputCard.tsx # NEW
│ └── ProgressRing.tsx # NEWFiles to Modify
apps/emprops-studio/
├── pages/studio/apps/v2/[id].tsx # Use new componentsFiles to Remove (after migration)
apps/emprops-studio/
├── components/studio/v2/
│ ├── GenerationGrid/ # REMOVE
│ │ ├── index.tsx
│ │ └── animations.ts
│ └── Outputs/ # REMOVE (already deprecated)
│ └── index.tsxSuccess Metrics
Quantitative
- Zero duplicates in grid after generation completes
- Zero position shifts during slot status transitions
- < 300ms transition time from generating → completed
Qualitative
- Predictable behavior - what you see matches data state
- Easy debugging - single reducer with clear actions
- Maintainable code - simple components with single responsibility
End of ADR
