fix(chat): snap to bottom after send to mask composer-shrink layout shift

This commit is contained in:
byGalax
2026-05-18 14:29:31 +02:00
parent 92a6e01a26
commit e2f86bc377
+27 -4
View File
@@ -754,6 +754,24 @@ export function ConversationPage() {
lastPendingCountRef.current = pending.length; lastPendingCountRef.current = pending.length;
}, [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) { async function handleSend(e?: React.FormEvent) {
e?.preventDefault(); e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return; if ((!text.trim() && attachments.length === 0) || sending) return;
@@ -773,6 +791,7 @@ export function ConversationPage() {
setStickToBottom(true); setStickToBottom(true);
notifyStopTyping(); notifyStopTyping();
if (id) clearDraft(id); if (id) clearDraft(id);
snapToBottom();
} catch (err: unknown) { } catch (err: unknown) {
const code = extractErrorCode(err); const code = extractErrorCode(err);
setSendError( setSendError(
@@ -798,13 +817,14 @@ export function ConversationPage() {
setReplyTo(null); setReplyTo(null);
setStickToBottom(true); setStickToBottom(true);
notifyStopTyping(); notifyStopTyping();
snapToBottom();
} catch (err: unknown) { } catch (err: unknown) {
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden'); setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
} finally { } finally {
setPollSending(false); setPollSending(false);
} }
}, },
[send, replyTo?.id, notifyStopTyping], [send, replyTo?.id, notifyStopTyping, snapToBottom],
); );
const handleCreateWhiteboard = useCallback(async () => { const handleCreateWhiteboard = useCallback(async () => {
@@ -817,12 +837,13 @@ export function ConversationPage() {
setReplyTo(null); setReplyTo(null);
setStickToBottom(true); setStickToBottom(true);
setOpenWhiteboardId(board.id); setOpenWhiteboardId(board.id);
snapToBottom();
} catch (err: unknown) { } catch (err: unknown) {
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden'); setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
} finally { } finally {
setCreatingWhiteboard(false); setCreatingWhiteboard(false);
} }
}, [id, creatingWhiteboard, send, replyTo?.id]); }, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
const handleStartWatchTogether = useCallback(async () => { const handleStartWatchTogether = useCallback(async () => {
if (!id) return; if (!id) return;
@@ -842,12 +863,13 @@ export function ConversationPage() {
setWatchDialogOpen(false); setWatchDialogOpen(false);
setWatchUrl(''); setWatchUrl('');
setOpenWatchSessionId(ws.id); setOpenWatchSessionId(ws.id);
snapToBottom();
} catch (err: unknown) { } catch (err: unknown) {
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten'); setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
} finally { } finally {
setWatchCreating(false); setWatchCreating(false);
} }
}, [id, watchUrl, send, replyTo?.id]); }, [id, watchUrl, send, replyTo?.id, snapToBottom]);
const handleStartGame = useCallback(async (gameType: GameType) => { const handleStartGame = useCallback(async (gameType: GameType) => {
if (!id) return; if (!id) return;
@@ -874,12 +896,13 @@ export function ConversationPage() {
setStickToBottom(true); setStickToBottom(true);
setGameDialogOpen(false); setGameDialogOpen(false);
setOpenGameId(game.id); setOpenGameId(game.id);
snapToBottom();
} catch (err: unknown) { } catch (err: unknown) {
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten'); setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
} finally { } finally {
setGameCreating(false); setGameCreating(false);
} }
}, [id, conversation, myId, send, replyTo?.id]); }, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
async function ingestFiles(files: File[]) { async function ingestFiles(files: File[]) {
const compressed = await compressImages(files); const compressed = await compressImages(files);