Merge pull request #2826 from ManishMadan2882/main

(fix:widget/ui): mobile keyboard, back button
This commit is contained in:
Alex authored and GitHub committed 2026-09-25 10:36:39 +01:00
commit f0aa6ae275
5 files changed
+242 -11

No files matched your search

@@ -24,7 +24,10 @@ import {
normalizeExtensions,
useAttachments,
} from '../hooks/useAttachments';
import { useBackDismiss } from '../hooks/useBackDismiss';
import { useDictation } from '../hooks/useDictation';
import { useVisualViewportBounds } from '../hooks/useVisualViewportBounds';
import { isTouchPrimary } from '../utils/helper';
import {
AttachButton,
AttachmentChips,
@@ -304,7 +307,8 @@ const WidgetContainer = styled.div<{ $modal?: boolean }>`
@media only screen and (max-width: 768px) {
right: 0;
bottom: 0;
/* Keyboard inset; see useVisualViewportBounds. */
bottom: var(--dgpt-vv-bottom, 0px);
&.modal {
transform: none;
}
@@ -325,6 +329,9 @@ const StyledContainer = styled.div<{ $isOpen: boolean }>`
background-color: ${(props) => props.theme.primary.bg};
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* all: initial re-enables Safari's text auto-inflation. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
display: flex;
padding: 0;
overflow: hidden;
@@ -368,10 +375,13 @@ const StyledContainer = styled.div<{ $isOpen: boolean }>`
@media only screen and (max-width: 768px) {
width: 100vw;
height: 100dvh;
/* 100dvh unless a keyboard is covering part of it. */
height: var(--dgpt-vv-height, 100dvh);
max-width: 100vw;
max-height: 100dvh;
max-height: var(--dgpt-vv-height, 100dvh);
border-radius: 0;
/* Expanding is desktop-only; easing after the keyboard reads as lag. */
transition: none;
}
`;
@@ -847,7 +857,7 @@ const StatusLine = styled.div`
min-width: 0;
max-width: 100%;
font-size: 12px;
font-family: sans-serif;
font-family: inherit;
color: ${(props) => props.theme.secondary.text};
`;
const StatusDot = styled.span`
@@ -892,7 +902,7 @@ const Thought = styled.div`
padding-left: 10px;
border-left: 2px solid ${(props) => props.theme.hairline};
font-size: 12px;
font-family: sans-serif;
font-family: inherit;
font-style: italic;
line-height: 1.5;
white-space: pre-wrap;
@@ -1034,6 +1044,11 @@ const StyledTextarea = styled.textarea<{ $hidden?: boolean }>`
text-align: left;
color: ${(props) => props.theme.secondary.text};
}
/* iOS Safari zooms the page in on any field under 16px. */
@media (pointer: coarse) {
font-size: 16px;
}
`;
const StyledButton = styled.button`
display: flex;
@@ -1323,6 +1338,7 @@ export const WidgetCore = ({
const [isDraggingFiles, setIsDraggingFiles] = React.useState(false);
const abortRef = useRef<AbortController | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const conversationRef = useRef<HTMLDivElement | null>(null);
const endMessageRef = React.useRef<HTMLDivElement | null>(null);
const promptRef = React.useRef<HTMLTextAreaElement | null>(null);
@@ -1330,6 +1346,20 @@ export const WidgetCore = ({
// dragenter/dragleave fire per child crossed, hence a depth count.
const dragDepthRef = React.useRef(0);
// On a phone the composer waits to be tapped: autofocus costs half the
// display to a keyboard nobody asked for.
const [touchPrimary] = React.useState(isTouchPrimary);
// Typed as a click handler, but no close path reads the event.
const closePanel = () => {
(handleClose as ((event?: React.MouseEvent) => void) | undefined)?.();
};
// The node exists only once mounted.
useVisualViewportBounds(isOpen && mounted, containerRef);
// Touch only: nothing fills the screen on desktop.
useBackDismiss(isOpen && touchPrimary, closePanel);
// An empty list disables attachments.
const acceptedExtensions = React.useMemo(
() => normalizeExtensions(allowedFileExtensions),
@@ -1440,6 +1470,20 @@ export const WidgetCore = ({
else scrollToLatest();
}, [queries.length, queries[queries.length - 1]?.response, status]);
// Shortening the panel leaves scrollTop where it was, dropping the tail of
// the latest answer below the fold. A frame later, once the height lands.
React.useEffect(() => {
const viewport = window.visualViewport;
if (!isOpen || !viewport) return;
const repin = () =>
window.requestAnimationFrame(() => {
const el = conversationRef.current;
if (el && isPinnedToLatest) el.scrollTop = el.scrollHeight;
});
viewport.addEventListener('resize', repin);
return () => viewport.removeEventListener('resize', repin);
}, [isOpen, isPinnedToLatest]);
const setFeedbackAt = (index: number, value?: FEEDBACK) =>
setQueries((prev: Query[]) =>
prev.map((q, i) => {
@@ -1847,6 +1891,7 @@ export const WidgetCore = ({
{isOpen && size === 'large' && <Overlay onClick={handleClose} />}
{
<WidgetContainer
ref={containerRef}
className={`${size !== 'large' ? (isOpen ? 'open' : 'close') : 'modal'}`}
$modal={size === 'large'}
>
@@ -2114,7 +2159,7 @@ export const WidgetCore = ({
$hidden={isDictating}
id="chatInput"
ref={promptRef}
autoFocus
autoFocus={!touchPrimary}
onInput={handleUserInput}
value={prompt}
onChange={handlePromptChange}
@@ -4,11 +4,13 @@ import { WidgetCore } from './DocsGPTWidget';
import { DEFAULT_AVATAR } from './defaultAvatar';
import { radii, themes } from './tokens';
import { MicButton, VoiceWaveform } from './ComposerControls';
import { useBackDismiss } from '../hooks/useBackDismiss';
import { useDictation } from '../hooks/useDictation';
import { useVisualViewportBounds } from '../hooks/useVisualViewportBounds';
import { SearchBarProps } from '@/types';
import { getSearchResults } from '../requests/searchAPI';
import { Result } from '@/types';
import { getOS, processMarkdownString } from '../utils/helper';
import { getOS, isTouchPrimary, processMarkdownString } from '../utils/helper';
import DOMPurify from 'dompurify';
import {
CodeIcon,
@@ -29,6 +31,9 @@ const Main = styled.div`
all: initial;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* all: initial re-enables Safari's text auto-inflation. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
`;
const SearchButton = styled.button<{ $inputWidth: string }>`
@@ -78,7 +83,8 @@ const SearchOverlay = styled.div`
const SearchResults = styled.div`
position: fixed;
top: 50%;
/* Centred on the strip left above an open keyboard, else the viewport. */
top: calc(var(--dgpt-vv-top, 0px) + var(--dgpt-vv-height, 100%) / 2);
left: 50%;
z-index: 100;
transform: translate(-50%, -50%);
@@ -101,6 +107,9 @@ const SearchResults = styled.div`
@media only screen and (max-width: 768px) {
width: 90vw;
height: 80vh;
/* Into the strip above an open keyboard, less a gutter at each end. With
no keyboard the fallback resolves against the viewport and never bites. */
max-height: calc(var(--dgpt-vv-height, 100%) - 32px);
}
`;
@@ -336,6 +345,7 @@ export const SearchBar = ({
const [isWidgetOpen, setIsWidgetOpen] = React.useState<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLInputElement>(null);
const resultsRef = React.useRef<HTMLDivElement | null>(null);
const [isResultVisible, setIsResultVisible] = React.useState<boolean>(false);
const [results, setResults] = React.useState<Result[]>([]);
const debounceTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
@@ -358,7 +368,13 @@ export const SearchBar = ({
onEnd: focusSearchInput,
});
const browserOS = getOS();
const isTouch = 'ontouchstart' in window;
// A touchscreen laptop still has Ctrl+K; this asks if touch is the only
// way in.
const [isTouch] = React.useState(isTouchPrimary);
useVisualViewportBounds(isResultVisible, resultsRef);
// Touch only: nothing fills the screen on desktop.
useBackDismiss(isResultVisible && isTouch, () => setIsResultVisible(false));
const getKeyboardInstruction = () => {
if (isResultVisible) return 'Enter';
@@ -463,7 +479,7 @@ export const SearchBar = ({
{isResultVisible && (
<>
<SearchOverlay onClick={() => setIsResultVisible(false)} />
<SearchResults>
<SearchResults ref={resultsRef}>
<SearchHeader>
{dictation.isDictating && (
<VoiceWaveform
@@ -486,7 +502,8 @@ export const SearchBar = ({
}}
onKeyDown={(e) => handleKeyDown(e)}
placeholder={placeholder}
autoFocus
/* A keyboard would cover the results. */
autoFocus={!isTouch}
/>
{dictation.available && (
<MicButton
@@ -0,0 +1,84 @@
import React from 'react';
/** Marks our own history entry, so only an entry we pushed is popped. */
const HISTORY_MARKER = 'dgptOverlay';
const layers: Array<() => void> = [];
let entryPushed = false;
let releaseTimer: ReturnType<typeof setTimeout> | 0 = 0;
let releasing = false;
const ours = () =>
(window.history.state as Record<string, unknown> | null)?.[HISTORY_MARKER] ===
true;
const arm = () => {
if (entryPushed) return;
entryPushed = true;
if (!ours()) window.history.pushState({ [HISTORY_MARKER]: true }, '');
window.addEventListener('popstate', handlePopState);
};
const handlePopState = () => {
if (releasing) {
releasing = false;
entryPushed = ours();
if (layers.length > 0) arm();
else if (!entryPushed)
window.removeEventListener('popstate', handlePopState);
return;
}
// A host entry stacked above ours pops first, leaving ours in place.
entryPushed = ours();
const remaining = layers.length > 1;
layers[layers.length - 1]?.();
if (remaining) arm();
};
const register = (dismiss: () => void) => {
layers.push(dismiss);
if (releaseTimer) {
clearTimeout(releaseTimer);
releaseTimer = 0;
}
arm();
};
const unregister = (dismiss: () => void) => {
const index = layers.lastIndexOf(dismiss);
if (index !== -1) layers.splice(index, 1);
if (layers.length > 0 || releaseTimer) return;
// The palette hands over to the chat panel in one commit, every cleanup
// before any setup, so release only once nothing reclaims it a task later.
releaseTimer = setTimeout(() => {
releaseTimer = 0;
if (layers.length > 0) return;
entryPushed = false;
// Closing by button or overlay would otherwise leave the entry behind.
if (ours()) {
releasing = true;
window.history.back();
return;
}
window.removeEventListener('popstate', handlePopState);
});
};
/**
* Closes an open layer on the hardware back button instead of leaving the page.
* All layers share one history entry, and `onDismiss` is read through a ref.
*/
export function useBackDismiss(active: boolean, onDismiss: () => void): void {
const dismissRef = React.useRef(onDismiss);
React.useEffect(() => {
dismissRef.current = onDismiss;
});
React.useEffect(() => {
if (!active || typeof window === 'undefined' || !window.history) return;
const dismiss = () => dismissRef.current();
register(dismiss);
return () => unregister(dismiss);
}, [active]);
}
@@ -0,0 +1,76 @@
import React from 'react';
/** Below this the gap is the browser's own chrome, not a keyboard. */
const KEYBOARD_MIN_INSET_PX = 120;
/**
* Publishes the visual viewport as `--dgpt-vv-top`, `--dgpt-vv-bottom` and
* `--dgpt-vv-height` on the node while a keyboard covers it. Neither
* `position: fixed` nor `100dvh` notices one, so a full-screen panel keeps its
* height and slides its header off the display, close button and all. Written
* straight to the node, since these events fire every frame while the keyboard
* animates.
*/
export function useVisualViewportBounds(
active: boolean,
ref: React.RefObject<HTMLElement | null>,
): void {
React.useEffect(() => {
const viewport =
typeof window === 'undefined' ? null : window.visualViewport;
const node = ref.current;
if (!active || !viewport || !node) return;
let frame = 0;
let written = '';
const clear = () => {
if (!written) return;
written = '';
node.style.removeProperty('--dgpt-vv-top');
node.style.removeProperty('--dgpt-vv-height');
node.style.removeProperty('--dgpt-vv-bottom');
};
const apply = () => {
frame = 0;
// Pinch-zoom shrinks the viewport the same way but covers nothing.
if (viewport.scale > 1.01) {
clear();
return;
}
// A quirks-mode host reports its content height from `clientHeight`.
const layoutHeight = Math.min(
document.documentElement.clientHeight,
window.innerHeight,
);
const top = viewport.offsetTop;
const bottom = Math.max(0, layoutHeight - top - viewport.height);
if (top + bottom < KEYBOARD_MIN_INSET_PX) {
clear();
return;
}
const next = `${top}|${viewport.height}|${bottom}`;
if (next === written) return;
written = next;
node.style.setProperty('--dgpt-vv-top', `${top}px`);
node.style.setProperty('--dgpt-vv-height', `${viewport.height}px`);
node.style.setProperty('--dgpt-vv-bottom', `${bottom}px`);
};
const schedule = () => {
if (!frame) frame = window.requestAnimationFrame(apply);
};
apply();
viewport.addEventListener('resize', schedule);
viewport.addEventListener('scroll', schedule);
return () => {
if (frame) window.cancelAnimationFrame(frame);
viewport.removeEventListener('resize', schedule);
viewport.removeEventListener('scroll', schedule);
clear();
};
}, [active, ref]);
}
@@ -1,3 +1,12 @@
/**
* Whether touch is the only pointer: a phone or tablet, not a laptop with a
* touchscreen, which `'ontouchstart' in window` cannot tell apart.
*/
export const isTouchPrimary = (): boolean => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(hover: none) and (pointer: coarse)').matches;
};
export const getOS = () => {
const platform = window.navigator.platform;
const userAgent = window.navigator.userAgent || window.navigator.vendor;