Files
ChatApp/docs/superpowers/plans/2026-05-15-call-preview-panel.md

12 KiB
Raw Permalink Blame History

Call Preview Panel 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: Replace the always-on "Sprach-Channel" banner with a Discord-DM-style preview panel that only renders when peers are in an active call, showing them as large avatar tiles with a single "Beitreten" button.

Architecture: VoiceChannelRail.tsx is renamed to CallPreviewPanel.tsx and rewritten with a new tile-grid layout. Visibility rule drops the || isGroup clause so groups behave identically to 1:1 chats. All call-session wiring (useCall, useCallPresence, joinActiveCall, ConversationHeader's phone icon for starting calls) stays unchanged.

Tech Stack: TypeScript, React 18, Tailwind, react-i18next, existing useCallPresence realtime hook.

Spec: docs/superpowers/specs/2026-05-15-call-preview-panel-design.md


File Overview

New files:

  • apps/desktop/src/components/CallPreviewPanel.tsx — the new panel.

Deleted files:

  • apps/desktop/src/components/VoiceChannelRail.tsx — replaced by CallPreviewPanel.tsx.

Modified files:

  • apps/desktop/src/pages/ConversationPage.tsx — change the import + JSX tag.

i18n note: No JSON resource changes needed. The orphaned keys (voice_empty, voice_open, voice_channel, voice_count) only existed as inline defaultValue: strings inside the deleted component — they were never in the locale files. The new component reuses the existing app:call.active_in_conv and app:call.join keys (present in both de/app.json and en/app.json).


Task 1: Create CallPreviewPanel.tsx

Files:

  • Create: apps/desktop/src/components/CallPreviewPanel.tsx

  • Step 1: Create the file

apps/desktop/src/components/CallPreviewPanel.tsx:

import type { ConversationSummary } from '@chat-app/shared/chat';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';

interface Props {
  conversation: ConversationSummary;
}

const MAX_TILES = 7;

/**
 * Discord-DM-style call preview panel. Renders only while peers are in the
 * conversation's active call and the local user is NOT in it. Provides large
 * avatar tiles plus a single "Beitreten" call-to-action. Calls are still
 * STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
 * component never initiates — only joins.
 */
export function CallPreviewPanel({ conversation }: Props) {
  const { t } = useTranslation(['app']);
  const { session } = useAuth();
  const { state, joinActiveCall } = useCall();
  const presentIds = useCallPresence(conversation.id);
  const [collapsed, setCollapsed] = useState(false);

  const myId = session?.user.id ?? null;
  const iAmIn =
    (state.kind === 'connected' ||
      state.kind === 'connecting' ||
      state.kind === 'reconnecting') &&
    state.conversationId === conversation.id;

  const others = presentIds.filter((u) => u !== myId);
  if (iAmIn || others.length === 0) return null;

  const visibleTiles = others.slice(0, MAX_TILES);
  const overflow = Math.max(0, others.length - MAX_TILES);
  const busy = state.kind !== 'idle';

  const handleJoin = () => {
    if (busy) return;
    void joinActiveCall(conversation.id, 'audio');
  };

  return (
    <div className="border-b border-line bg-surface-3/70">
      <div className="flex items-center gap-2 px-5 py-2 text-xs">
        <PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
        <span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
          {t('app:call.active_in_conv', {
            defaultValue: 'Laufender Anruf · {{count}} im Raum',
            count: others.length,
          })}
        </span>
        <button
          type="button"
          onClick={() => setCollapsed((c) => !c)}
          aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
          className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
        >
          <XIcon className="h-3.5 w-3.5" />
        </button>
      </div>

      {!collapsed && (
        <div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
          <div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
            {visibleTiles.map((id) => {
              const member = conversation.members.find((m) => m.userId === id);
              const name = member?.profile?.displayName ?? '?';
              return (
                <div
                  key={id}
                  title={name}
                  className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
                >
                  <div className="h-16 w-16 overflow-hidden rounded-full">
                    <Avatar
                      url={member?.profile?.avatarUrl ?? null}
                      displayName={name}
                      className="h-full w-full text-base"
                    />
                  </div>
                  <span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
                </div>
              );
            })}
            {overflow > 0 && (
              <div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
                <span className="text-xl font-semibold">+{overflow}</span>
                <span className="text-xs">weitere</span>
              </div>
            )}
          </div>

          <button
            type="button"
            onClick={handleJoin}
            disabled={busy}
            className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
          >
            {busy ? (
              <SpinnerIcon className="h-4 w-4" />
            ) : (
              <PhoneIcon className="h-4 w-4" />
            )}
            <span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
          </button>
        </div>
      )}
    </div>
  );
}
  • Step 2: Verify icon imports resolve
cd D:/Programmieren/ChatApp-Electron/chat-app
grep -n "export const PhoneIcon\|export function PhoneIcon\|export const SpinnerIcon\|export function SpinnerIcon\|export const XIcon\|export function XIcon" apps/desktop/src/components/icons.tsx

Expected: all three icons exported. (Earlier tasks confirmed PhoneIcon, SpinnerIcon; XIcon is also used by other modals — verify.) If XIcon is missing under that name, swap the import to whatever the close-icon export is in icons.tsx (e.g. CloseIcon) and report the substitution.

  • Step 3: Typecheck
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10

Expected: zero errors. (After the encryption-UX rollout the desktop typecheck is clean.)

  • Step 4: Commit
cd D:/Programmieren/ChatApp-Electron/chat-app
git add apps/desktop/src/components/CallPreviewPanel.tsx
git commit -m "feat(desktop): add CallPreviewPanel — Discord-DM-style join surface"

Task 2: Wire the new panel in ConversationPage.tsx and delete the old rail

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx

  • Delete: apps/desktop/src/components/VoiceChannelRail.tsx

  • Step 1: Swap the import

In apps/desktop/src/pages/ConversationPage.tsx, replace this line (around line 26):

import { VoiceChannelRail } from '../components/VoiceChannelRail';

with:

import { CallPreviewPanel } from '../components/CallPreviewPanel';
  • Step 2: Swap the JSX usage

In the same file, replace the JSX line (around line 657):

{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}

with:

{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
  • Step 3: Delete the dead component
rm apps/desktop/src/components/VoiceChannelRail.tsx
  • Step 4: Sweep for stragglers
grep -rn "VoiceChannelRail" apps/desktop/src

Expected: no hits. If anything remains, clean it up.

  • Step 5: Typecheck
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10

Expected: zero errors.

  • Step 6: Build the desktop renderer once to surface any tailwind/runtime issues
pnpm --filter @chat-app/desktop build 2>&1 | tail -15

Expected: build succeeds. Tailwind classes used here (bg-surface-3/70, border-line, bg-emerald-600, bg-surface-2, text-fg, text-fg-muted, aspect-square, line-clamp-1, grid-cols-2 md:grid-cols-3) are all already used elsewhere in the desktop app and should be in the existing tailwind config.

  • Step 7: Commit
cd D:/Programmieren/ChatApp-Electron/chat-app
git add apps/desktop/src/pages/ConversationPage.tsx
git rm apps/desktop/src/components/VoiceChannelRail.tsx
git commit -m "refactor(desktop): replace VoiceChannelRail with CallPreviewPanel

Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon."

Task 3: Manual smoke pass (USER)

This task is for the human operator after the previous two land. Run a fresh dev build and walk through the spec's smoke checklist:

  • Open a 1:1 chat with no active call → panel not rendered.
  • Open a group chat with no active call → panel not rendered (regression test for the bug).
  • Have a peer start a call → panel appears, peer's avatar tile + "Beitreten" visible.
  • Click "Beitreten" → joins the call; panel disappears (you're now iAmIn).
  • Hang up → panel reappears with peer still in.
  • Peer hangs up too → panel disappears.
  • Resize the window narrow → grid collapses to 2 columns.
  • Group call with 9 participants → 7 avatar tiles + one +2 overflow tile.
  • Click the in the panel header → tiles collapse, header stays. Click again → tiles re-expand.

If any step fails, capture the exact behavior and reopen the relevant task.


Self-Review

1. Spec coverage:

  • Visibility rule (!iAmIn && others.length > 0, no || isGroup) — Task 1.
  • Discord-DM tile layout, ~120×120px tiles, 2/3-column responsive grid, +N overflow at 8 — Task 1.
  • "Aktiver Anruf · n im Channel" header reusing app:call.active_in_conv — Task 1.
  • Centered "Beitreten" CTA, disabled when busy, SpinnerIcon while connecting — Task 1.
  • Optional collapse via useState<boolean>, default expanded — Task 1.
  • Mount point unchanged (!incomingHere && gate retained) — Task 2.
  • Old empty state and Channel öffnen removed by deleting the rail — Task 2.
  • Manual smoke list — Task 3.

2. Placeholder scan: Clean. Every code block is complete; every command has expected output; the only defaultValue: strings are in the i18n calls (not placeholders, just fallback copy).

3. Type consistency: Props { conversation: ConversationSummary } matches the type used by the deleted rail and consumed by ConversationPage.tsx. useCallPresence returns string[] per apps/desktop/src/lib/useCallPresence.ts. useCall().state.kind values (idle, connecting, connected, reconnecting) match the existing CallContext discriminated union. joinActiveCall(convId, 'audio') matches the existing useCall API.

No gaps; plan is complete.