2026 03 21 Vitto Studio Complete Fix

Documentation for Plans

Vitto Studio — Complete Fix & Enhancement Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Close all gaps between the platform generation pipeline and vitto-studio UI — every tab must react correctly to generation events, show real data, and provide a polished, cohesive experience.

Architecture: The root cause is a SSE event forwarding gap in VittoChatEngineService — only 3 of ~15 event types reach the studio. Fixing this single chokepoint unblocks Preview, Pipeline, Generations, and Review tabs simultaneously. Secondary fixes address data persistence, runtime bugs, and UX polish across all panels.

Tech Stack: Angular 20+ (standalone, OnPush, signals), RxJS, TypeScript, rspack WASM, SSE streams, CSS custom properties via @theme-system.

Constraints: No git commits (concurrent session). No setTimeout (use RxJS timer()). No raw any (use SafeAny). Separate .ts/.html/.scss files for new components. inject() pattern only.


Phase 1: SSE Event Pipeline Fix (Critical — Unblocks Everything)

Task 1: Forward ALL module-gen/pipeline/generation events from chat engine to studio

The single most impactful change. Currently VittoChatEngineService silently drops ~12 SSE event types that the studio needs. The fix: forward ALL module-gen.*, pipeline.*, and generation.* events through moduleGenEvents$.

Files:

At line 557-561, NON_PHASE_EVENTS gates which events get forwarded. Events NOT in this set (like module-gen.module-generated) fall through to the !phase check at line 588 and are silently dropped. The fix is to forward ALL module-gen/pipeline/generation events to moduleGenEvents$ regardless of whether they're in NON_PHASE_EVENTS.

The fix must go ABOVE the NON_PHASE_EVENTS check (before line 557), because events like module-gen.module-generated are not in NON_PHASE_EVENTS and would be silently dropped if the forwarding were inside it.

