Compare commits

...

12 Commits

Author SHA1 Message Date
byGalax 341f5f227d chore(desktop): release v0.17.0 2026-05-12 21:35:29 +02:00
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
byGalax 05870ef8fa feat(call): split resolution/fps in share picker + restore window state after fullscreen
ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p
· 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality
presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old
preset list didn't surface. The underlying screenShareSettings framerateOverride
slot already existed; the modal just stopped resetting it to null on every
start and now plumbs the chosen FPS through to startScreenShare.

Cinema-mode fullscreen on Windows had two defects:

1. Maximized → fullscreen left the taskbar drawn on top of the window
   because DWM kept the maximized work-area constraints. We now unmaximize
   first so DWM recomposes cleanly and setFullScreen actually covers the
   whole monitor including the taskbar strip.

2. Esc out of cinema came back as a small floating window even when the
   user had been maximized before clicking the Vollbild button — the
   unmaximize from (1) was never undone. We now memo the pre-fullscreen
   maximized flag per window-id and call win.maximize() once the
   leave-full-screen event has fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:10 +02:00
byGalax f9e340dbec feat(branding): Netralax rebrand + Discord-style taskbar unread badge
App productName becomes Netralax (driving exe name and window title);
existing installs keep their %APPDATA%\ChatApp profile via an explicit
app.setPath('userData', appData/ChatApp) so no Login/Sounds/Secret store
data is lost.

The Windows taskbar overlay now renders a red bubble with the actual
unread count (Discord parity) instead of just a static red dot. Renderer
paints a 64×64 PNG via canvas — full-bleed red circle, white bold count
with a "99+" cap, no outer ring — and passes the data URL through the
existing setTrayUnread IPC. Main decodes via nativeImage and applies it
as the BrowserWindow overlay icon. Falls back to the static dot if the
renderer canvas pipeline is unavailable.

