From 5df1de31bd9a787724587a4db40ab66fc88087b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 4 Oct 2026 17:43:31 +0200 Subject: [PATCH 1/2] fix(chat): keep mobile interruption requests reachable above the composer Prioritize expanded questions and permissions over a manually enlarged composer. Temporarily use the minimum field height without changing the draft or saved size, restore normal resizing on collapse or settlement, and prevent the dock from shrinking into an unusable header. Bound the request/composer stack to the visual viewport during Android keyboard resize and pan. Retain shell ownership, request drafts, background/global requests and session navigation. Keep disabled resize controls out of keyboard traversal. Add real InstanceShell browser regressions for desktop and Android touch layouts, portrait, landscape, short viewports, immersive mode, maximum saved composer size, keyboard geometry, long forms and permission actions. All 52 targeted browser checks, UI TypeScript and the production UI build pass. The reported screenshot separately came from an installed pre-836 bundle; this follow-up fixes the independently reproduced current-code height regression. --- AGENTS.md | 1 + dev-docs/NATIVE_INTERRUPTION_UX.md | 13 + .../components/instance/instance-shell2.tsx | 8 +- .../instance/shell/useInterruptionViewport.ts | 38 +++ .../ui/src/components/interruption-dock.tsx | 4 +- packages/ui/src/components/prompt-input.tsx | 18 +- .../ui/src/components/prompt-input/types.ts | 2 + .../src/components/session/session-view.tsx | 2 + .../styles/components/interruption-dock.css | 6 +- .../ui/src/styles/messaging/prompt-input.css | 2 + .../tests/browser/fixtures/header-windows.tsx | 36 ++- .../tests/browser/mobile-interruption.test.ts | 247 ++++++++++++++++++ 12 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/instance/shell/useInterruptionViewport.ts create mode 100644 packages/ui/tests/browser/mobile-interruption.test.ts diff --git a/AGENTS.md b/AGENTS.md index a62df47e8..cf86c1786 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ - The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session. - Native questions and permissions are answered in the shell-owned `InterruptionDock`, slotted above the composer. Pending questions appear only in the dock; the transcript retains native completed question results. Badges target the dock via `interruptionFocus`; omit transcript-source and answer-in-dock links. Keep selection stable by request kind/id across queue refreshes, with bounded previous/next navigation hidden for a single request. Preserve request drafts across refreshes and session navigation. Keep its square, height-bounded chrome in `styles/components/interruption-dock.css`, with a token-based accent header and fixed action footer outside the scrolling request content. See `dev-docs/NATIVE_INTERRUPTION_UX.md`. - Durable permission decisions render through `components/permission-receipts.tsx` outside native tool visibility, with bounded mounted-row reads and a paginated session disclosure for unanchored receipts. Keep receipts out of native message/copy/search/speech projections; styles live in `styles/messaging/permission-receipts.css`. +- An expanded interruption temporarily reduces the composer to its minimum without changing its saved height or draft; resizing resumes after collapse/settlement. `instance/shell/useInterruptionViewport.ts` bounds the request/composer stack to the visual viewport during keyboard resize/pan. Keep the dock non-shrinking and its footer reachable on short mobile layouts. - Completed question receipts share native answer decoding with text projection through `tool-call/renderers/question-data.ts`. Preserve selected-option descriptions and verbatim free text; keep remaining choices in a native disclosure. Receipt styles live in `styles/messaging/tool-call/question.css`. - Pending-request recovery uses the authenticated broker in `packages/server/src/server/routes/pending-requests.ts` when the daemon supports loaded-only snapshots. Validate directory ownership before querying and every returned placement before publishing; retain execution-host authority even for cold/empty coverage, including WSL and path aliases. Preserve idle/global Forms, per-kind mutation fences and settled-request tombstones. Errors or incomplete coverage never clear queues. The capability is negotiated per connection, not inferred from a version label; older daemons retain their existing discovery behavior and do not gain the native performance fix merely by updating CodeNomad. - OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`. diff --git a/dev-docs/NATIVE_INTERRUPTION_UX.md b/dev-docs/NATIVE_INTERRUPTION_UX.md index 814c639e7..3181510e2 100644 --- a/dev-docs/NATIVE_INTERRUPTION_UX.md +++ b/dev-docs/NATIVE_INTERRUPTION_UX.md @@ -9,6 +9,13 @@ independently; reply, cancel and permission actions remain in a fixed footer outside that scrolling content. Its square shared window chrome uses the existing accent/surface tokens for the header, icon and leading border. +An expanded request takes priority over a manually enlarged composer. The composer +temporarily uses its minimum field height and disables resizing without overwriting +the saved height or draft; collapse or settlement restores the preference. The dock +does not flex-shrink into an unusable header. The shell bounds the conversation to +the visual viewport while a request is expanded, including Android keyboard resize +and pan. Request fields scroll within the dock while its actions remain reachable. + ## Ownership and navigation - Native pending queues remain authoritative. No transcript scan decides whether @@ -97,3 +104,9 @@ pagination, SSE refresh, stale reads and long receipts in light/dark mobile layo `node scripts/test-permission-receipts-native.mjs` runs isolated native recovery against project/worktree roots and descendant Locations, with external replies and disk reload. It never uses a shared daemon or user database. + +`tests/browser/mobile-interruption.test.ts` exercises the real instance shell with +Android touch emulation, session navigation, saved maximum composer height, +portrait/landscape/short layouts, simulated keyboard resize/pan, global/background +Forms and permission actions. Width-only component captures do not replace this +shell-level geometry regression. diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 052e16cfa..0c0e9ee83 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -83,6 +83,7 @@ import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" import { isPermissionAutoAcceptEnabled } from "../../stores/permission-auto-accept" import { readClientLayoutValue, writeClientLayoutValue } from "../../stores/client-state" import { runtimeEnv } from "../../lib/runtime-env" +import { useInterruptionViewport } from "./shell/useInterruptionViewport" const log = getLogger("session") const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" @@ -165,8 +166,10 @@ const InstanceShell2: Component = (props) => { }) // Shell ownership keeps partially answered requests alive as session panes change. + const [interruptionExpanded, setInterruptionExpanded] = createSignal(false) + const interruptionViewport = useInterruptionViewport(() => sessionCenterEl() ?? undefined, interruptionExpanded) const interruptionPanel = + sessionId={activeSessionIdForInstance()} active={props.isActiveInstance} onExpandedChange={setInterruptionExpanded} /> const desktopQuery = useMediaQuery("(min-width: 1280px)") @@ -1048,6 +1051,7 @@ const InstanceShell2: Component = (props) => { = (props) => { {interruptionPanel} = (props) => { , expanded: Accessor) { + const [bottomSpace, setBottomSpace] = createSignal() + let schedule = () => {} + createEffect(() => { if (expanded()) schedule() }) + onMount(() => { + const element = container() + if (!element) return + const viewport = window.visualViewport + let frame = 0 + const measure = () => { + if (!expanded()) return + const bottom = (viewport?.offsetTop ?? 0) + (viewport?.height ?? window.innerHeight) + setBottomSpace(Math.max(0, bottom - element.getBoundingClientRect().top)) + } + schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure) } + const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(schedule) + observer?.observe(element) + viewport?.addEventListener("resize", schedule) + viewport?.addEventListener("scroll", schedule) + window.addEventListener("resize", schedule) + measure() + onCleanup(() => { + cancelAnimationFrame(frame) + observer?.disconnect() + viewport?.removeEventListener("resize", schedule) + viewport?.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + }) + }) + return (): JSX.CSSProperties | undefined => expanded() && bottomSpace() !== undefined + ? { "max-height": `${bottomSpace()}px` } : undefined +} diff --git a/packages/ui/src/components/interruption-dock.tsx b/packages/ui/src/components/interruption-dock.tsx index 2b76a90cf..0ecbc6e72 100644 --- a/packages/ui/src/components/interruption-dock.tsx +++ b/packages/ui/src/components/interruption-dock.tsx @@ -8,7 +8,7 @@ import { interruptionFocus } from "../stores/interruption-navigation" import FormRequest from "./form-request" import { InterruptionPermission } from "./interruption-permission" -export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean }) { +export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean; onExpandedChange?: (expanded: boolean) => void }) { const { t } = useI18n() const [selected, setSelected] = createSignal() const [collapsed, setCollapsed] = createSignal(false) @@ -18,6 +18,8 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string ...getFormQueue(props.instanceId).map(payload => ({ key: `form:${payload.id}`, kind: "form" as const, payload })), ]) const byKey = createMemo(() => new Map(queue().map(item => [item.key, item]))) + createEffect(() => props.onExpandedChange?.(queue().length > 0 && !collapsed())) + onCleanup(() => props.onExpandedChange?.(false)) const current = createMemo(() => byKey().get(selected() ?? "") ?? queue().find(item => item.payload.sessionID === props.sessionId) ?? queue()[0]) const index = () => queue().findIndex(item => item.key === current()?.key) const title = (sessionId: string) => sessions().get(props.instanceId)?.get(sessionId)?.title || sessionId diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 0a1627b17..11b037718 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -127,11 +127,12 @@ export default function PromptInput(props: PromptInputProps) { }) createEffect(() => { const saved = inputHeight() - if (typeof saved === "number" && viewport().height > 0) { + if (!props.interruptionExpanded && typeof saved === "number" && viewport().height > 0) { persistPromptInputHeight(heightPreference(saved)) } }) const effectiveInputHeight = () => { + if (props.interruptionExpanded) return minimumFieldHeight() const saved = inputHeight() const desired = saved === null ? defaultFieldHeight() : typeof saved === "number" ? saved @@ -407,6 +408,7 @@ export default function PromptInput(props: PromptInputProps) { function handleResizeStart(event: PointerEvent) { event.preventDefault() + if (props.interruptionExpanded) return const target = event.currentTarget as HTMLElement resizeDragState = { @@ -427,6 +429,7 @@ export default function PromptInput(props: PromptInputProps) { } function handleResizeMove(event: PointerEvent) { + if (props.interruptionExpanded) return if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return event.preventDefault() @@ -449,6 +452,10 @@ export default function PromptInput(props: PromptInputProps) { } function handleResizeKeyDown(event: KeyboardEvent) { + if (props.interruptionExpanded) { + if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) event.preventDefault() + return + } const currentHeight = effectiveInputHeight() ?? fieldContainerRef?.getBoundingClientRect().height ?? defaultFieldHeight() const minimum = minimumFieldHeight() const current = Math.max(minimum, currentHeight) @@ -470,6 +477,11 @@ export default function PromptInput(props: PromptInputProps) { onCleanup(() => { resizeDragState = undefined }) + createEffect(() => { + if (!props.interruptionExpanded) return + resizeDragState = undefined + setIsResizing(false) + }) const promptDelivery = (alternate = false) => resolvePromptDelivery( Boolean(props.isSessionBusy || submissionsInFlight > 0), @@ -624,6 +636,7 @@ export default function PromptInput(props: PromptInputProps) { function handleResizeMaximize(event: MouseEvent) { event.preventDefault() + if (props.interruptionExpanded) return persistPromptInputHeight(heightPreference(computeMaxFieldHeight())) textareaRef?.focus() } @@ -897,7 +910,8 @@ export default function PromptInput(props: PromptInputProps) { onPointerCancel={handleResizeEnd} onDblClick={handleResizeMaximize} onKeyDown={handleResizeKeyDown} - tabIndex={0} + tabIndex={props.interruptionExpanded ? -1 : 0} + aria-disabled={props.interruptionExpanded || undefined} role="separator" aria-orientation="horizontal" aria-valuemin={Math.round(minimumFieldHeight())} diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index d0ad1a11d..96bca0dd5 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -25,6 +25,8 @@ export interface PromptInputProps { // Used to scope global "type-to-focus" behavior. isActive?: boolean + // Expanded shell-owned requests temporarily take priority over saved composer height. + interruptionExpanded?: boolean // Phone/tablet layouts should keep the expanded prompt more compact. onSend: (prompt: string, attachments: Attachment[], delivery: PromptDelivery, restoredPayload?: SessionInboxUserPayload) => Promise diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index e6225abc6..63106be2a 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -40,6 +40,7 @@ function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text interface SessionViewProps { interruptionPanel?: JSXElement + interruptionExpanded?: boolean sessionId: string activeSessions: Map instanceId: string @@ -676,6 +677,7 @@ export const SessionView: Component = (props) => { {props.interruptionPanel} (sseManager as any).handleEvent(id, { + id: `fixture-event-${++eventTime}`, type, created: eventTime, location: { directory: "/repo" }, data, +}) const client: any = { - session: { list: async () => ({ data: [session], cursor: {} }), active: async () => ({}), get: async () => session, inbox: { list: async () => ({ data: [] }) }, interrupt: async () => { interrupts++ } }, + session: { list: async () => ({ data: [...fixtureSessions.values()], cursor: {} }), active: async () => ({}), + get: async ({ sessionID }: any) => fixtureSessions.get(sessionID), inbox: { list: async () => ({ data: [] }) }, interrupt: async () => { interrupts++ }, + form: { reply: async (input: any) => { replies.push(input); emit("form.replied", { sessionID: input.sessionID, id: input.formID, answer: input.answer }) } }, + }, + form: { list: async () => ({ data: getFormQueue(id) }) }, + permission: { reply: async (input: any) => { replies.push(input) } }, message: { list: async () => ({ data: [{ id: "hello", type: "user", text: "Fixture message", time: { created: 1 } }], cursor: {} }) }, model: { default: async () => ({ data: { providerID: "fixture", id: "fixture" } }) }, file: { status: async () => ({ data: [] }) }, @@ -47,6 +61,8 @@ let executions = 0 const escapeStates: boolean[] = [] const [menuInstance, setMenuInstance] = createSignal(id) let viewAction: (action: string) => boolean +const [immersive, setImmersive] = createSignal(false) +const [active, setActive] = createSignal(true) function Fixture() { viewAction = useViewMenu(menuInstance) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) @@ -61,11 +77,11 @@ function Fixture() { return (
- [{ id: "fixture", label: "Fixture command", description: "Execute the fixture", category: "System", action: () => {} }]} onExecuteCommand={() => { executions++ }} onCloseSession={() => {}} onNewSession={() => {}} handleSidebarAgentChange={async () => {}} handleSidebarModelChange={async () => {}} tabBarOffset={0} - mobileFullscreenMode={false} onEnterMobileFullscreen={() => {}} onExitMobileFullscreen={() => {}} /> + mobileFullscreenMode={immersive()} onEnterMobileFullscreen={() => setImmersive(true)} onExitMobileFullscreen={() => setImmersive(false)} />
) } @@ -76,6 +92,20 @@ bootStage("preferences-before") await updatePreferences({ locale: "en" }) bootStage("preferences-after") ;(window as any).fixture = { + runtimeEnv, replies, setImmersive, setActive, + addSession: (sid: string) => { + fixtureSessions.set(sid, { ...session, id: sid, title: `Fixture ${sid}` }) + setSessions(previous => new Map(previous).set(id, new Map(fixtureSessions))) + setSessionPage(id, [...fixtureSessions.keys()], false, true) + }, + selectSession: (sid: string) => { setActiveParentSession(id, sid); setActiveSession(id, sid) }, + nativeQuestion: (sid = sessionId, formId = `question-${sid}`, long = false) => emit("form.created", { + sessionID: sid, form: { id: formId, sessionID: sid, location: { directory: "/repo" }, title: "Questions", metadata: { kind: "question" }, + fields: [{ key: "q0", type: "string", title: long ? "Choose the deployment approach for the mobile browser release" : "Approach", + description: long ? "Explain the deployment approach and the acceptance checks for the mobile browser release. ".repeat(25) : `Which approach for ${sid}?`, required: true }], + state: { status: "pending" } }, + }), + pendingForms: () => getFormQueue(id).map(form => form.id), askQuestion: () => addPendingForm(id, { id: "dock-question", sessionID: sessionId, title: "Questions", metadata: { kind: "question" }, fields: [{ key: "q0", type: "string", title: "Approach", description: "Which approach?", required: true }], diff --git a/packages/ui/tests/browser/mobile-interruption.test.ts b/packages/ui/tests/browser/mobile-interruption.test.ts new file mode 100644 index 000000000..61fe06094 --- /dev/null +++ b/packages/ui/tests/browser/mobile-interruption.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict" +import { after, before, test } from "node:test" +import { fileURLToPath } from "node:url" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { chromium, devices, type Browser, type Locator, type Page } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" + +let server: ViteDevServer, browser: Browser, url: string, cacheDir: string +before(async () => { + cacheDir = await mkdtemp(join(process.env.CODENOMAD_TEST_TEMP || tmpdir(), "codenomad-mobile-interruption-")) + server = await createServer({ cacheDir, configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", + plugins: [solid(), { name: "mobile-interruption-fixture", configureServer(s) { + s.middlewares.use("/fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await s.transformIndexHtml("/fixture", '
')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null }, + }) + await server.listen() + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) +}) +after(async () => { await browser?.close(); await server?.close(); if (cacheDir) await rm(cacheDir, { recursive: true, force: true }) }) + +async function dockSnapshot(page: Page) { + return page.evaluate(() => ({ + forms: (window as any).fixture?.pendingForms(), + panes: [...document.querySelectorAll(".session-cache-pane")].map(el => ({ + session: el.getAttribute("data-session-id"), active: el.getAttribute("data-session-active"), + })), + docks: [...document.querySelectorAll(".interruption-dock")].map(el => { + const rect = el.getBoundingClientRect() + return { parent: el.parentElement?.className, session: el.closest("[data-session-id]")?.getAttribute("data-session-id"), + width: rect.width, height: rect.height, top: rect.top, bottom: rect.bottom, + children: [...el.querySelectorAll(".window-header, .interruption-body, .form-request-fields, .form-request-actions, input")].map(child => { + const r = child.getBoundingClientRect() + return { className: child.className, top: r.top, height: r.height, bottom: r.bottom } + }) } + }), + })) +} + +for (const size of ["portrait", "short", "keyboard"] as const) { + test(`Android ${size} keeps long background/global forms and permission actions reachable above a saved maximum composer`, async () => { + const page = await browser.newPage({ ...devices["Pixel 5"], viewport: { width: 393, height: size === "short" ? 393 : 851 } }) + page.setDefaultTimeout(15000) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + if (size === "keyboard") await page.addInitScript(`(() => { + let height = innerHeight, top = 0 + Object.defineProperties(window.visualViewport, { height: { get: () => height }, offsetTop: { get: () => top } }) + window.keyboardViewport = (nextHeight, nextTop = 0) => { + height = nextHeight; top = nextTop + visualViewport.dispatchEvent(new Event("resize")) + visualViewport.dispatchEvent(new Event("scroll")) + } + })()`) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url, { timeout: 45000 }) + await page.waitForFunction(() => Boolean((window as any).fixture), undefined, { timeout: 45000 }) + const composer = page.locator("textarea.prompt-input:visible"), resize = page.locator(".prompt-resize-handle") + await composer.fill("Background request must preserve this draft\n".repeat(15)) + const draft = await composer.inputValue() + await resize.focus() + await page.keyboard.press("End") + const originalHeight = await composer.evaluate(el => el.getBoundingClientRect().height) + const saved = await page.evaluate(() => (window as any).fixture.promptHeight()) + await page.evaluate(() => { + const f = (window as any).fixture + f.addSession("background") + f.nativeQuestion("background", "background-form", true) + }) + if (size === "keyboard") { + await page.evaluate(() => (window as any).keyboardViewport(393)) + await page.waitForFunction(() => document.querySelector(".content-area")!.getBoundingClientRect().bottom <= 393) + assert.equal(await page.evaluate(() => innerHeight), 851) + } + for (const request of ["background", "global"]) { + if (request === "global") await page.evaluate(() => (window as any).fixture.nativeQuestion("global", "global-form", true)) + const fields = page.locator(".interruption-dock .form-request-fields") + assert.equal(await fields.evaluate(el => el.scrollHeight > el.clientHeight), true) + await assertFooter(page) + const answer = page.locator('.interruption-dock input[type="text"]:visible') + await answer.fill(`Answer ${request}`) + await assertReachable(answer) + await assertFooter(page) + assert.equal(await composer.inputValue(), draft) + assert.equal(await composer.isDisabled(), false, "background/global requests still compact the unrelated active composer") + assert.equal(await resize.getAttribute("aria-disabled"), "true") + if (size === "keyboard") { + for (const immersive of [true, false]) { + await page.evaluate(immersive => (window as any).fixture.setImmersive(immersive), immersive) + await assertFooter(page) + } + await page.evaluate(() => (window as any).keyboardViewport(393, 60)) + await page.waitForFunction(() => Math.abs(document.querySelector(".content-area")!.getBoundingClientRect().bottom - 453) < 1) + await assertReachable(answer) + await assertFooter(page) + await page.evaluate(() => (window as any).keyboardViewport(393)) + } + await page.getByRole("button", { name: "Submit", exact: true }).click() + await page.locator(".interruption-dock").waitFor({ state: "detached" }) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + } + await page.evaluate(() => (window as any).fixture.queuePermission()) + await page.locator(".interruption-dock .tool-call-permission").waitFor() + await assertFooter(page, ".tool-call-permission-buttons") + const rejectReason = page.locator(".interruption-dock textarea") + await rejectReason.fill("Not this command") + await assertReachable(rejectReason) + await assertFooter(page, ".tool-call-permission-buttons") + if (process.env.CODENOMAD_MOBILE_CAPTURE) { + await mkdir(process.env.CODENOMAD_MOBILE_CAPTURE, { recursive: true }) + await page.screenshot({ path: join(process.env.CODENOMAD_MOBILE_CAPTURE, `interruption-${size}-permission.png`), scale: "css" }) + } + await page.getByRole("button", { name: "Deny", exact: true }).click() + await page.locator(".interruption-dock").waitFor({ state: "detached" }) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + if (size === "keyboard") await page.evaluate(() => (window as any).keyboardViewport(851)) + await page.waitForFunction(height => document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height === height, originalHeight) + assert.equal(await composer.inputValue(), draft) + assert.deepEqual(errors, []) + } finally { await page.close() } + }) +} + +async function assertReachable(control: Locator) { + await control.scrollIntoViewIfNeeded() + assert.equal(await control.evaluate(el => { + const r = el.getBoundingClientRect() + return r.height > 0 && r.width > 0 && el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) + }), true, "the actual control must receive pointer hit testing") +} + +async function assertFooter(page: Page, selector = ".form-request-actions") { + const metrics = await page.locator(`.interruption-dock ${selector}`).evaluate(el => { + const r = el.getBoundingClientRect(), dock = el.closest(".interruption-dock")!.getBoundingClientRect() + const bottom = (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? innerHeight) + return { bounded: r.top >= dock.top && r.bottom <= dock.bottom && r.bottom <= bottom, + controls: [...el.querySelectorAll("button")].every(button => { + const b = button.getBoundingClientRect() + return b.top >= r.top && b.bottom <= r.bottom && button.contains(document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2)) + }) } + }) + assert.deepEqual(metrics, { bounded: true, controls: true }) +} + +for (const mode of ["desktop", "mobile", "immersive", "landscape"] as const) { + test(`real shell pending question survives two-session navigation in ${mode}`, async (t) => { + const mobile = mode !== "desktop" + const page = await browser.newPage({ ...(mobile ? devices["Pixel 5"] : {}), + viewport: mode === "desktop" ? { width: 1100, height: 800 } : mode === "landscape" ? { width: 851, height: 393 } : { width: 393, height: 851 }, + }) + page.setDefaultTimeout(10000) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url, { timeout: 45000 }) + await page.waitForFunction(() => Boolean((window as any).fixture), undefined, { timeout: 45000 }) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.runtimeEnv), { + host: "web", platform: mobile ? "mobile" : "desktop", windowContext: "remote", + }) + assert.equal(await page.evaluate(() => matchMedia("(pointer: coarse)").matches), mobile) + await page.evaluate(immersive => { + const f = (window as any).fixture + f.addSession("second") + f.setImmersive(immersive) + }, mode === "immersive") + const composer = page.locator("textarea.prompt-input:visible") + await composer.fill("Preserve first session draft") + const resize = page.locator(".prompt-resize-handle") + await resize.focus() + await page.keyboard.press("End") + const saved = await page.evaluate(() => (window as any).fixture.promptHeight()) + assert.deepEqual(saved, { ratio: 0.6 }) + const originalHeight = await composer.evaluate(el => el.getBoundingClientRect().height) + for (const sid of ["second", "session"]) { + await page.evaluate(sid => (window as any).fixture.selectSession(sid), sid) + await page.locator(`.session-cache-pane[data-session-id="${sid}"][data-session-active="true"] textarea`).waitFor() + } + await page.evaluate(() => (window as any).fixture.nativeQuestion()) + const answer = page.locator('.interruption-dock input[type="text"]:visible') + await answer.fill("Preserve my answer") + assert.equal(await resize.getAttribute("aria-disabled"), "true") + assert.ok(await composer.evaluate(el => el.getBoundingClientRect().height) < originalHeight) + for (const sid of ["second", "session", "second", "session"]) { + await page.evaluate(sid => (window as any).fixture.selectSession(sid), sid) + await page.locator(`.session-cache-pane[data-session-id="${sid}"][data-session-active="true"] textarea`).waitFor() + assert.equal(await page.locator(".interruption-dock:visible").count(), 1, JSON.stringify(await dockSnapshot(page))) + assert.equal(await answer.inputValue(), "Preserve my answer") + } + if (mode !== "immersive") { + await page.getByRole("button", { name: "Collapse requests", exact: true }).click() + assert.equal(await composer.evaluate(el => el.getBoundingClientRect().height), originalHeight) + await page.locator(".session-header-indicators .permission-center-trigger:visible").click() + await answer.waitFor() + } + assert.equal(await composer.inputValue(), "Preserve first session draft") + const bounds = await page.locator(".interruption-dock").boundingBox() + assert.ok(bounds && bounds.height > 60 && bounds.y >= 0 && bounds.y + bounds.height <= page.viewportSize()!.height, JSON.stringify(await dockSnapshot(page))) + await page.evaluate(() => (window as any).fixture.setActive(false)) + await page.locator(".session-cache-pane").waitFor({ state: "detached" }) + await page.evaluate(() => (window as any).fixture.setActive(true)) + await answer.waitFor() + assert.equal(await answer.inputValue(), "Preserve my answer") + await resize.focus() + await page.keyboard.press("Home") + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + await page.keyboard.press("End") + await resize.dispatchEvent("dblclick") + await resize.dispatchEvent("pointerdown", { pointerId: 1, clientY: 100 }) + await resize.dispatchEvent("pointermove", { pointerId: 1, clientY: 200 }) + await resize.dispatchEvent("pointerup", { pointerId: 1, clientY: 200 }) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + await page.keyboard.press("Tab") + assert.equal(await resize.evaluate(el => el === document.activeElement), false, "an interrupted resize must not trap keyboard focus") + await assertReachable(answer) + await assertFooter(page) + if (process.env.CODENOMAD_MOBILE_CAPTURE) { + await mkdir(process.env.CODENOMAD_MOBILE_CAPTURE, { recursive: true }) + await page.screenshot({ path: join(process.env.CODENOMAD_MOBILE_CAPTURE, `interruption-${mode}-expanded.png`), scale: "css" }) + } + await page.getByRole("button", { name: "Submit", exact: true }).click() + await page.waitForFunction(() => (window as any).fixture.pendingForms().length === 0) + assert.equal(await page.evaluate(() => (window as any).fixture.replies[0].answer.q0), "Preserve my answer") + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + assert.equal(await composer.evaluate(el => el.getBoundingClientRect().height), originalHeight) + assert.deepEqual(errors, []) + } catch (error) { + t.diagnostic(JSON.stringify(await dockSnapshot(page))) + t.diagnostic(JSON.stringify({ errors })) + throw error + } finally { + if (process.env.CODENOMAD_MOBILE_CAPTURE) { + await mkdir(process.env.CODENOMAD_MOBILE_CAPTURE, { recursive: true }) + await page.screenshot({ path: join(process.env.CODENOMAD_MOBILE_CAPTURE, `interruption-${mode}.png`) }) + } + await page.close() + } + }) +} From a9d6332ddf118391f99d909550ff84218c9a8441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 4 Oct 2026 18:14:53 +0200 Subject: [PATCH 2/2] fix(chat): retain usable request controls at extreme keyboard heights Address the gatekeeper's 220px visual-viewport reproduction. Measure the active dock header, footer, input and padding to reserve a usable minimum. When the whole stack cannot fit, allow session-level scrolling and field scroll chaining instead of clipping reply actions. Exercise 260/220/180px keyboard heights in portrait and landscape with real pointer/wheel and native Chromium touch gestures. Inputs, submit and permission actions remain hit-testable and functional; all 13 mobile and 44 existing header/composer/interruption browser tests and UI TypeScript pass. --- dev-docs/NATIVE_INTERRUPTION_UX.md | 3 + .../components/interruption-dock-layout.ts | 36 +++++ .../ui/src/components/interruption-dock.tsx | 5 +- .../styles/components/interruption-dock.css | 12 +- .../tests/browser/mobile-interruption.test.ts | 123 ++++++++++++++++++ 5 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/interruption-dock-layout.ts diff --git a/dev-docs/NATIVE_INTERRUPTION_UX.md b/dev-docs/NATIVE_INTERRUPTION_UX.md index 3181510e2..3f6b9ddf6 100644 --- a/dev-docs/NATIVE_INTERRUPTION_UX.md +++ b/dev-docs/NATIVE_INTERRUPTION_UX.md @@ -15,6 +15,9 @@ the saved height or draft; collapse or settlement restores the preference. The d does not flex-shrink into an unusable header. The shell bounds the conversation to the visual viewport while a request is expanded, including Android keyboard resize and pan. Request fields scroll within the dock while its actions remain reachable. +For keyboard heights too short to fit the whole stack, a measured minimum reserves +the header, footer and one usable input. The session stack then scrolls as a whole +instead of clipping controls; field scrolling can chain into that outer scroller. ## Ownership and navigation diff --git a/packages/ui/src/components/interruption-dock-layout.ts b/packages/ui/src/components/interruption-dock-layout.ts new file mode 100644 index 000000000..66de4487c --- /dev/null +++ b/packages/ui/src/components/interruption-dock-layout.ts @@ -0,0 +1,36 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +// A percentage cap cannot fit even the fixed chrome under a very short keyboard +// viewport. Preserve one usable control between the measured header/footer; +// the session's outer scroller then makes each part of the stack reachable. +export function useInterruptionMinimumHeight(root: Accessor, request: Accessor) { + const [minimum, setMinimum] = createSignal() + createEffect(() => { + if (!request()) return + let disposed = false + let observer: ResizeObserver | undefined + queueMicrotask(() => { + if (disposed) return + const section = root() + const header = section?.querySelector(".window-header") + const editor = section?.querySelector(".interruption-editor:not([hidden])") + const footer = editor?.querySelector(".window-footer") + const fields = editor?.querySelector(".form-request-fields, .interruption-permission-content") + if (!section || !header || !editor || !footer || !fields) return + const control = fields.querySelector("input, textarea, select, [role=radio]") + const measure = () => { + const style = getComputedStyle(section), fieldStyle = getComputedStyle(fields) + const controlHeight = Math.max(parseFloat(style.getPropertyValue("--touch-target-size")) || 0, control?.getBoundingClientRect().height ?? 0) + const fieldHeight = controlHeight + parseFloat(fieldStyle.paddingTop) + parseFloat(fieldStyle.paddingBottom) + const border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth) + const error = editor.querySelector(".form-request-error, .tool-call-permission-error")?.getBoundingClientRect().height ?? 0 + setMinimum(Math.ceil(header.getBoundingClientRect().height + footer.getBoundingClientRect().height + fieldHeight + border + error)) + } + observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(measure) + for (const element of [section, header, footer, fields, ...(control ? [control] : [])]) observer?.observe(element) + measure() + }) + onCleanup(() => { disposed = true; observer?.disconnect() }) + }) + return minimum +} diff --git a/packages/ui/src/components/interruption-dock.tsx b/packages/ui/src/components/interruption-dock.tsx index 0ecbc6e72..7f1d9e8b0 100644 --- a/packages/ui/src/components/interruption-dock.tsx +++ b/packages/ui/src/components/interruption-dock.tsx @@ -7,6 +7,7 @@ import { sessions } from "../stores/sessions" import { interruptionFocus } from "../stores/interruption-navigation" import FormRequest from "./form-request" import { InterruptionPermission } from "./interruption-permission" +import { useInterruptionMinimumHeight } from "./interruption-dock-layout" export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean; onExpandedChange?: (expanded: boolean) => void }) { const { t } = useI18n() @@ -21,6 +22,7 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string createEffect(() => props.onExpandedChange?.(queue().length > 0 && !collapsed())) onCleanup(() => props.onExpandedChange?.(false)) const current = createMemo(() => byKey().get(selected() ?? "") ?? queue().find(item => item.payload.sessionID === props.sessionId) ?? queue()[0]) + const minimumHeight = useInterruptionMinimumHeight(() => root, () => collapsed() ? undefined : current()?.key) const index = () => queue().findIndex(item => item.key === current()?.key) const title = (sessionId: string) => sessions().get(props.instanceId)?.get(sessionId)?.title || sessionId const heading = () => t(current()?.kind === "permission" ? "interruption.permission" : "interruption.question") @@ -51,7 +53,8 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string } return 0}> -
+
diff --git a/packages/ui/src/styles/components/interruption-dock.css b/packages/ui/src/styles/components/interruption-dock.css index d67324571..bac4bfd6b 100644 --- a/packages/ui/src/styles/components/interruption-dock.css +++ b/packages/ui/src/styles/components/interruption-dock.css @@ -1,8 +1,8 @@ .interruption-dock { flex: 0 0 auto; min-height: 0; - /* A short conversation loses a larger fraction to toolbar/composer chrome. - Give its editor room while retaining the viewport-wide 45% upper bound. */ + /* The measured usable minimum takes precedence on extreme keyboard heights; + the session stack scrolls rather than clipping the footer or composer. */ max-height: min(60%, 45vh); display: flex; flex-direction: column; @@ -16,6 +16,14 @@ gap: var(--space-sm); background: color-mix(in srgb, var(--accent-primary) 9%, var(--surface-chrome)); } +.session-view:has(> .interruption-dock:not(.is-collapsed)) { + overflow-y: auto; + overscroll-behavior-y: contain; +} +.session-view:has(> .interruption-dock:not(.is-collapsed)) > .prompt-input-container { flex-shrink: 0; } +.session-view:has(> .interruption-dock:not(.is-collapsed)) :is(.form-request-fields, .interruption-permission-content) { + overscroll-behavior-y: auto; +} .interruption-heading { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; } .interruption-heading > svg { flex-shrink: 0; color: var(--accent-primary); } .interruption-heading-copy { min-width: 0; } diff --git a/packages/ui/tests/browser/mobile-interruption.test.ts b/packages/ui/tests/browser/mobile-interruption.test.ts index 61fe06094..ba05ac4ff 100644 --- a/packages/ui/tests/browser/mobile-interruption.test.ts +++ b/packages/ui/tests/browser/mobile-interruption.test.ts @@ -150,6 +150,129 @@ async function assertFooter(page: Page, selector = ".form-request-actions") { assert.deepEqual(metrics, { bounded: true, controls: true }) } +// Exercise native nested scrolling rather than scrollIntoView: the field scrolls +// its long contents, and the outer stack exposes fixed actions when the keyboard +// leaves less room than the dock's usable minimum plus the composer. +async function revealByGesture(page: Page, control: Locator, touch: boolean) { + const cdp = touch ? await page.context().newCDPSession(page) : undefined + let stable = 0, previousTop: number | undefined + try { + for (let attempt = 0; attempt < 70; attempt++) { + const target = await control.evaluate(el => { + const r = el.getBoundingClientRect(), outer = el.closest(".session-view")!.getBoundingClientRect() + const top = Math.max(outer.top, visualViewport?.offsetTop ?? 0) + const bottom = Math.min(outer.bottom, (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? innerHeight)) + const fields = el.closest(".form-request-fields, .interruption-permission-content")?.getBoundingClientRect() + const withinFields = !fields || (r.top >= fields.top && r.bottom <= fields.bottom) + const ready = withinFields && r.top >= top && r.bottom <= bottom && el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) + // First expose the inner scrollport, then scroll within it. Gestures on + // visible dock chrome reach the stack without grabbing a drawer resizer + // or continuing to edit/scroll the focused permission textarea. + const inner = fields && fields.top >= top && fields.bottom <= bottom && !withinFields + const wanted = inner ? r.y + r.height / 2 : fields && !withinFields ? fields.y + fields.height / 2 : r.y + r.height / 2 + const chrome = [...el.closest(".interruption-dock")!.querySelectorAll(".window-header, .window-footer")] + .map(node => node.getBoundingClientRect()).map(r => ({ x: r.x, width: r.width, top: Math.max(top, r.top), bottom: Math.min(bottom, r.bottom) })) + .filter(r => r.bottom - r.top > 8).sort((a, b) => (b.bottom - b.top) - (a.bottom - a.top))[0] + const rect = inner ? fields : chrome ?? { x: outer.x, width: 24, top, bottom } + const delta = wanted - (inner ? (rect.top + rect.bottom) / 2 : (top + bottom) / 2) + return { ready, controlTop: r.top, x: rect.x + rect.width / 2, + y: inner ? (rect.top + rect.bottom) / 2 : delta > 0 ? rect.bottom - 3 : rect.top + 3, + delta, top: visualViewport?.offsetTop ?? 0, bottom } + }) + if (target.ready) { + stable = previousTop === target.controlTop ? stable + 1 : 0 + previousTop = target.controlTop + if (stable >= 5) return + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + continue + } + stable = 0 + if (cdp) { + const distance = Math.sign(target.delta) * Math.max(24, Math.min(80, Math.abs(target.delta))) + const endY = Math.max(target.top + 2, Math.min(target.bottom - 2, target.y - distance)) + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: target.x, y: target.y }] }) + for (let step = 1; step <= 5; step++) { + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: target.x, y: target.y + (endY - target.y) * step / 5 }] }) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve()))) + } + await page.waitForTimeout(100) // Release a held drag, rather than a kinetic fling that consumes the next tap. + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }) + } else { + await page.mouse.move(target.x, target.y) + await page.mouse.wheel(0, Math.sign(target.delta) * Math.min(180, Math.abs(target.delta))) + } + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + } + assert.fail(`control was not reachable through ${touch ? "touch" : "wheel"}: ${JSON.stringify(await control.evaluate(el => { + const r = el.getBoundingClientRect(), outer = el.closest(".session-view")! + return { rect: r.toJSON(), outer: outer.getBoundingClientRect().toJSON(), scrollTop: outer.scrollTop, scrollHeight: outer.scrollHeight, + hit: document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.className } + }))}`) + } finally { await cdp?.detach() } +} + +for (const landscape of [false, true]) for (const keyboardHeight of [260, 220, 180]) { + test(`Android ${landscape ? "landscape touch" : "portrait wheel"} keyboard ${keyboardHeight}px scrolls the request stack without clipping actions`, async () => { + const page = await browser.newPage({ ...devices["Pixel 5"], viewport: landscape ? { width: 851, height: 393 } : { width: 393, height: 851 } }) + page.setDefaultTimeout(15000) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.addInitScript(`(() => { + let height = innerHeight + Object.defineProperty(visualViewport, "height", { get: () => height }) + window.keyboardViewport = next => { height = next; visualViewport.dispatchEvent(new Event("resize")) } + })()`) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url, { timeout: 45000 }) + await page.waitForFunction(() => Boolean((window as any).fixture), undefined, { timeout: 45000 }) + const composer = page.locator("textarea.prompt-input:visible") + await composer.fill("Saved maximum composer draft") + await page.locator(".prompt-resize-handle").focus() + await page.keyboard.press("End") + const height = await composer.evaluate(el => el.getBoundingClientRect().height) + const saved = await page.evaluate(() => (window as any).fixture.promptHeight()) + await page.evaluate(keyboardHeight => { + ;(window as any).fixture.nativeQuestion("session", "short-keyboard-form", true) + ;(window as any).keyboardViewport(keyboardHeight) + }, keyboardHeight) + await page.waitForFunction(bottom => document.querySelector(".content-area")!.getBoundingClientRect().bottom <= bottom, keyboardHeight) + await page.waitForFunction(() => { + const el = document.querySelector(".session-view")! + return el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY === "auto" + }) + const answer = page.locator('.interruption-dock input[type="text"]:visible') + await revealByGesture(page, answer, landscape) + await answer.tap() + await answer.fill("Reachable with a short keyboard") + const submit = page.getByRole("button", { name: "Submit", exact: true }) + await revealByGesture(page, submit, landscape) + await assertFooter(page) + if (process.env.CODENOMAD_MOBILE_CAPTURE) { + await mkdir(process.env.CODENOMAD_MOBILE_CAPTURE, { recursive: true }) + await page.screenshot({ path: join(process.env.CODENOMAD_MOBILE_CAPTURE, `short-keyboard-${landscape ? "landscape" : "portrait"}-${keyboardHeight}.png`), scale: "css" }) + } + await submit.tap() + assert.equal(await page.evaluate(() => (window as any).fixture.replies.length), 1, "the visible Submit receives the native tap") + await page.locator(".interruption-dock").waitFor({ state: "detached" }) + await page.evaluate(() => (window as any).fixture.queuePermission()) + const reason = page.locator(".interruption-dock textarea") + await revealByGesture(page, reason, landscape) + await reason.fill("Permission still reachable") + const deny = page.getByRole("button", { name: "Deny", exact: true }) + await revealByGesture(page, deny, landscape) + await assertFooter(page, ".tool-call-permission-buttons") + await deny.tap() + await page.locator(".interruption-dock").waitFor({ state: "detached" }) + await page.evaluate(() => (window as any).keyboardViewport(innerHeight)) + await page.waitForFunction(height => document.querySelector("textarea.prompt-input")!.getBoundingClientRect().height === height, height) + assert.equal(await composer.inputValue(), "Saved maximum composer draft") + assert.deepEqual(await page.evaluate(() => (window as any).fixture.promptHeight()), saved) + assert.deepEqual(errors, []) + } finally { await page.close() } + }) +} + for (const mode of ["desktop", "mobile", "immersive", "landscape"] as const) { test(`real shell pending question survives two-session navigation in ${mode}`, async (t) => { const mobile = mode !== "desktop"