Files
ChatApp/docs/superpowers/plans/2026-05-12-discord-call-tile-handling.md
T
byGalax 8baac2fd1e fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.

Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.

ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.

docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:26 +02:00

31 KiB
Raw Blame History

Discord-Style Call Tile Handling 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: Make in-call tile rendering, click-to-pin, and mixed share/webcam layouts mirror Discord — uniform 16:9 grid, correct object-fit per mode, left-click pins, auto-promote shares, multi-share bento.

Architecture: All changes are renderer-only inside apps/desktop/src/components/. A new discriminated-union StageLayout lives in InCallPanel.tsx and replaces the implicit effectiveFocusedId logic. CallParticipantTile gains a fit prop forwarded to the underlying <video> element. ScreenShareViewer drops its hardcoded 16:9 button-aspect because the parent grid cell owns the ratio now. No changes to CallContext, no data-model changes.

Tech Stack: React 18, TypeScript, Tailwind (aspect-video, grid-cols-*, object-cover/object-contain), LiveKit JS SDK 2.x.

Spec: docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md

Testing note: No Vitest/Jest harness exists for in-call layouts (Storybook not wired up). Every task ends with a manual verification checklist run against pnpm --filter @chatapp/desktop dev plus a peer (or a second window joined to the same room). Tasks are committed only after the manual checks pass.


File Structure

File Responsibility Change
apps/desktop/src/components/CallParticipantTile.tsx Single participant tile (webcam or audio-only) Add fit prop on VideoStub, forward focusedfit='contain', add onDoubleClick
apps/desktop/src/components/ScreenShareViewer.tsx Renders a remote screen-share with watch/fullscreen chrome Drop hardcoded aspectRatio: '16/9' on the unwatched preview button
apps/desktop/src/components/InCallPanel.tsx Top-level in-call orchestration: stage layouts, fullscreen, controls, pin state plumbing Add StageLayout selector; rewrite CallStage + FullscreenCall rendering; drop grid-rows-* from gridColsFor; wrap every tile cell in aspect-video

No new files. Three modified files, each with a clear local responsibility.


Task 1: VideoStub gains fit prop, default cover, contain when focused

Files:

  • Modify: apps/desktop/src/components/CallParticipantTile.tsx:299-339 (VideoStub component) + CallParticipantTile.tsx:89-135 (CallParticipantTile wiring)

  • Step 1: Add fit prop to VideoStub

In CallParticipantTile.tsx, replace the VideoStub signature (currently function VideoStub({ userId, displayName, avatarUrl, videoTrack, me, small }: ...)):

