Files
ChatApp/docs/superpowers/plans/2026-05-12-discord-call-tile-handling.md
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

893 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../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 `focused``fit='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 }: ...)`):
```tsx
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:
```tsx
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:
```tsx
{video ? (
<VideoStub {...props} small={small} />
) : (
<AudioContent {...props} small={small} />
)}
```
with:
```tsx
{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**
```bash
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:
```tsx
<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:
```tsx
<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**
```bash
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`:
```tsx
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:
```tsx
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);` …):
```tsx
// 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:
```tsx
// 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">`):
```tsx
<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**
```bash
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**
```bash
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:
```tsx
// 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:
```tsx
// 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**
```bash
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**
```bash
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.tsx``InCallPanel`'s docked-call render branch (the area that calls `<CallStage mode={callMode} ...>`) and `CallStage` itself
- [ ] **Step 1: Map `stageLayout` → `mode` for `CallStage`**
In `InCallPanel.tsx`, locate the docked-call render that mounts `<CallStage mode={callMode} ...>`. Replace the `mode={callMode}` prop with a derived value:
```tsx
<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`):
```tsx
<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**
```bash
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**
```bash
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.tsx``TileRender` props and the focused-tile rendering paths
- [ ] **Step 1: Add `onDoubleClick` prop**
In `CallParticipantTile.tsx`, extend `ParticipantTileProps`:
```tsx
onDoubleClick?: () => void;
```
In the `CallParticipantTile` body, destructure it:
```tsx
const {
// ... existing
onDoubleClick,
} = props;
```
And add it to the root `<div>`:
```tsx
<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:
```tsx
{...(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`:
```tsx
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:
```tsx
<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:
```tsx
<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**
```bash
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**
```bash
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.tsx``CallStage` (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`:
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
{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**
```bash
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**
```bash
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.
```bash
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.