Also: app.setName('Netralax') + setAppUserModelId('cloud.netralax.desktop')
for Windows taskbar grouping and notification source attribution, and
release.mjs now reads productName dynamically from package.json so the
artifact lookup stays correct after the rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:30:54 +02:00
byGalax 91eedc0e8e fix(call): aspect-video for fullscreen grid+strip, switch section sizing to stageLayout 2026-05-12 19:03:23 +02:00
byGalax e56533918e feat(call): multi-share bento layout in stage + fullscreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:54:23 +02:00
byGalax c87d4e82d3 feat(call): doubleclick on focused tile clears pin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:50:29 +02:00
byGalax 187f8dc95a feat(call): left-click toggles pin, drop manual focus mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:47:10 +02:00
byGalax f612c1bb50 feat(call): introduce StageLayout discriminated union
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:43:55 +02:00
byGalax a9d5e2d430 feat(call): uniform 16:9 grid cells, drop grid-rows constraint 2026-05-12 18:40:54 +02:00
byGalax a065cc0a2c feat(call): drop hardcoded 16:9 on screen-share preview button 2026-05-12 18:39:06 +02:00
byGalax 005aebd60b feat(call): VideoStub accepts fit prop, contain when focused 2026-05-12 18:36:32 +02:00
14 changed files with 1559 additions and 153 deletions
+18 -1
View File
@@ -37,6 +37,23 @@ const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json';
// App branding. productName in package.json drives the packaged exe name
// (Netralax.exe) and electron-builder installer title. setName + the
// AppUserModelId below cover the live process: window title fallback,
// Windows taskbar grouping, notification source attribution.
app.setName('Netralax');
if (process.platform === 'win32') {
app.setAppUserModelId('cloud.netralax.desktop');
}
// Pin userData to %APPDATA%\ChatApp regardless of productName so existing
// installs keep their profile, sounds, secrets, SQLite. Electron's default
// is %APPDATA%\<productName>, which after the Netralax rename would point
// at an empty fresh dir — same painful migration as the Tauri → Electron
// cut. Anchored to `appData` (the platform-AppData root) so productName
// changes can't drag it.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// Run dev side-by-side with the installed packaged build by isolating the
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
@@ -74,7 +91,7 @@ async function createWindow(): Promise<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({
title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)',
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
width: state.width,
height: state.height,
...(state.x !== undefined ? { x: state.x } : {}),
+19 -8
View File
@@ -55,7 +55,7 @@ function buildOverlay(): NativeImage {
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('ChatApp');
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
{
@@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void {
else mainWindow.show();
});
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `ChatApp${n} unread` : 'ChatApp');
trayRef.setToolTip(n > 0 ? `Netralax${n} ungelesen` : 'Netralax');
}
if (mainWindow.isDestroyed()) return;
if (process.platform === 'win32') {
if (n > 0) {
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
} else {
if (process.platform !== 'win32') return;
if (n <= 0) {
mainWindow.setOverlayIcon(null, '');
return;
}
// Discord-style: prefer the renderer-painted badge (red circle with
// the actual unread number). Fall back to the static red dot only if
// the renderer didn't supply one or decoding failed — keeps the
// visual indicator alive even when the canvas pipeline is unavailable.
let overlay: NativeImage | null = null;
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
if (!decoded.isEmpty()) overlay = decoded;
}
});
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
},
);
app.on('before-quit', () => {
try {
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
// Per-window maximize-before-fullscreen memo. We have to drop the
// maximized flag on Windows before setFullScreen so DWM recomposes
// cleanly (taskbar quirk), but Electron doesn't remember that the
// window WAS maximized — exiting fullscreen would leave it as a small
// floating window. Track it ourselves keyed by window-id so a future
// multi-window setup doesn't cross-pollute state.
const wasMaximized = new Map<number, boolean>();
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
// registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
win.setFullScreen(!!enabled);
const id = win.id;
if (enabled) {
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
// taskbar drawn on top of the window because DWM keeps the
// maximized work-area constraints. Drop the maximize flag first
// so setFullScreen covers the whole monitor cleanly. Remember the
// pre-fullscreen state so the exit path can restore it.
if (process.platform === 'win32') {
const was = win.isMaximized();
wasMaximized.set(id, was);
if (was) win.unmaximize();
}
win.setFullScreen(true);
} else {
win.setFullScreen(false);
// Restore maximize if we dropped it on entry. setFullScreen(false)
// emits 'leave-full-screen' asynchronously; maximize() needs to
// wait until the window is back in normal mode or it silently
// no-ops. The event fires same-tick in Electron 33, but we listen
// for it once just to be safe across versions.
if (process.platform === 'win32' && wasMaximized.get(id)) {
wasMaximized.delete(id);
const restore = (): void => {
if (!win.isDestroyed()) win.maximize();
};
if (win.isFullScreen()) {
win.once('leave-full-screen', restore);
} else {
restore();
}
}
}
} catch (err: unknown) {
console.warn('window setFullscreen failed', err);
throw err;
+1 -1
View File
@@ -58,7 +58,7 @@ export interface ElectronAPI {
notify: (args: NotifyArgs) => Promise<void>;
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
setTrayUnread: (count: number) => Promise<void>;
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
+6 -1
View File
@@ -94,7 +94,12 @@ const api = {
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray -------------------------------------------------------------------
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
// that main applies as the Windows taskbar overlay icon. We render in the
// renderer because main has no Canvas2D; passing a finished image avoids
// bundling a native canvas backend just for a 32×32 badge.
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
// Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.16.3",
"version": "0.17.0",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -54,7 +54,7 @@
},
"build": {
"appId": "com.meinname.chatapp",
"productName": "ChatApp",
"productName": "Netralax",
"directories": {
"output": "release",
"buildResources": "resources"
@@ -83,6 +83,7 @@ export interface ParticipantTileProps {
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
@@ -102,6 +103,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
size = 'default',
focused = false,
onClick,
onDoubleClick,
onContextMenu,
} = props;
@@ -120,6 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
@@ -129,7 +132,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
}
>
{video ? (
<VideoStub {...props} small={small} />
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
<AudioContent {...props} small={small} />
)}
@@ -303,7 +306,8 @@ function VideoStub({
videoTrack,
me,
small,
}: ParticipantTileProps & { small: boolean }) {
fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoRef.current;
@@ -330,7 +334,8 @@ function VideoStub({
playsInline
muted
className={
'h-full w-full object-cover ' +
'h-full w-full ' +
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
+248 -93
View File
@@ -1,7 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
if (soundboardCount === 0) setSoundboardOpen(false);
}, [soundboardCount]);
// Active-speaker auto-focus uses "who most recently started speaking"
// rather than "exactly one speaker" — matches Discord more closely and
// handles the case where two people talk briefly without the focus
// collapsing to nobody.
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const id of activeSpeakers) {
if (!prevActiveSpeakersRef.current.has(id)) {
setLastStartedSpeakerId(id);
break;
}
}
prevActiveSpeakersRef.current = new Set(activeSpeakers);
}, [activeSpeakers]);
// Single right-click dispatcher for all tiles. User-tiles open the volume
// menu; screen-tiles open the share-specific menu (volume + mute + stop
// watching). Self-tiles get no menu — no volume to control, and you can
@@ -310,12 +294,35 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
// 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];
// 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;
const controls = (
<CallControls
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
}));
if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone OR
// the person who most recently started speaking (tracked in
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
// trigger; they stay as equal-size grid tiles until the user clicks
// one. "Most recent speaker" beats "exactly one currently speaking"
// because two people briefly overlapping shouldn't kick us out of
// auto-focus.
const autoSpeaker =
focusedId === null && lastStartedSpeakerId !== null
? tiles.find(
(t) =>
t.kind === 'user' &&
!t.self &&
t.userId === lastStartedSpeakerId,
)
: undefined;
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return (
<>
{micError && (
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
)}
<FullscreenCall
tiles={tiles}
speaker={effectiveSpeaker}
speaker={bigTile}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
stageLayout.kind === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
return (
<section
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
<CallStage
tiles={tiles}
speaker={speaker}
mode={callMode}
speaker={bigTile}
stageLayout={stageLayout}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
// Discord-style toggle: clicking the already-focused tile
// collapses back to grid; clicking another tile swaps focus;
// clicking any tile in grid mode focuses it.
if (callMode === 'focus' && focusedId === id) {
setFocusedId(null);
setCallMode('grid');
return;
}
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openTileContextMenu}
compact
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
const isPinned = focusedId === volumeMenu.tileId;
setFocusedId(isPinned ? null : volumeMenu.tileId);
if (!isPinned && callMode === 'grid') setCallMode('focus');
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
}}
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
@@ -869,9 +847,6 @@ function ModeToggles({
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton
active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')}
@@ -916,7 +891,12 @@ function ModeButton({
interface StageProps {
tiles: Tile[];
speaker: Tile | undefined;
mode: CallMode;
/** 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[] };
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
@@ -941,7 +921,9 @@ function TileRender({
conversationMembers,
size,
focused,
cinema,
onClick,
onDoubleClick,
onContextMenu,
}: {
tile: Tile;
@@ -951,7 +933,12 @@ function TileRender({
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
/** True when rendered inside FullscreenCall's big-tile slot. Drives
* chrome-suppression on the inner ScreenShareViewer so its toggle
* doesn't visually collide with the cinema-mode strip-hidden button. */
cinema?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
@@ -964,6 +951,7 @@ function TileRender({
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
@@ -971,6 +959,7 @@ function TileRender({
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
hideFullscreenToggle={cinema === true}
/>
</div>
);
@@ -994,6 +983,7 @@ function TileRender({
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
@@ -1002,7 +992,7 @@ function TileRender({
function CallStage({
tiles,
speaker,
mode,
stageLayout,
activeSpeakers,
e2ee,
remoteScreenShares,
@@ -1011,7 +1001,7 @@ function CallStage({
onTileContextMenu,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
if (stageLayout.kind === 'focus' && speaker) {
const others = tiles.filter((p) => p.id !== speaker.id);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
@@ -1022,6 +1012,7 @@ function CallStage({
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onDoubleClick={() => onFocusTile(speaker.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
: {})}
@@ -1032,7 +1023,11 @@ function CallStage({
{others.map((p) => (
<div
key={p.id}
className="h-full w-[240px] shrink-0 [&>div]:h-full"
// Docked strip: thumbs grow to share the row width evenly
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
// with the share above; min-w keeps them readable when many.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1054,13 +1049,91 @@ function CallStage({
);
}
// Grid
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}
// Same docked-strip sizing as the focus branch above.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>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>
);
}
// Grid (equal-grid fallthrough)
const gridClass = gridColsFor(tiles.length);
// aspect-video on every cell pushed the row past the section height on
// wide chat panels — a single cell at full width forced height = width
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
// below. Let cells fill grid tracks normally for n>=2, and only enforce
// a 16:9 silhouette (capped width, centred) for the solo-user case.
const isSolo = tiles.length === 1;
return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}>
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{tiles.map((p) => (
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
<div
key={p.id}
className={
isSolo
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
@@ -1086,6 +1159,7 @@ function FocusedTile({
remoteScreenShares,
conversationMembers,
onContextMenu,
onDoubleClick,
}: {
tile: Tile;
e2ee: boolean;
@@ -1093,6 +1167,7 @@ function FocusedTile({
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
onContextMenu?: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div className="h-full [&>div]:h-full">
@@ -1104,6 +1179,7 @@ function FocusedTile({
conversationMembers={conversationMembers}
focused
{...(onContextMenu ? { onContextMenu } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
/>
</div>
);
@@ -1112,17 +1188,16 @@ function FocusedTile({
const GRID_PAGE_SIZE = 12;
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';
// 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';
}
// Promote self + active speakers to the front of the tile list. Stable
@@ -1216,12 +1291,25 @@ function FullscreenCall({
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
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))
: [];
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages. Reset to page 0 if the page count drops
// below the current page (someone left).
// exist, slice them into pages and prioritize active speakers onto page 1.
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
// whenever someone speaks.
const needsPagination = tiles.length > GRID_PAGE_SIZE;
const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers),
[tiles, activeSpeakers],
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
[tiles, activeSpeakers, needsPagination],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
@@ -1235,14 +1323,18 @@ function FullscreenCall({
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
{/* Content area. pb-24 reserves ~96px space at the bottom for the
floating controls bar so tiles never sit behind it. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
{/* Content area. pb-28 reserves ~112px space at the bottom for the
floating controls bar plus extra clearance so the tiles' bottom
name-chip (positioned `bottom-2` inside each tile) doesn't sit
directly underneath the controls — pb-24 was tight enough that
on wide screens with audio-only avatars the chip got eclipsed. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
{hasFocus ? (
<>
<div
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
onClick={() => onFocusTile(speaker!.id)}
onDoubleClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht"
>
<TileRender
@@ -1252,6 +1344,7 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
cinema
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
@@ -1262,7 +1355,7 @@ function FullscreenCall({
{others.map((p) => (
<div
key={p.id}
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1281,11 +1374,72 @@ function FullscreenCall({
</div>
)}
</>
) : 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>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{visibleTiles.map((p) => {
const isSolo = visibleTiles.length === 1;
return (
<div
key={p.id}
// Same fix as docked equal-grid: aspect-video w-full on
// wide screens pushed cell height to ~ width × 9/16,
// which dragged the tile's bottom name-chip down behind
// the floating controls bar. For n>=2 fill the grid
// tracks normally; for solo, cap width + centre.
className={
isSolo
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
@@ -1298,7 +1452,8 @@ function FullscreenCall({
: {})}
/>
</div>
))}
);
})}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
@@ -26,13 +26,65 @@ const TABS = [
];
type TabId = (typeof TABS)[number]['id'];
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
// Resolution and framerate are picked independently. The preset table
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
// caps, so we map (res, fps) → existing preset and rely on
// `framerateOverride` for the non-default framerate combinations
// (e.g. 1440p · 30, 4K · 30).
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
type FpsChoice = 30 | 60;
const RES_PILLS: { id: ResChoice; label: string }[] = [
{ id: 'auto', label: 'Auto' },
{ id: '720p60', label: '720p · 60' },
{ id: '1080p60', label: '1080p · 60' },
{ id: '1440p60', label: '1440p · 60' },
{ id: '720p', label: '720p' },
{ id: '1080p', label: '1080p' },
{ id: '1440p', label: '1440p' },
{ id: '4k', label: '4K' },
];
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
{ id: 30, label: '30 fps' },
{ id: 60, label: '60 fps' },
];
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
switch (res) {
case 'auto':
return 'auto';
case '720p':
return fps === 60 ? '720p60' : '720p30';
case '1080p':
return fps === 60 ? '1080p60' : '1080p30';
case '1440p':
return '1440p60';
case '4k':
return '4k60';
}
}
function decomposePreset(
p: ScreenSharePreset,
framerateOverride: number | null,
): { res: ResChoice; fps: FpsChoice } {
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
switch (p) {
case 'auto':
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
case '720p30':
return { res: '720p', fps: 30 };
case '720p60':
return { res: '720p', fps: 60 };
case '1080p30':
return { res: '1080p', fps: 30 };
case '1080p60':
return { res: '1080p', fps: 60 };
case '1440p60':
return { res: '1440p', fps: fallback };
case '4k60':
return { res: '4k', fps: fallback };
}
}
const THUMBNAIL_REFRESH_MS = 3500;
export function ScreenSharePickerModal({ onClose }: Props) {
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>(
() => getScreenShareSettings().preset,
);
const [res, setRes] = useState<ResChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).res;
});
const [fps, setFps] = useState<FpsChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).fps;
});
const [audio, setAudio] = useState<boolean>(
() => getScreenShareSettings().includeSystemAudio,
);
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
setError(null);
setBusy(true);
try {
// Persist quality + audio toggle. Also force-clear any stale duck
// setting users may have inherited from earlier builds — the
// native loopback addon excludes the app's own audio at OS level
// now, so JS-side ducking (which muted the user's incoming peer
// audio) is no longer needed and was causing "I can't hear anyone".
const preset = presetForResFps(res, fps);
// Persist resolution + fps + audio. The duck flag is force-cleared
// because the native loopback addon now excludes the app's own audio
// at OS level; JS-side ducking (which also muted incoming peer audio)
// was causing "I can't hear anyone" on earlier builds.
updateScreenShareSettings({
preset,
framerateOverride: fps,
includeSystemAudio: audio,
duckRemoteAudioWhileSharing: false,
});
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
await startScreenShare({
preset,
displaySurface: tab === 'screen' ? 'monitor' : 'window',
framerate: null,
framerate: fps,
// Forward the picked source id so the native loopback path can
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
// (parses HWND from `window:<HWND>:0`). For screen-shares this
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
Qualität
Auflösung
</span>
<div className="flex gap-1">
{QUALITY_PILLS.map((q) => {
const active = preset === q.id;
{RES_PILLS.map((q) => {
const active = res === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setPreset(q.id)}
onClick={() => setRes(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
{q.label}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
FPS
</span>
<div className="flex gap-1">
{FPS_PILLS.map((q) => {
const active = fps === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setFps(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
/** Suppress the in-share fullscreen toggle button. Used inside cinema
* mode where (a) the window is already OS-fullscreen and (b) the
* toggle collides visually with FullscreenCall's strip-hidden button
* at the same top-right corner. */
hideFullscreenToggle?: boolean;
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
share,
avatarUrl,
displayName,
hideFullscreenToggle = false,
}: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
{watching && !hideFullscreenToggle && (
<button
type="button"
onClick={toggleFullscreen}
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
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' }}
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
>
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
+53 -4
View File
@@ -1,14 +1,63 @@
import { isTauriRuntime } from './globalShortcut';
// Pushes the current aggregate unread count to main, which updates the
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
// outside the Electron runtime (e.g. browser dev preview) so no guards
// needed at call-sites.
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
// badge here in the renderer because main has no Canvas2D — painting a
// red bubble with the count, encoding to PNG, and handing the buffer
// to main keeps the implementation free of a native canvas dependency.
//
// No-op outside the Electron runtime (e.g. browser dev preview) so no
// guards needed at call-sites.
export async function updateTrayUnread(count: number): Promise<void> {
if (!isTauriRuntime()) return;
const n = Math.max(0, Math.floor(count));
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
try {
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
} catch (err: unknown) {
console.warn('updateTrayUnread failed', err);
}
}
// Discord-style red bubble with white count.
//
// Source is rendered at 64×64 so Windows' high-quality downsample to the
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
// pixel at the target size, and the bigger font fell into AA mush. Now
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
function renderBadgePng(count: number): string | null {
if (typeof document === 'undefined') return null;
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Solid red bubble, full bleed.
const center = size / 2;
ctx.beginPath();
ctx.arc(center, center, center, 0, Math.PI * 2);
ctx.fillStyle = '#ef4444';
ctx.fill();
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
// Sizes are tuned per glyph count so each variant fills the bubble
// without clipping when Windows downsamples to 16×16.
const label = count > 99 ? '99+' : String(count);
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
ctx.fillStyle = '#fff';
// Segoe UI is the Windows system font; explicit weight 800 keeps the
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// +2 vertical nudge: most system fonts render numerals visually high
// relative to the baseline mid-point; the offset re-centres them.
ctx.fillText(label, center, center + 2);
return canvas.toDataURL('image/png');
}
@@ -0,0 +1,892 @@
# 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.
@@ -0,0 +1,139 @@
# Discord-Style Call Tile Handling — Design Spec
**Date:** 2026-05-12
**Scope:** `apps/desktop/src/components/InCallPanel.tsx`, `CallParticipantTile.tsx`, `ScreenShareViewer.tsx`
**Goal:** Make tile rendering, click handling, and mixed share/webcam layouts behave like Discord.
---
## Problem
Today's in-call rendering has four user-visible defects:
1. **Webcam tiles look stretched/cropped wrong.** `VideoStub` uses `object-cover` in every size, so when the tile aspect ratio diverges from the webcam stream, faces get cropped aggressively or distorted.
2. **Screen-share tiles get the wrong aspect.** `ScreenShareViewer` uses `object-contain` (correct), but the grid cell that wraps it has no aspect-ratio constraint. Cells stretch tall/wide based on the grid template, leaving the share floating with large black bars on the sides.
3. **Click-to-pin doesn't feel like Discord.** Left-click in docked grid swaps `callMode` from grid → focus, but fullscreen-grid doesn't react; pinning is right-click-only; toggling off requires another right-click.
4. **Mixed layouts (share + webcams) treat every tile equally.** A screen share competes for space with 1:1 webcam tiles instead of dominating the stage with webcams beside it.
## Goals
- Grid renders uniform tile sizes without distorting content.
- Single left-click on any tile pins it big; click again or click another tile swaps.
- When at least one screen share is live and nothing is manually pinned, the share auto-promotes to the big stage spot.
- Multiple parallel shares share the stage in a bento layout; webcams sit as a strip.
- Active-speaker reorder stays disabled (preserves the earlier "no constant switching" fix).
## Non-Goals
- No new transitions/animations beyond what's already in place.
- No changes to context menu, volume control, screen-share picker.
- No mobile/responsive rework — desktop only.
- No migration of stored prefs (focused tile is session-only already).
---
## Design
### 1. Tile aspect ratio
Every tile (webcam **and** screen) in the **grid** renders inside an `aspect-video` (16:9) box.
- Grid container drops `grid-rows-*` and instead lets `aspect-video` on each cell drive height.
- `gridColsFor(n)` keeps the column count logic, just drops the row count constraint.
- Effect: uniform tile sizes, no stretching, content sizing is per-tile not per-row.
```tsx
// Today: grid h-full gap-2 grid-cols-3 grid-rows-2
// Tomorrow: grid h-full gap-2 grid-cols-3 (each child has aspect-video)
```
In fullscreen-cinema **focus** layouts (single big tile + thumbnail strip) the strip thumbs use `aspect-video` as well so they line up evenly.
### 2. Object-fit per mode
| Tile type | Grid (thumb) | Pinned/Focus (big) | Fullscreen strip |
|--------------|--------------|--------------------|------------------|
| Webcam | `cover` | `contain` | `cover` |
| Screen share | `contain` | `contain` | `contain` |
- `VideoStub` accepts a new `fit?: 'cover' | 'contain'` prop (default `cover`). `CallParticipantTile` passes `contain` when its `focused` prop is true.
- `ScreenShareViewer` already uses `object-contain` — no change there beyond removing the hardcoded `aspectRatio: '16/9'` on the unwatched preview button (the parent grid cell will own the ratio).
### 3. Click-to-pin
Single source of truth: `focusedId` in `CallContext`.
- **Left-click** on any tile: `setFocusedId(tile.id === focusedId ? null : tile.id)`.
- In `callMode === 'grid'` and `focusedId !== null` → also `setCallMode('focus')`.
- In `callMode === 'focus'` and `focusedId === null``setCallMode('grid')`.
- In `callMode === 'fullscreen'`: only `focusedId` flips; layout reacts inside `FullscreenCall`.
- **Doubleclick on the big/pinned tile**: clears the pin (`focusedId = null`).
- **Right-click**: unchanged — opens existing context menu (volume / pin toggle / profile).
- **Esc in fullscreen**: unchanged — exits fullscreen back to grid.
The current Stage `onClick` (`InCallPanel.tsx` around lines 578586) already does this for docked mode; we extend the same handler to `FullscreenCall`'s tile click path (`onFocusTile`).
### 4. Layout selection (precedence)
`InCallPanel` picks one of four layouts every render. Precedence top-down — first matching rule wins:
| # | Condition | Layout |
|---|-----------|--------|
| 1 | `focusedId !== null` | Single-stage focus: the pinned tile is big, all others strip. |
| 2 | `shareCount >= 2` and `focusedId === null` | Multi-share bento: all shares in sub-grid stage, webcams strip below. |
| 3 | `shareCount === 1` and `focusedId === null` | Single-stage focus auto-promote: the share is big, webcams strip. |
| 4 | `shareCount === 0` and `focusedId === null` | Equal grid (preserves the 2026-05-12 "no constant switching" fix). |
Where `shareCount = tiles.filter((t) => t.kind === 'screen').length`.
This is implemented via a derived `stageLayout` discriminated union, not a single `effectiveFocusedId`:
```ts
type StageLayout =
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
```
- Pin clearance returns control to rules 2/3/4 — Discord-style auto-fall-back.
- Clicking a share inside the bento sets `focusedId = share.id` → drops into rule 1 (single-stage focus on that share).
- Share ends → falls naturally from rule 3 → rule 4, or rule 2 → rule 3.
### 5. Active-speaker behavior (preserved)
No reorder. The emerald speaking border on `CallParticipantTile` stays. `prioritizeTiles` only runs when paginating (>12 tiles), as fixed in the 2026-05-12 patch.
---
## File-level changes
| File | Change |
|------|--------|
| `InCallPanel.tsx` | Compute `stageLayout` (equal-grid / focus / bento per the precedence table). Adjust grid CSS (drop `grid-rows-*`, add `aspect-video` per cell). Plumb `onClick` to `FullscreenCall` tiles. Render bento stage for layout `bento`. |
| `CallParticipantTile.tsx` | Forward `focused``VideoStub.fit`. Update wrapper class to expect `aspect-video` from parent grid cell. Add `onDoubleClick` to clear pin. |
| `ScreenShareViewer.tsx` | Remove hardcoded `aspectRatio: '16/9'` on the unwatched preview button (parent owns aspect now). |
No changes to `CallContext`, no changes to data model (`Tile`, `focusedId`).
---
## Risks & Mitigations
- **Aspect-video might shrink tiles when there are many participants.** Mitigation: existing `GRID_PAGE_SIZE = 12` pagination keeps cells from collapsing to thumbnail-sized; we accept that 12 tiles at 16:9 will produce small rows just like Discord does at the same density.
- **Auto-promote could be unexpected if a user explicitly cleared their pin.** Mitigation: pin clearance sets `focusedId = null`, then `firstShareId` takes over only if shares exist — exactly Discord's behavior. The user can stop watching shares to escape.
- **`aspect-video` + flex children in the existing focus-mode (`FocusCall`)**: Focus mode has its own big-tile layout (`Stage` lines ~520610). It already sets `flex h-full`; introducing `aspect-video` only on the strip thumbs is additive and won't reflow the big tile.
---
## Testing
Manual verification with two-user dev call:
1. **Webcam-only equal grid:** 4 webcams, none pinned, no shares → all tiles 16:9 equal, faces visible cropped (cover), no stretching.
2. **Pinning toggle:** click a webcam → that tile becomes big with `contain` fit (no face crop), rest strip; click again → back to grid.
3. **Auto-promote:** start a screen share → share is big, webcams strip, share aspect respected (no stretch).
4. **Manual override during share:** while share is big, click a webcam → webcam pins big, share moves to strip. Click webcam again → falls back to share-auto-promote.
5. **Multi-share:** two users share → bento stage, both shares visible at equal size, webcams below.
6. **Active speaker:** someone talks → emerald border, no tile reorder/swap.
No automated tests (Storybook setup not in place for in-call layouts).
+6 -1
View File
@@ -89,7 +89,12 @@ execSync('pnpm --filter @chat-app/desktop run build:win', {
// --- Locate artifacts -----------------------------------------------------
const releaseDir = join(ROOT, 'apps/desktop/release');
const exeName = `ChatApp Setup ${versionArg}.exe`;
// productName lives in the electron-builder block of the desktop package.
// Read it back from disk (post-bump) so installer filenames stay in sync
// after a rebrand (e.g. ChatApp → Netralax) without manual script edits.
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const productName = pkgJson?.build?.productName ?? 'ChatApp';
const exeName = `${productName} Setup ${versionArg}.exe`;
const blockmapName = `${exeName}.blockmap`;
const latestYml = 'latest.yml';