From e2f86bc3771e78860252f53f0f48a3b8ab098bea Mon Sep 17 00:00:00 2001 From: byGalax Date: Mon, 18 May 2026 14:29:31 +0200 Subject: [PATCH] fix(chat): snap to bottom after send to mask composer-shrink layout shift --- apps/desktop/src/pages/ConversationPage.tsx | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index b2edac0..4a2572c 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -754,6 +754,24 @@ export function ConversationPage() { lastPendingCountRef.current = pending.length; }, [pending.length]); + // Snap the viewport back to the bottom after a send. The composer + // shrinks (cleared text, dismissed reply preview, dropped attachment + // thumbs) which lets the Virtuoso area grow vertically — leaving the + // just-sent bubble visibly above the new bottom for a frame. + // `requestAnimationFrame` defers the scroll until React has committed + // the composer-height change, so Virtuoso's ResizeObserver has + // already seen the new viewport and `index: 'LAST', align: 'end'` + // targets the correct bottom edge. + const snapToBottom = useCallback(() => { + window.requestAnimationFrame(() => { + virtuosoRef.current?.scrollToIndex({ + index: 'LAST', + align: 'end', + behavior: 'auto', + }); + }); + }, []); + async function handleSend(e?: React.FormEvent) { e?.preventDefault(); if ((!text.trim() && attachments.length === 0) || sending) return; @@ -773,6 +791,7 @@ export function ConversationPage() { setStickToBottom(true); notifyStopTyping(); if (id) clearDraft(id); + snapToBottom(); } catch (err: unknown) { const code = extractErrorCode(err); setSendError( @@ -798,13 +817,14 @@ export function ConversationPage() { setReplyTo(null); setStickToBottom(true); notifyStopTyping(); + snapToBottom(); } catch (err: unknown) { setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden'); } finally { setPollSending(false); } }, - [send, replyTo?.id, notifyStopTyping], + [send, replyTo?.id, notifyStopTyping, snapToBottom], ); const handleCreateWhiteboard = useCallback(async () => { @@ -817,12 +837,13 @@ export function ConversationPage() { setReplyTo(null); setStickToBottom(true); setOpenWhiteboardId(board.id); + snapToBottom(); } catch (err: unknown) { setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden'); } finally { setCreatingWhiteboard(false); } - }, [id, creatingWhiteboard, send, replyTo?.id]); + }, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]); const handleStartWatchTogether = useCallback(async () => { if (!id) return; @@ -842,12 +863,13 @@ export function ConversationPage() { setWatchDialogOpen(false); setWatchUrl(''); setOpenWatchSessionId(ws.id); + snapToBottom(); } catch (err: unknown) { setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten'); } finally { setWatchCreating(false); } - }, [id, watchUrl, send, replyTo?.id]); + }, [id, watchUrl, send, replyTo?.id, snapToBottom]); const handleStartGame = useCallback(async (gameType: GameType) => { if (!id) return; @@ -874,12 +896,13 @@ export function ConversationPage() { setStickToBottom(true); setGameDialogOpen(false); setOpenGameId(game.id); + snapToBottom(); } catch (err: unknown) { setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten'); } finally { setGameCreating(false); } - }, [id, conversation, myId, send, replyTo?.id]); + }, [id, conversation, myId, send, replyTo?.id, snapToBottom]); async function ingestFiles(files: File[]) { const compressed = await compressImages(files);