Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
})
})

test("focused prompt stays visible after viewport shrink", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock viewport", async (session) => {
await page.setViewportSize({ width: 390, height: 844 })
await gotoSession(session.id)

const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()

await prompt.click()
await expect(prompt).toBeFocused()

await page.setViewportSize({ width: 390, height: 540 })
await expect(prompt).toBeVisible()
await expect(prompt).toBeFocused()

const viewport = page.viewportSize()
const box = await prompt.boundingBox()
expect(viewport).not.toBeNull()
expect(box).not.toBeNull()
expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual((viewport?.height ?? 0) + 1)
})
})

test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
Expand Down
72 changes: 71 additions & 1 deletion packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default function Page() {
const [ui, setUi] = createStore({
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
keyboardOffset: 0,
scroll: {
overflow: false,
bottom: true,
Expand Down Expand Up @@ -410,6 +411,12 @@ export default function Page() {
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}

const isTouch = createMemo(() => {
const coarse = globalThis.window.matchMedia("(pointer: coarse)").matches
const touch = "ontouchstart" in globalThis.window || navigator.maxTouchPoints > 0
return coarse || touch
})

const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
Expand Down Expand Up @@ -455,6 +462,39 @@ export default function Page() {
}
}

const promptFocused = () => {
const active = deepActiveElement()
if (!active) return false
if (!inputRef) return false
return active === inputRef || inputRef.contains(active)
}

const clearKeyboardOffset = () => {
if (ui.keyboardOffset === 0) return
setUi("keyboardOffset", 0)
}

const updateKeyboardOffset = () => {
if (!isTouch()) {
clearKeyboardOffset()
return
}
if (!promptFocused()) {
clearKeyboardOffset()
return
}

const viewport = window.visualViewport
if (!viewport) {
clearKeyboardOffset()
return
}

const overlap = Math.max(0, Math.round(window.innerHeight - (viewport.height + viewport.offsetTop)))
if (ui.keyboardOffset === overlap) return
setUi("keyboardOffset", overlap)
}

const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
Expand Down Expand Up @@ -1017,7 +1057,34 @@ export default function Page() {
})

onMount(() => {
let raf: number | undefined
const queueKeyboardOffset = () => {
if (raf !== undefined) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = undefined
updateKeyboardOffset()
})
}

document.addEventListener("keydown", handleKeyDown)
document.addEventListener("focusin", queueKeyboardOffset, true)
document.addEventListener("focusout", queueKeyboardOffset, true)

const viewport = window.visualViewport
viewport?.addEventListener("resize", queueKeyboardOffset)
viewport?.addEventListener("scroll", queueKeyboardOffset)
window.addEventListener("resize", queueKeyboardOffset)
queueKeyboardOffset()

onCleanup(() => {
document.removeEventListener("focusin", queueKeyboardOffset, true)
document.removeEventListener("focusout", queueKeyboardOffset, true)
viewport?.removeEventListener("resize", queueKeyboardOffset)
viewport?.removeEventListener("scroll", queueKeyboardOffset)
window.removeEventListener("resize", queueKeyboardOffset)
if (raf !== undefined) cancelAnimationFrame(raf)
clearKeyboardOffset()
})
})

onCleanup(() => {
Expand All @@ -1028,7 +1095,10 @@ export default function Page() {
})

return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<div
class="relative bg-background-base size-full overflow-hidden flex flex-col"
style={{ "--session-keyboard-offset": `${ui.keyboardOffset}px` }}
>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<SessionMobileTabs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export function SessionComposerRegion(props: {
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
class="shrink-0 w-full flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
style={{ "padding-bottom": "calc(0.75rem + var(--session-keyboard-offset, 0px))" }}
>
<div
classList={{
Expand Down
Loading