2026 03 21 Whistle Group Chat Rewiring

Documentation for Plans

Whistle & Group Chat Rewiring — Implementation 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: Complete the wiring between existing whistle/group frontend components and backend services — 6 specific gaps identified by code audit.

Architecture: All components exist. WhistleButton, WhistleNotification, WhistleService, ParticipantStrip, ChatEngine, ChatShell — all built in S218. The issue is broken event flows and missing data fields. This plan reconnects them.

Tech Stack: Angular 20+, standalone components, signals, Socket.IO, existing NestJS endpoints.

Spec: docs/superpowers/specs/2026-03-14-whistle-group-chat-design.md (S218 original) Audit source: S250 code audit (this session)

Git convention: No commits — concurrent session constraint.


Gap Map (from audit)

GAP 1: WhistleButton → ChatShell (event not handled)
GAP 2: Group session creation flow (missing entirely)
GAP 3: VittoChatSession + VittoSessionSummary missing group fields
GAP 4: UnifiedMessageComponent not group-aware (no sender names)
GAP 5: Socket.IO missing participant-joined listener
GAP 6: SessionSidebar not group-aware

Task 1: Wire WhistleButton → ChatShell → Backend

Gap: WhistleButton emits memberSelected → ChatHeader passes it up as whistleMemberSelected → ChatShell never handles it → WhistleService.whistle() never called.

Files:

Read libs/libs-ai/vitto-chat/src/lib/components/chat-shell/chat-shell.component.html and find the <lib-chat-header> tag. Note the exact line and existing bindings.

On the <lib-chat-header> tag, add:

(whistleMemberSelected)="onWhistleMemberSelected($event)"

In chat-shell.component.ts, add:

async onWhistleMemberSelected(member: { userId: string; displayName: string }) {
  const session = this.chatEngine.activeSession();
  if (!session) return;

  await this.whistleService.whistle({
    sessionId: session.sessionId,
    targetUserId: member.userId,
    targetDisplayName: member.displayName,
    whistlerUserId: this.chatEngine.userId(),
    whistlerDisplayName: this.chatEngine.userName(),
    historyAccess: 'summary',
    urgency: 'smart',
  });
}

Verify WhistleService is injected in the constructor. If not, add: private readonly whistleService = inject(WhistleService);

Read whistle.service.ts and confirm it POSTs to /vitto-api/engine/whistle with the right payload. If the method signature doesn't match what we're calling, adapt the call.


Task 2: Add Group Fields to Frontend Session Models

Gap: VittoChatSession and VittoSessionSummary have no isGroup, participants, or groupConversationId fields. The sidebar, message renderer, and engine can't distinguish group from 1-on-1.

Files:

Search for interface VittoChatSession or VittoChatSession in libs/libs-ai/vitto-chat/src/. Read the full interface.

Add to the VittoChatSession interface:

isGroup?: boolean;
participants?: Array<{
  userId: string;
  displayName: string;
  role: 'originator' | 'member';
  joinedAt: number;
  channel?: string;
}>;
groupConversationId?: string;

Search for interface VittoSessionSummary or similar. Add:

isGroup?: boolean;
participantCount?: number;
participantNames?: string[];  // For sidebar preview

Find where sessions are fetched from the backend (GET /sessions/:id or GET /sessions/my). Ensure the response mapping includes the new fields. The backend already returns isGroup and participants — we just need to not strip them.


Task 3: Wire UnifiedMessageComponent for Group Sender Attribution

Gap: message-bubble.component.ts has senderInfo logic for groups, but UnifiedMessageComponent (the actual renderer in chat-shell) doesn't pass isGroup through. Group messages show "Vitto" instead of actual sender names.

Files:

Find and read the component. Identify how it renders messages and where it decides the display name and avatar.

Add to the component:

isGroup = input(false);

Where the component determines the display name (currently "You" vs "Vitto"), add group logic:

readonly displayName = computed(() => {
  const msg = this.message();
  if (msg.role === 'user') {
    if (this.isGroup() && msg.senderId && msg.senderId !== this.currentUserId()) {
      return msg.senderName || 'Unknown';
    }
    return 'You';
  }
  return 'Vitto';
});

In the chat-shell template where messages are rendered (likely a @for loop over messages), pass:

[isGroup]="isGroupSession()"

Task 4: Add Socket.IO Listener for Participant Joined

Gap: When someone accepts a whistle and joins a group session, the existing participants don't see the update until page refresh. Backend emits chat:participant-joined but frontend doesn't listen.

Files:

Find the Socket.IO service. Read how existing events (onMessagesReceived, onSessionUpdated) are wired.

Add a new observable/callback for the chat:participant-joined event:

onParticipantJoined(callback: (data: { sessionId: string; userId: string; displayName: string; joinedAt: number }) => void) {
  this.socket.on('chat:participant-joined', callback);
}

In the engine service, where Socket.IO listeners are set up, add:

this.realtimeService.onParticipantJoined((data) => {
  const session = this.activeSession();
  if (session?.sessionId === data.sessionId) {
    // Update participants list reactively
    const current = session.participants || [];
    if (!current.find(p => p.userId === data.userId)) {
      this._activeSession.update(s => s ? {
        ...s,
        participants: [...(s.participants || []), {
          userId: data.userId,
          displayName: data.displayName,
          role: 'member' as const,
          joinedAt: data.joinedAt,
        }],
        isGroup: true,
      } : s);
    }
  }
});

Task 5: Update Session Sidebar for Group Awareness

Gap: Session list shows all sessions identically. No visual indicator for group sessions, no participant preview.

Files:

Find and read unified-session-sidebar.component.ts. Understand how sessions are listed and what data is available.

In the template, where each session is rendered, add conditional group indicator:

@if (session.isGroup) {
  <span class="group-badge" title="Group conversation">
    <span class="participant-count">{{ session.participantCount || 2 }}</span>
  </span>
}
.group-badge {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  font-size: 11px;
  opacity: 0.7;
}

Task 6: Verify Full Whistle Flow End-to-End

Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -10 Run: build the affected frontend libs

  1. Chat header renders whistle button ✓ (existing)
  2. User clicks member → memberSelected emits ✓ (existing)
  3. ChatHeader passes to shell → whistleMemberSelected emits (existing)
  4. ChatShell handles it → calls whistleService.whistle() (Task 1 — NEW)
  5. Backend processes whistle → Socket.IO emits chat:whistled to target (existing)
  6. Target sees WhistleNotificationComponent → clicks Join (existing)
  7. chatEngine.acceptWhistle() loads group session (existing)
  8. Session shows as group in sidebar (Task 5 — NEW)
  9. Messages show sender names (Task 3 — NEW)
  10. New participant joins → Socket.IO updates participant list (Task 4 — NEW)

Implementation Notes

What we are NOT changing

What we ARE changing (6 targeted fixes)

  1. ChatShell: bind to header's whistle event, call backend
  2. Session models: add isGroup + participants fields
  3. Message renderer: show sender names in groups
  4. Socket.IO: listen for participant-joined
  5. Sidebar: show group badge
  6. Verify everything compiles and flows end-to-end

Risks