Location: Insert AFTER line 556 (after const NON_PHASE_EVENTS is NOT yet defined — actually insert BEFORE line 555, right after // S204: Skip non-phase events... comment), BEFORE the NON_PHASE_EVENTS declaration. Use the existing eventType variable already declared at line 550.

Insert this block at line 555 (before the NON_PHASE_EVENTS const):

// S252: Forward ALL generation-related SSE events to moduleGenEvents$
// for the studio generation listener. This must happen BEFORE the
// NON_PHASE_EVENTS filter, because events like module-gen.module-generated
// are not in that set and would be silently dropped.
// Uses the existing `eventType` variable from line 550.
const STUDIO_EVENT_PREFIXES = ['module-gen.', 'pipeline.', 'generation.', 'phase.'];
if (STUDIO_EVENT_PREFIXES.some(prefix => eventType.startsWith(prefix))) {
  this.moduleGenEvents$.next({
    type: eventType,
    data: sseEvent.data as Record<string, unknown> ?? {},
  });
}

Then remove the three individual forwarding blocks inside the if (NON_PHASE_EVENTS.has(eventType)) block (lines 573-586) — the three if (eventType === 'module-gen.completed'), if (eventType === 'module-gen.failed'), and if (eventType === 'module-gen.started') blocks. These are now redundant since ALL module-gen.* events are forwarded above.

Keep everything else intact: The NON_PHASE_EVENTS set itself (controls progress card suppression), the deploy timing logic, and the else branch for phase progress cards (line 590+) must remain untouched. The forwarding and the progress card logic serve independent consumers (studio listener vs chat UI).

The StudioGenerationListenerService.handleEvent() already has a switch statement that handles each event type exactly once. The dock shell subscribes to moduleGenEvents$ and calls handleEvent(). Verify that forwarding module-gen.phase-start/progress/complete won't create duplicate progress cards in the chat — it won't, because the chat engine creates its own progress cards in the else branch (line 590+), and the studio listener's handleEvent() only updates studio-internal state (signals, VFS writes). These are independent consumers.

  1. Open Studio for a universe
  2. Ask Vitto to generate a platform via chat
  3. Verify: Pipeline tab shows real-time phase nodes
  4. Verify: Files appear in VFS during generation (not just after)
  5. Verify: Review panel auto-opens with pending files
  6. Verify: Live Preview auto-compiles when files arrive
  7. Verify: Generations tab shows the new generation when complete

Task 2: Add spaceId/universeId fallback in StudioGenerationListenerService

Even with Task 1 fixed, handleCompleted() gates loadGenerations() on activeSpaceId which is captured from the module-gen.started event data. If the backend nests the IDs differently, the Generations tab won't refresh. Add a fallback that sources IDs from the dock shell's route params.

Files:

Add a method to set fallback IDs that will be used when SSE event data doesn't include them:

// S252: Fallback IDs from route params, used when SSE event data
// doesn't include spaceId/universeId.
private fallbackSpaceId: string | null = null;
private fallbackUniverseId: string | null = null;

setFallbackIds(spaceId: string, universeId: string): void {
  this.fallbackSpaceId = spaceId;
  this.fallbackUniverseId = universeId;
}

In handleCompleted() at line 312-313, after capturing activeSpaceId/activeUniverseId, add fallback:

const spaceId = this.activeSpaceId || this.fallbackSpaceId;
const universeId = this.activeUniverseId || this.fallbackUniverseId;

In StudioDockShellComponent.initializeFromSpace(), after loadProject succeeds (line 329), call:

if (project.spaceId && project.universeId) {
  this.generationListener.setFallbackIds(project.spaceId, project.universeId);
}

In handleEvent() case module-gen.started (line 128-129), add after the existing ID capture:

// S252: Also update fallbacks so they're always fresh
if (this.activeSpaceId) this.fallbackSpaceId = this.activeSpaceId;
if (this.activeUniverseId) this.fallbackUniverseId = this.activeUniverseId;

Phase 2: Runtime Bugs & Data Gaps

Task 3: Fix deploy panel private signal access

StudioDeployPanelComponent.deploy() accesses this.gitState['_spaceId']() — private signals by string key. This will throw at runtime in production builds.

Files:

At line 89 (after existing public getters), add:

readonly spaceId = this._spaceId.asReadonly();
readonly universeId = this._universeId.asReadonly();

At line 190, replace:

const mergeUrl = `${this.apiBaseUrl}/studio/universes/${this.gitState['_spaceId']()}/${this.gitState['_universeId']()}/git/merge`;

with:

const mergeUrl = `${this.apiBaseUrl}/studio/universes/${this.gitState.spaceId()}/${this.gitState.universeId()}/git/merge`;

Search for gitState\['_ across the codebase. Already verified — only one occurrence at deploy-panel.component.ts:190.


Task 4: Wire module tree click to CanvasSelectionService

The module tree's onModuleClick() is a console.log no-op. Wire it to select the module in the visual canvas.

Files:

Add to the component's injections:

private readonly canvasSelection = inject(CanvasSelectionService);

Replace onModuleClick():

onModuleClick(mod: UniverseStructureNode): void {
  this.canvasSelection.select(mod);
}

Add the import at the top of the file:

import { CanvasSelectionService } from '../services/canvas-selection.service';

Task 5: Hydrate entities from universe structure on Studio load

CrudGeneratorService.entities is session-local (signal<StudioEntity[]>([])) — entities vanish on page refresh. The Entities tab is always empty for returning users.

Data model reality: UniverseStructureNode.config.entities is string[] — just entity names (e.g., ["Player", "Team"]), NOT full StudioEntity objects. StudioEntity requires id, collection, displayName, fields[], crudOperations, crudEndpoints, pageBindings, createdAt, updatedAt — none of which exist on a string name.

Approach: Create skeleton StudioEntity objects from entity names, with sensible defaults. Users can then edit fields via the Entities tab. This is better than showing nothing.

Files:

Confirm that UniverseStructureNode.config.entities is indeed string[]. Check the interface definition:

grep -n "entities" libs/libs-structural/vitto-studio/src/lib/types/universe-structure.types.ts

Also check StudioEntity at libs/libs-structural/vitto-studio/src/lib/types/studio-entity.types.ts:27-39.

/**
 * S252: Hydrate skeleton entities from universe structure on Studio load.
 * config.entities is string[] (names only), so we create skeleton StudioEntity
 * objects with sensible defaults. Users can edit fields via the Entities tab.
 */
hydrateFromStructure(modules: UniverseStructureNode[]): void {
  // Don't overwrite entities that were already defined in this session
  if (this.entities().length > 0) return;

  const entities: StudioEntity[] = [];
  const now = new Date().toISOString();

  for (const mod of modules) {
    const entityNames = mod.config?.entities ?? [];
    for (const name of entityNames) {
      if (typeof name !== 'string' || !name) continue;
      // Avoid duplicates
      if (entities.some(e => e.displayName === name)) continue;

      const collection = name.toLowerCase().replace(/\s+/g, '_');
      entities.push({
        id: crypto.randomUUID(),
        collection,
        displayName: name,
        description: `${name} entity from ${mod.config.name} module`,
        fields: [
          // Provide a default 'name' field so the entity isn't completely empty
          { name: 'name', type: 'string', required: true, label: 'Name' },
        ],
        crudOperations: ['create', 'read', 'update', 'delete', 'get'],
        crudEndpoints: [
          { method: 'GET', path: `/${collection}`, operation: 'read' },
          { method: 'GET', path: `/${collection}/:id`, operation: 'get' },
          { method: 'POST', path: `/${collection}`, operation: 'create' },
          { method: 'PUT', path: `/${collection}/:id`, operation: 'update' },
          { method: 'DELETE', path: `/${collection}/:id`, operation: 'delete' },
        ],
        pageBindings: [],
        createdAt: now,
        updatedAt: now,
      });
    }
  }

  if (entities.length > 0) {
    this.entities.set(entities);
  }
}

Note: Verify that CrudEndpoint interface accepts { method, path, operation }. Check the types file. Adjust the shape if needed.

In StudioDockShellComponent, add injection:

private readonly crudGenerator = inject(CrudGeneratorService);

Then in the constructor, add an effect that reacts to module structure loading:

// S252: Hydrate skeleton entities from universe structure
// so the Entities tab shows entity names even on page refresh.
effect(() => {
  const modules = this.structureState.modules();
  if (modules.length > 0) {
    this.crudGenerator.hydrateFromStructure(modules);
  }
});

Ensure UniverseStructureNode is imported. Check the actual type name — it may be exported from the types directory or from UniverseStructureStateService.


Phase 3: Preview Reliability

Task 6: Improve preview retry and error resilience

The preview has several fragility points: 3 retries with 2s delay may not be enough when VFS loads from GitLab, the preview-panel-opened event can be missed, and SharedArrayBuffer issues are silent.

Files:

In PreviewService, replace the retry constants:

private readonly MAX_EMPTY_VFS_RETRIES = 5;    // was 3
private readonly INITIAL_RETRY_DELAY_MS = 1000; // was 2000 flat

And use exponential backoff in the retry logic:

const delay = this.INITIAL_RETRY_DELAY_MS * Math.pow(1.5, this.emptyVfsRetryCount);

In BundlerService.ensureWorker(), add an upfront check before creating the Worker:

if (typeof SharedArrayBuffer === 'undefined') {
  this.state.set('error');
  this.errors.set([
    'SharedArrayBuffer is not available. Ensure the page is served with ' +
    'Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy headers.',
  ]);
  return;
}

This turns a silent failure into a visible error in the preview frame.


Phase 4: UI/UX Polish & Consistency

Task 7: Enhance Pipeline Visualizer with generation activity feed

When generation runs via chat, the pipeline visualizer should show a live activity feed even when formal pipeline.status nodes aren't available. The StudioGenerationListenerService already emits generationActivity — wire it to the pipeline panel.

Files:

import { DatePipe } from '@angular/common';
import { StudioGenerationListenerService } from '../../services/studio-generation-listener.service';

// In @Component imports array, add: DatePipe
// In the class body:
private readonly genListener = inject(StudioGenerationListenerService);
readonly activity = this.genListener.generationActivity;
readonly isGenerating = this.genListener.isGenerating;
readonly currentPhase = this.genListener.currentPhase;
readonly phaseProgress = this.genListener.phaseProgress;

Important: DatePipe must be added to the component's imports array since it's standalone. The entry.timestamp is a number (Unix ms from Date.now()), which Angular's DatePipe handles correctly.

When pipelineState.nodes().length === 0 but isGenerating() is true, show a live activity feed instead of "No Pipeline Active":

@if (pipelineState.nodes().length === 0) {
  @if (isGenerating()) {
    <div class="activity-feed">
      <div class="activity-header">
        <div class="gen-pulse"></div>
        <span>{{ currentPhase() || 'Generating...' }}</span>
        @if (phaseProgress()) {
          <span class="phase-progress">{{ phaseProgress() }}</span>
        }
      </div>
      <div class="activity-log">
        @for (entry of activity(); track entry.timestamp) {
          <div class="activity-entry" [class.phase-entry]="entry.phase">
            <span class="activity-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
            <span class="activity-text">{{ entry.message }}</span>
          </div>
        }
      </div>
    </div>
  } @else {
    <div class="empty-state">
      <span class="material-icons empty-icon">route</span>
      <p>No Pipeline Active</p>
      <p class="empty-hint">Start a generation to see the pipeline</p>
    </div>
  }
}
.activity-feed {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 12px;
}

.activity-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 0;
  font-size: 13px;
  font-weight: 500;
  color: var(--text-color, #e0e0e0);
  border-bottom: 1px solid var(--surface-border, #1e1e2e);
  margin-bottom: 8px;
}

.phase-progress {
  font-size: 11px;
  color: var(--text-secondary, #888);
  margin-left: auto;
}

.activity-log {
  flex: 1;
  overflow-y: auto;
}

.activity-entry {
  display: flex;
  gap: 8px;
  padding: 3px 0;
  font-size: 11px;
  color: var(--text-secondary, #888);

  &.phase-entry {
    color: var(--text-color, #e0e0e0);
    font-weight: 500;
  }
}

.activity-time {
  font-family: monospace;
  color: var(--text-tertiary, #666);
  min-width: 60px;
}

.gen-pulse {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--primary-color, #6366f1);
  animation: pulse 1.5s ease infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

Task 8: Enhance empty states across all panels

Current empty states are functional but sparse. Add consistent, informative empty states with clear CTAs.

Files:

Replace lines 168-174 in generations-panel.component.html:

<div class="empty-state">
  <span class="material-icons empty-icon">history</span>
  <p class="empty-title">No generations yet</p>
  <p class="empty-hint">Ask Vitto to generate a platform, or use the Vibe canvas to start building</p>
</div>

Add styles in the .scss:

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 32px 16px;
  text-align: center;
  height: 100%;
  min-height: 120px;
}

.empty-icon {
  font-size: 36px;
  color: var(--text-tertiary, #555);
  margin-bottom: 12px;
  opacity: 0.6;
}

.empty-title {
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary, #888);
  margin: 0 0 4px;
}

.empty-hint {
  font-size: 11px;
  color: var(--text-tertiary, #666);
  margin: 0;
  max-width: 240px;
  line-height: 1.4;
}

In file-review-panel.component.ts, update the empty state section of the inline template:

<div class="empty-state">
  <span class="material-icons empty-icon">fact_check</span>
  <p class="empty-title">No pending files to review</p>
  <p class="empty-hint">Generated files will appear here for review before being added to your project</p>
</div>

In preview-frame.component.html, update the empty state (lines 32-38):

<div class="empty-state">
  <span class="material-icons empty-icon">monitor</span>
  <p class="empty-title">Ready when you are</p>
  <p class="empty-hint">Ask Vitto to generate a platform, or open a space with existing modules. Your preview will compile automatically.</p>
</div>

Task 9: Add loading/generating states to the toolbar

The studio toolbar should show a generation progress indicator when generation is active, so users always know something is happening regardless of which tab they're viewing.

Files:

Read the toolbar component to understand its current structure and inputs. If the toolbar already has isGenerating, generationPhase, or generationProgress inputs, skip the redundant additions and proceed to wiring only.

readonly isGenerating = input(false);
readonly generationPhase = input<string | null>(null);
readonly generationProgress = input('');
@if (isGenerating()) {
  <div class="generation-indicator">
    <div class="gen-pulse"></div>
    <span class="gen-phase">{{ generationPhase() || 'Generating...' }}</span>
    @if (generationProgress()) {
      <span class="gen-detail">{{ generationProgress() }}</span>
    }
  </div>
}

In the dock shell template, pass the generation state to the toolbar:

<lib-studio-toolbar
  [isGenerating]="isGeneratingModule()"
  [generationPhase]="generationPhase()"
  [generationProgress]="generationProgress()"
/>

Phase 5: Code Quality

Task 10: Replace setTimeout with RxJS timer()

Three violations of the no-setTimeout rule.

Files:

Replace the setTimeout in the onerror handler:

// Before:
this.eventSource.onerror = () => {
  setTimeout(() => {
    if (!this.isRunning()) return;
    // reconnection logic
  }, 1000);
};

// After:
this.eventSource.onerror = () => {
  timer(1000).pipe(take(1)).subscribe(() => {
    if (!this.isRunning()) return;
    // reconnection logic
  });
};

Replace the setTimeout:

// Before:
setTimeout(() => {
  if (this.saveStatus() === 'saved') this.saveStatus.set('idle');
}, 2000);

// After:
timer(2000).pipe(take(1)).subscribe(() => {
  if (this.saveStatus() === 'saved') this.saveStatus.set('idle');
});

Ensure timer and take are imported from rxjs.

At line 537, this.contentFlushTimer = setTimeout(() => this.flushContentDeltas(), 500) stores the timer ID for cancellation. Replace with a Subscription:

// Add a private field:
private contentFlushSub?: Subscription;

// Replace setTimeout:
this.contentFlushSub?.unsubscribe();
this.contentFlushSub = timer(500).pipe(take(1)).subscribe(() => this.flushContentDeltas());

// In the cleanup/cancel method, replace clearTimeout with:
this.contentFlushSub?.unsubscribe();

Also add Subscription to the rxjs import.


Task 11: Replace raw any with SafeAny in WorkflowStateService

18 occurrences of raw any in the service.

Files:

import type { SafeAny } from '@libs-foundational/core-models';

Verified import path — used by @libs-ui-core/ui-core breadcrumb service and others.

Use find-and-replace carefully. Only replace type annotations (: any, as any, <any>), not string content.


Task 12: Remove console.log statements from ModularityModule

15+ console.log statements used for lifecycle logging.

Files:

These are lifecycle debug logs that should not be in production code. If logging is genuinely needed, inject LoggerService — but for lifecycle events, Angular's built-in lifecycle hooks are self-documenting and don't need logging.


Phase 6: Verify & Polish

Task 13: End-to-end verification

After all changes, verify the complete flow works.

npx nx build vitto-studio --skip-nx-cache
npx nx build vitto-chat --skip-nx-cache
npx nx build platform-preview --skip-nx-cache

All three must build with 0 errors.

  1. Start dev server: yarn serve:space
  2. Navigate to a universe's Studio
  3. Ask Vitto to generate a platform
  4. Watch: Pipeline tab shows live activity feed with phases
  5. Watch: Files appear in VFS progressively (check Files tab)
  6. Watch: Review panel auto-opens with generated files
  7. Watch: Generations tab shows new generation on completion
  8. Watch: Live Preview auto-compiles and shows the generated app
  9. Check: Entities tab shows entities from the generated modules
  1. Click a module in the Module Tree → visual canvas selects it
  2. Switch to Code layout → Branches, History, Diff tabs are visible and functional
  3. Click Deploy → no runtime error (merge URL uses public signals)
  4. Open Settings → env vars load correctly

Dependency Graph

Task 1 (SSE forwarding) ← EVERYTHING depends on this
  ↓
Task 2 (fallback IDs) — makes Task 1 more resilient
  ↓
Tasks 3-5 (runtime bugs) — independent of each other, can be parallel
  ↓
Task 6 (preview reliability) — builds on Task 1 fix
  ↓
Tasks 7-9 (UI/UX polish) — independent of each other, can be parallel
  ↓
Tasks 10-12 (code quality) — independent, can be parallel
  ↓
Task 13 (verification) — must be last

Parallelizable groups:

Sequential dependencies:


Risk Assessment

Risk Likelihood Impact Mitigation
SSE event forwarding creates duplicate handling Low Medium Studio listener's switch statement handles each type once; chat progress cards are independent
Entity hydration creates sparse objects Low Low Skeleton entities have defaults; users can edit fields. Better than showing nothing
SharedArrayBuffer check causes false negative Low Low Only blocks if truly unavailable — correct behavior
Preview retry increase causes unnecessary delays Low Low Exponential backoff starts at 1s, shorter than current flat 2s
Removing console.log breaks debugging Low Negligible These are lifecycle logs, not error diagnostics