Skip to content

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

  1. Single Array of Items - No merging two data sources in the component
  2. Deduplication at Source - Items deduplicated in the hook before reaching the component
  3. Stable IDs - Each item has a stable ID that doesn't change when status changes
  4. CSS Transitions Only - No Framer Motion complexity, simple opacity/transform transitions
  5. 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 useState calls that get out of sync

Root Cause Analysis

Issue #1: Two Separate Data Sources Merged in Component

tsx
// 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

tsx
// 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) = DUPLICATES

Issue #3: Nested AnimatePresence Mode Conflict

tsx
// 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

typescript
// 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.1

This creates visible gaps where the grid item is visible but empty.

Issue #6: WebSocket Debounce vs Animation Timing

typescript
// 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-animation

Decision

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":

  1. Immediately create N placeholder slots at the TOP of the grid
  2. Each slot has a fixed position - it never moves
  3. Slot transitions through states: pending → generating → completed
  4. Only the content inside the slot changes (opacity transition)
  5. Image fades IN on top of progress indicator
  6. 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

typescript
// 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

tsx
// 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

tsx
// 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)

  1. Create apps/emprops-studio/hooks/use-test-workflow-grid.ts
  2. Create apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/index.tsx
  3. Create apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/WorkflowOutputCard.tsx
  4. Create apps/emprops-studio/components/studio/v2/WorkflowOutputGrid/ProgressRing.tsx

Phase 2: Integration (0.5 day)

  1. Update apps/emprops-studio/pages/studio/apps/v2/[id].tsx to use new components
  2. Wire up WebSocket events to new hook
  3. Connect to existing generation trigger

Phase 3: Testing & Cleanup (0.5 day)

  1. Test generation flow end-to-end
  2. Verify no duplicates
  3. Verify smooth transitions
  4. Remove old GenerationGrid component
  5. Remove deprecated Outputs component

Consequences

Benefits

  1. No Duplicates - Single source of truth with deduplication at source
  2. Stable Grid Positions - Slots are fixed containers, content transitions inside
  3. Predictable Behavior - Simple state machine: pending → generating → completed
  4. No Animation Conflicts - CSS transitions only, no Framer Motion complexity
  5. Easier Debugging - Single reducer handles all state, clear action types
  6. Better Performance - No Framer Motion layout calculations, simpler DOM

Drawbacks

  1. Less "fancy" animations - CSS transitions are simpler than Framer Motion
  2. Migration effort - Need to replace existing components
  3. 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                 # NEW

Files to Modify

apps/emprops-studio/
├── pages/studio/apps/v2/[id].tsx           # Use new components

Files to Remove (after migration)

apps/emprops-studio/
├── components/studio/v2/
│   ├── GenerationGrid/                      # REMOVE
│   │   ├── index.tsx
│   │   └── animations.ts
│   └── Outputs/                             # REMOVE (already deprecated)
│       └── index.tsx

Success 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

Released under the MIT License.