function VideoStub({
  userId,
  displayName,
  avatarUrl,
  videoTrack,
  me,
  small,
  fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {

And replace the className on the <video> element (currently 'h-full w-full object-cover ' + (me ? 'scale-x-[-1]' : '')) with:

className={
  'h-full w-full ' +
  (fit === 'contain' ? 'object-contain ' : 'object-cover ') +
  (me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
  • Step 2: Forward fit from CallParticipantTile

In CallParticipantTile.tsx, inside CallParticipantTile, replace:

{video ? (
  <VideoStub {...props} small={small} />
) : (
  <AudioContent {...props} small={small} />
)}

with:

{video ? (
  <VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
  <AudioContent {...props} small={small} />
)}
  • Step 3: Type-check

Run: pnpm --filter @chatapp/desktop typecheck Expected: PASS (no errors in CallParticipantTile.tsx).

  • Step 4: Commit
git add apps/desktop/src/components/CallParticipantTile.tsx
git commit -m "feat(call): VideoStub accepts fit prop, contain when focused"

Task 2: ScreenShareViewer drops the hardcoded preview aspect

Files:

  • Modify: apps/desktop/src/components/ScreenShareViewer.tsx:108-128 (unwatched preview button)

  • Step 1: Remove style={{ aspectRatio: '16 / 9' }}

In ScreenShareViewer.tsx, locate the <button type="button" onClick={() => watchShare(...)}> (the "Bildschirm anschauen" overlay). Replace:

<button
  type="button"
  onClick={() => watchShare(share.participantId)}
  aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
  className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
  style={{ aspectRatio: '16 / 9' }}
>

with:

<button
  type="button"
  onClick={() => watchShare(share.participantId)}
  aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
  className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
>

flex-1 h-full makes the button fill whatever vertical space the parent grid cell (now aspect-video) gives it, instead of forcing its own 16:9 inside an arbitrary cell.

  • Step 2: Type-check + commit
pnpm --filter @chatapp/desktop typecheck
git add apps/desktop/src/components/ScreenShareViewer.tsx
git commit -m "feat(call): drop hardcoded 16:9 on screen-share preview button"

Task 3: Grid cells become aspect-video, drop grid-rows-*

Files:

  • Modify: apps/desktop/src/components/InCallPanel.tsx:1085-1099 (gridColsFor), :1030-1052 (CallStage grid branch), :1003-1025 (CallStage focus strip)

  • Step 1: Rewrite gridColsFor to drop row constraints

In InCallPanel.tsx, replace the existing gridColsFor:

function gridColsFor(n: number): string {
  // Explicit `grid-rows-*` so cells get a defined height (1fr of available
  // space). Without this, implicit rows default to auto → they size to
  // content, and a video element's intrinsic size blows the tile past the
  // container bounds (overlapping the toolbar below).
  if (n <= 1) return 'grid-cols-1 grid-rows-1';
  if (n === 2) return 'grid-cols-2 grid-rows-1';
  if (n === 3) return 'grid-cols-3 grid-rows-1';
  if (n === 4) return 'grid-cols-2 grid-rows-2';
  if (n <= 6) return 'grid-cols-3 grid-rows-2';
  if (n <= 9) return 'grid-cols-3 grid-rows-3';
  return 'grid-cols-4 grid-rows-3';
}

with:

function gridColsFor(n: number): string {
  // Discord-style: column count only. Cells are `aspect-video` so their
  // height follows from their width, and the container centers them
  // vertically when the row stack is shorter than the available area.
  if (n <= 1) return 'grid-cols-1';
  if (n === 2) return 'grid-cols-2';
  if (n === 3) return 'grid-cols-3';
  if (n === 4) return 'grid-cols-2';
  if (n <= 6) return 'grid-cols-3';
  if (n <= 9) return 'grid-cols-3';
  return 'grid-cols-4';
}
  • Step 2: Wrap CallStage grid cells in aspect-video

In CallStage, replace the grid-branch return (currently the block starting with // Grid then const gridClass = gridColsFor(tiles.length); …):

// Grid
const gridClass = gridColsFor(tiles.length);
return (
  <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
    <div className={'grid h-full gap-2 ' + gridClass}>
      {tiles.map((p) => (
        <div key={p.id} className="[&>div]:h-full [&>div]:w-full">
          <TileRender ... />
        </div>
      ))}
    </div>
  </div>
);

with:

// Grid
const gridClass = gridColsFor(tiles.length);
return (
  <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
    <div
      className={
        'grid h-full max-h-full gap-2 place-content-center ' + gridClass
      }
    >
      {tiles.map((p) => (
        <div
          key={p.id}
          className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
        >
          <TileRender
            tile={p}
            activeSpeakers={activeSpeakers}
            e2ee={e2ee}
            remoteScreenShares={remoteScreenShares}
            conversationMembers={conversationMembers}
            onClick={() => onFocusTile(p.id)}
            {...(onTileContextMenu
              ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
              : {})}
          />
        </div>
      ))}
    </div>
  </div>
);

place-content-center centers the row stack vertically; each cell is aspect-video so 16:9 wins over arbitrary row stretching.

  • Step 3: Wrap focus-strip thumbs in aspect-video

In the if (mode === 'focus' && speaker) branch of CallStage, replace the strip cell wrapper (currently <div key={p.id} className="h-full w-[240px] shrink-0 [&>div]:h-full">):

<div
  key={p.id}
  className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
>

The fixed w-[240px] is replaced by aspect-video so the thumb's width is driven by the strip's h-[180px] height. This keeps webcam thumbs at 16:9 (320×180) instead of an arbitrary 240×180 which crops faces.

  • Step 4: Type-check
pnpm --filter @chatapp/desktop typecheck

Expected: PASS.

  • Step 5: Manual verification

Start the dev server, join a call with 24 webcams. Check:

  • All grid tiles are equal-size 16:9 boxes; no tile is taller or wider than its neighbors.
  • With 3 participants → single row of 3; with 4 → 2×2; with 56 → 3×2 (last cell may be empty/centered).
  • Faces are framed naturally (object-cover); no obvious squish or stretch.

If layout looks wrong, screenshot, do not commit, and iterate on the wrapping classes.

  • Step 6: Commit
git add apps/desktop/src/components/InCallPanel.tsx
git commit -m "feat(call): uniform 16:9 grid cells, drop grid-rows constraint"

Task 4: Introduce StageLayout discriminated union

Files:

  • Modify: apps/desktop/src/components/InCallPanel.tsx:295-345 (the area where effectiveFocusedId and speaker are computed inside InCallPanel)

  • Step 1: Add the StageLayout type and selector

In InCallPanel.tsx, locate the block:

// Screen shares no longer auto-promote — the user opts in by clicking the
// "Bildschirm anschauen" overlay, which also toggles whether the audio
// plays. Focus falls back to the first tile so focus-mode always has
// something to show when no tile was explicitly picked.
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];

Replace it with:

// Discord-style precedence:
//   1. focusedId set         → 'focus', that tile is the stage.
//   2. ≥2 shares, no pin     → 'bento', shares fill the stage, webcams strip.
//   3. exactly 1 share, no pin → 'focus' (auto-promote share).
//   4. no shares, no pin     → 'equal-grid'.
type StageLayout =
  | { kind: 'equal-grid' }
  | { kind: 'focus'; bigTileId: string }
  | { kind: 'bento'; shareIds: string[] };

const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
const stageLayout: StageLayout = (() => {
  if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
    return { kind: 'focus', bigTileId: focusedId };
  }
  if (shareIds.length >= 2) return { kind: 'bento', shareIds };
  if (shareIds.length === 1 && shareIds[0]) {
    return { kind: 'focus', bigTileId: shareIds[0] };
  }
  return { kind: 'equal-grid' };
})();

// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
// stays consistent with the layout decision.
const bigTile =
  stageLayout.kind === 'focus'
    ? tiles.find((t) => t.id === stageLayout.bigTileId)
    : undefined;
  • Step 2: Replace existing usages of speaker and effectiveFocusedId

Search InCallPanel.tsx for every remaining reference to effectiveFocusedId and speaker inside the InCallPanel function and replace as follows:

  • speaker (used as prop on CallStage, FullscreenCall, focused-tile detection) → bigTile.
  • effectiveFocusedId (used in pinnedTileId prop for context menu) → focusedId (we no longer override pin for menu purposes; the auto-promoted share isn't user-pinned).

Concretely, the line pinnedTileId: focusedId, is already correct (uses focusedId, not the effective). The effectiveFocusedId declaration and speaker are removed by Step 1. Remaining usages:

  • In the fullscreen branch: replace speaker={effectiveSpeaker} and the effectiveSpeaker = hasFocus ? speaker : undefined derivation with speaker={bigTile} (and drop the now-redundant hasFocus / effectiveSpeaker lines, since bigTile is undefined exactly when there's no focus).
  • In the focus branch: replace speaker={speaker} with speaker={bigTile}.

After this step, InCallPanel's render path no longer uses the old effectiveFocusedId or speaker locals — only stageLayout, bigTile, focusedId.

  • Step 3: Type-check
pnpm --filter @chatapp/desktop typecheck

Expected: PASS.

  • Step 4: Manual verification

Start the dev server, join a call (no shares yet, no pin). Check:

  • Equal-grid renders as in Task 3 (no behavior regression).

  • Right-click → "Anpinnen" still works: pins the tile, the call panel collapses to focus-mode showing that tile big.

  • Right-click → "Anpinnen aufheben" returns to equal-grid.

  • Step 5: Commit

git add apps/desktop/src/components/InCallPanel.tsx
git commit -m "feat(call): introduce StageLayout discriminated union"

Task 5: Wire CallStage to render focus and equal-grid from StageLayout

Files:

  • Modify: apps/desktop/src/components/InCallPanel.tsxInCallPanel's docked-call render branch (the area that calls <CallStage mode={callMode} ...>) and CallStage itself

  • Step 1: Map stageLayoutmode for CallStage

In InCallPanel.tsx, locate the docked-call render that mounts <CallStage mode={callMode} ...>. Replace the mode={callMode} prop with a derived value:

<CallStage
  tiles={tiles}
  speaker={bigTile}
  // Discord-style: layout decision is driven by StageLayout (see top of
  // InCallPanel), not by the user-visible callMode toggle. callMode still
  // gates the cinema/fullscreen entry — for the docked stage we collapse
  // 'focus' and 'bento' to whatever CallStage knows how to render.
  mode={stageLayout.kind === 'equal-grid' ? 'grid' : 'focus'}
  activeSpeakers={activeSpeakers}
  e2ee={e2ee}
  remoteScreenShares={remoteScreenShares}
  conversationMembers={conversation.members}
  onFocusTile={(id) => {
    // Discord-style toggle: clicking the already-focused tile drops the
    // pin; clicking another tile swaps. callMode auto-syncs.
    setFocusedId(focusedId === id ? null : id);
  }}
  ...
/>

Note: setCallMode calls on click are removed — callMode no longer tracks pin state. callMode is now only 'grid' (docked) or 'fullscreen' (cinema). The third state ('focus') is implicit when focusedId !== null and isn't a separate top-level mode anymore.

  • Step 2: Drop the click-toggle that swapped callMode

Find the onClick callbacks in InCallPanel that did setCallMode('focus') or setCallMode('grid'). Replace each with a single setFocusedId(focusedId === id ? null : id) call (or remove the redundant ones that are now handled by onFocusTile).

  • Step 3: Adjust the Mode button bar

The <ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid"> row currently switches between grid, focus, fullscreen. Drop the focus button entirely — there's no manual focus mode anymore. Keep grid and fullscreen.

Locate the ModeButtonRow (search for ModeButton):

<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
  <GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Sprecher">
  <FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'fullscreen'} onClick={() => onChange('fullscreen')} label="Vollbild">
  <MaximizeIcon className="h-4 w-4" />
</ModeButton>

Delete the middle (focus) ModeButton block. The remaining two cover all user-driven modes.

  • Step 4: Type-check
pnpm --filter @chatapp/desktop typecheck

Expected: PASS. If 'focus' is referenced in CallMode type and unused now, leave the type alone — 'focus' is still a valid value, just not user-selectable. Don't refactor the type.

  • Step 5: Manual verification

In a dev call (24 participants, no shares):

  • Click a webcam tile → it becomes big, others to strip (focus-style stage). No mode bar change.

  • Click it again → equal grid restores.

  • Click another tile while one is pinned → swap to that tile.

  • Mode bar shows only Grid and Vollbild (the middle Sprecher button is gone).

  • Step 6: Commit

git add apps/desktop/src/components/InCallPanel.tsx
git commit -m "feat(call): left-click toggles pin, drop manual focus mode"

Task 6: Auto-promote single share (precedence rule 3)

Files:

  • No code change beyond what's already in Task 4 + Task 5. This task is the manual verification that the auto-promote path works end-to-end.

  • Step 1: Manual verification — share auto-promote

In a dev call (2 participants):

  • User A starts a screen share. Expected: share auto-promotes to the big stage on User B's side; User A's webcam moves to the strip.
  • User A stops the share. Expected: equal grid restores.
  • User A shares again; User B clicks User A's webcam thumb. Expected: webcam pins big, share moves to strip.
  • User B double-clicks the pinned webcam (Task 7 adds this; if not yet implemented, right-click → unpin works too). Expected: share auto-promotes again.

If any step fails, return to Task 4 (stageLayout selector) and verify the bigTile derivation is reading tiles.find((t) => t.id === stageLayout.bigTileId) correctly.

  • Step 2: No commit

This task only verifies behavior introduced in earlier tasks.


Task 7: Doubleclick on the focused tile clears pin

Files:

  • Modify: apps/desktop/src/components/CallParticipantTile.tsx:120-130 (root <div> of the tile)

  • Modify: apps/desktop/src/components/CallParticipantTile.tsx:54-87 (ParticipantTileProps)

  • Modify: apps/desktop/src/components/InCallPanel.tsxTileRender props and the focused-tile rendering paths

  • Step 1: Add onDoubleClick prop

In CallParticipantTile.tsx, extend ParticipantTileProps:

onDoubleClick?: () => void;

In the CallParticipantTile body, destructure it:

const {
  // ... existing
  onDoubleClick,
} = props;

And add it to the root <div>:

<div
  onClick={onClick}
  onDoubleClick={onDoubleClick}
  onContextMenu={onContextMenu}
  ...
>
  • Step 2: Plumb onDoubleClick through TileRender

In InCallPanel.tsx, extend the TileRender component props (the inline interface) with onDoubleClick?: () => void;. Pass it through to CallParticipantTile the same way onClick is passed:

{...(onDoubleClick ? { onDoubleClick } : {})}

And on the <div> wrapping the screen-tile branch, add onDoubleClick={onDoubleClick} next to onClick.

  • Step 3: Hook doubleclick on focused tiles to clear pin

In FocusedTile, accept and forward onDoubleClick:

function FocusedTile({
  tile,
  e2ee,
  activeSpeakers,
  remoteScreenShares,
  conversationMembers,
  onContextMenu,
  onDoubleClick,
}: {
  // ... existing
  onDoubleClick?: () => void;
}) {
  return (
    <div className="h-full [&>div]:h-full">
      <TileRender
        tile={tile}
        activeSpeakers={activeSpeakers}
        e2ee={e2ee}
        remoteScreenShares={remoteScreenShares}
        conversationMembers={conversationMembers}
        focused
        {...(onContextMenu ? { onContextMenu } : {})}
        {...(onDoubleClick ? { onDoubleClick } : {})}
      />
    </div>
  );
}

In CallStage's focus branch, pass an onDoubleClick that clears the pin:

<FocusedTile
  tile={speaker}
  e2ee={e2ee}
  activeSpeakers={activeSpeakers}
  remoteScreenShares={remoteScreenShares}
  conversationMembers={conversationMembers}
  onDoubleClick={() => onFocusTile(speaker.id)}
  {...(onTileContextMenu
    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
    : {})}
/>

(onFocusTile(speaker.id) toggles — clicking the already-pinned id clears the pin per Task 5's setter.)

In FullscreenCall's big-tile branch, pass the same onDoubleClick to the big tile wrapper:

<div
  className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
  onDoubleClick={() => onFocusTile(speaker.id)}
>
  • Step 4: Type-check
pnpm --filter @chatapp/desktop typecheck

Expected: PASS.

  • Step 5: Manual verification

In a dev call with a pinned tile:

  • Doubleclick the pinned big tile → unpins, layout falls back through StageLayout precedence (equal-grid if no shares, or share auto-promote if shares are active).

  • Single-click still toggles (no regression).

  • Step 6: Commit

git add apps/desktop/src/components/CallParticipantTile.tsx apps/desktop/src/components/InCallPanel.tsx
git commit -m "feat(call): doubleclick on focused tile clears pin"

Task 8: Multi-share bento stage

Files:

  • Modify: apps/desktop/src/components/InCallPanel.tsxCallStage (add a bento branch) and the docked-call render to pass the stageLayout directly to CallStage

  • Step 1: Pass stageLayout to CallStage

In InCallPanel.tsx, extend StageProps:

interface StageProps {
  tiles: Tile[];
  speaker: Tile | undefined;
  /** Discriminated layout decision driven by InCallPanel's StageLayout
   *  selector. Drives the bento-vs-grid-vs-focus render branch. */
  stageLayout:
    | { kind: 'equal-grid' }
    | { kind: 'focus'; bigTileId: string }
    | { kind: 'bento'; shareIds: string[] };
  // mode dropped — it duplicated stageLayout. callMode is still in the
  // parent for fullscreen-mode entry, just not threaded here anymore.
  activeSpeakers: Set<string>;
  e2ee: boolean;
  remoteScreenShares: {
    track: import('livekit-client').RemoteTrack;
    participantId: string;
    participantName: string;
  }[];
  conversationMembers: ConversationSummary['members'];
  onFocusTile: (id: string) => void;
  onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
  compact?: boolean;
}

(Remove the mode: CallMode; field; replace with stageLayout.)

Update the <CallStage ...> site in InCallPanel to pass stageLayout={stageLayout} instead of mode={...}.

  • Step 2: Rewrite CallStage branch dispatch

In CallStage, replace the body (currently if (mode === 'focus' && speaker) { ... } // Grid ...) with:

function CallStage({
  tiles,
  speaker,
  stageLayout,
  activeSpeakers,
  e2ee,
  remoteScreenShares,
  conversationMembers,
  onFocusTile,
  onTileContextMenu,
  compact = false,
}: StageProps) {
  if (stageLayout.kind === 'focus' && speaker) {
    // ... existing focus branch unchanged
  }

  if (stageLayout.kind === 'bento') {
    const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
    const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
    const bentoCols = gridColsFor(shares.length);
    return (
      <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
        <div className="min-h-0 flex-1">
          <div
            className={
              'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
            }
          >
            {shares.map((s) => (
              <div
                key={s.id}
                className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={s}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  onClick={() => onFocusTile(s.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        </div>
        {webcams.length > 0 && (
          <div className="flex h-[180px] gap-2.5 overflow-x-auto">
            {webcams.map((w) => (
              <div
                key={w.id}
                className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={w}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  size="small"
                  onClick={() => onFocusTile(w.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        )}
      </div>
    );
  }

  // equal-grid
  const gridClass = gridColsFor(tiles.length);
  return (
    <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
      <div
        className={
          'grid h-full max-h-full gap-2 place-content-center ' + gridClass
        }
      >
        {tiles.map((p) => (
          <div
            key={p.id}
            className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
          >
            <TileRender
              tile={p}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              onClick={() => onFocusTile(p.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 3: Apply the same layout choice inside FullscreenCall

In FullscreenCall, the existing branch is if (hasFocus) { big-stage } else { grid }. Update the else-branch to also handle bento. The grid render path inside FullscreenCall currently uses sortedGridTiles + gridColsFor. Extend it:

After the existing hasFocus check and before the grid render, add:

const fsShareIds = tiles
  .filter((t) => t.kind === 'screen')
  .map((t) => t.id);
const bentoMode = !hasFocus && fsShareIds.length >= 2;
const bentoShares = bentoMode
  ? tiles.filter((t) => fsShareIds.includes(t.id))
  : [];
const bentoWebcams = bentoMode
  ? tiles.filter((t) => !fsShareIds.includes(t.id))
  : [];

Then wrap the existing grid-only render in:

{bentoMode ? (
  <div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
    <div className="min-h-0 flex-1">
      <div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
        {bentoShares.map((s) => (
          <div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
            <TileRender
              tile={s}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              onClick={() => onFocusTile(s.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    </div>
    {bentoWebcams.length > 0 && (
      <div className="flex h-[180px] gap-2 overflow-x-auto">
        {bentoWebcams.map((w) => (
          <div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
            <TileRender
              tile={w}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              size="small"
              onClick={() => onFocusTile(w.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    )}
  </div>
) : (
  // ... existing grid render unchanged
)}
  • Step 4: Type-check
pnpm --filter @chatapp/desktop typecheck

Expected: PASS. If mode is still referenced anywhere in CallStage, remove the stray reference (it's been replaced by stageLayout.kind).

  • Step 5: Manual verification — multi-share

Set up a 2-share scenario (two clients sharing simultaneously):

  • In docked mode: stage shows both shares side-by-side at equal size, webcams in the strip below.

  • In fullscreen-cinema mode: same bento, fills the screen.

  • Clicking one of the bento shares pins it → layout drops to single-stage focus on that share.

  • Stopping one share → falls back through StageLayout → rule 3 (single-share auto-promote).

  • Stopping both → equal grid.

  • Step 6: Commit

git add apps/desktop/src/components/InCallPanel.tsx
git commit -m "feat(call): multi-share bento layout in stage + fullscreen"

Task 9: End-to-end verification pass

Files: None.

  • Step 1: Run all manual checks from the spec, end to end

Spec section "Testing" lists six scenarios. Run all six:

  1. Webcam-only equal grid: 4 webcams, no pin, no share → 2×2 uniform, faces cropped via cover.
  2. Pinning toggle: click webcam → big with contain (no head crop), strip below; click again → grid.
  3. Share auto-promote: start share → share is big, webcams strip, share aspect respected.
  4. Pin override during share: while share is big, click webcam → webcam pins big, share to strip; click webcam again → back to share auto-promote.
  5. Multi-share: two users share → bento stage; click one → pin that share.
  6. Active speaker: someone talks → emerald border, no reorder.
  • Step 2: Check no console errors

Open DevTools console during the call. Expected: no warnings about React keys, missing props, or unhandled promise rejections related to the touched files.

  • Step 3: Final commit (if any cleanup)

If you made trailing cleanup commits during the verification, push the branch. Otherwise nothing more to commit.

git log --oneline -10

Expected: 56 commits with prefixes feat(call): ....


Self-Review Notes

  • Spec coverage: Each section of the spec maps to a task:
    • Spec §1 (Tile aspect ratio) → Task 3
    • Spec §2 (Object-fit per mode) → Task 1
    • Spec §3 (Click-to-pin) → Task 5 + Task 7 (doubleclick)
    • Spec §4 (Layout selection precedence) → Task 4 + Task 5 + Task 6 + Task 8
    • Spec §5 (Active-speaker preserved) → no work needed; verified in Task 9 step 1.6
  • No placeholders: Every step has concrete code or commands.
  • Type consistency: StageLayout is named the same in spec and plan. bigTileId/bigTile naming is consistent across Tasks 48. onFocusTile signature (id: string) => void matches between InCallPanel callsite and CallStage prop.
  • Reading-order safety: Each task block re-states the file paths and the exact code being replaced — Task N doesn't assume the reader memorized Task N-1.