fix(dashboard): translate chat thread to follow iOS visual viewport offset

After sending on mobile the iOS Safari focus retention shifts
visualViewport.offsetTop, leaving the composer near the top of the
screen. Translate the chat thread by that offset so it stays pinned
above the keyboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 07:53:46 -07:00
parent 1c5f60d3b6
commit ab0a5e8f58
3 changed files with 64 additions and 5 deletions

View File

@@ -761,7 +761,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const mentionCursorPosRef = useRef(0);
const mode = useViewportMode();
const isMobile = mode === "mobile";
const { keyboardOverlap, viewportHeight, keyboardOpen } = useMobileKeyboard({
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: isMobile && !!activeSession,
});
@@ -769,6 +769,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
@@ -815,9 +816,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
};
}, []);
// Scroll to bottom on new messages or streaming
// Scroll thread container to bottom on new messages or streaming.
// Avoid Element.scrollIntoView() here because on mobile Safari it can
// scroll the page viewport instead of only the chat thread.
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
const messagesContainer = messagesContainerRef.current;
if (!messagesContainer) return;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}, [messages, streamingText]);
useEffect(() => {
@@ -1001,7 +1006,18 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
if (window.innerWidth > 768) return;
const input = inputRef.current;
if (!input || input.disabled) return;
const previousScrollX = window.scrollX;
const previousScrollY = window.scrollY;
input.focus({ preventScroll: true });
// iOS can still jump layout viewport on focus changes even with preventScroll.
// Restore scroll position on the next frame to keep the thread anchored.
window.requestAnimationFrame(() => {
if (window.scrollX !== previousScrollX || window.scrollY !== previousScrollY) {
window.scrollTo(previousScrollX, previousScrollY);
}
});
}, []);
const markPreserveComposerFocus = useCallback(() => {
@@ -1781,7 +1797,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
void handleSend();
window.setTimeout(() => {
preserveComposerFocusRef.current = false;
}, 300);
}, 1500);
}
}}
onTouchStart={(event) => {
@@ -1793,7 +1809,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
void handleSend();
window.setTimeout(() => {
preserveComposerFocusRef.current = false;
}, 300);
}, 1500);
}}
onMouseDown={(event) => {
if (typeof window === "undefined" || window.innerWidth > 768) return;