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>
31 KiB
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 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
fitprop 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
fitfrom 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(CallStagegrid branch),:1003-1025(CallStagefocus strip) -
Step 1: Rewrite
gridColsForto 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 2–4 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 5–6 → 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 whereeffectiveFocusedIdandspeakerare computed insideInCallPanel) -
Step 1: Add the
StageLayouttype 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
speakerandeffectiveFocusedId
Search InCallPanel.tsx for every remaining reference to effectiveFocusedId and speaker inside the InCallPanel function and replace as follows:
speaker(used as prop onCallStage,FullscreenCall, focused-tile detection) →bigTile.effectiveFocusedId(used inpinnedTileIdprop 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 theeffectiveSpeaker = hasFocus ? speaker : undefinedderivation withspeaker={bigTile}(and drop the now-redundanthasFocus/effectiveSpeakerlines, sincebigTileis undefined exactly when there's no focus). - In the focus branch: replace
speaker={speaker}withspeaker={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.tsx—InCallPanel's docked-call render branch (the area that calls<CallStage mode={callMode} ...>) andCallStageitself -
Step 1: Map
stageLayout→modeforCallStage
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
Modebutton 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 (2–4 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
GridandVollbild(the middleSprecherbutton 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.tsx—TileRenderprops and the focused-tile rendering paths -
Step 1: Add
onDoubleClickprop
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
onDoubleClickthroughTileRender
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.tsx—CallStage(add a bento branch) and the docked-call render to pass thestageLayoutdirectly toCallStage -
Step 1: Pass
stageLayouttoCallStage
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
CallStagebranch 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:
- Webcam-only equal grid: 4 webcams, no pin, no share → 2×2 uniform, faces cropped via cover.
- Pinning toggle: click webcam → big with contain (no head crop), strip below; click again → grid.
- Share auto-promote: start share → share is big, webcams strip, share aspect respected.
- Pin override during share: while share is big, click webcam → webcam pins big, share to strip; click webcam again → back to share auto-promote.
- Multi-share: two users share → bento stage; click one → pin that share.
- 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: 5–6 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:
StageLayoutis named the same in spec and plan.bigTileId/bigTilenaming is consistent across Tasks 4–8.onFocusTilesignature(id: string) => voidmatches 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.