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..3f6b9ddf6 100644 --- a/dev-docs/NATIVE_INTERRUPTION_UX.md +++ b/dev-docs/NATIVE_INTERRUPTION_UX.md @@ -9,6 +9,16 @@ 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. +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 - Native pending queues remain authoritative. No transcript scan decides whether @@ -97,3 +107,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-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 2b76a90cf..7f1d9e8b0 100644 --- a/packages/ui/src/components/interruption-dock.tsx +++ b/packages/ui/src/components/interruption-dock.tsx @@ -7,8 +7,9 @@ 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 }) { +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,7 +19,10 @@ 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 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") @@ -49,7 +53,8 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string } return 0}> -
+
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} .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/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 1cd76d97c..d0d0f5850 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -18,6 +18,8 @@ padding: 0; } +.prompt-resize-handle[aria-disabled="true"] { cursor: default; pointer-events: none; } + .prompt-resize-handle::after { content: ""; position: absolute; diff --git a/packages/ui/tests/browser/fixtures/header-windows.tsx b/packages/ui/tests/browser/fixtures/header-windows.tsx index ba3ce4cbb..1eae4dabd 100644 --- a/packages/ui/tests/browser/fixtures/header-windows.tsx +++ b/packages/ui/tests/browser/fixtures/header-windows.tsx @@ -9,6 +9,9 @@ import { ThemeProvider } from "../../../src/lib/theme" import { promptInputHeight } from "../../../src/components/prompt-input/height-state" import { serverApi } from "../../../src/lib/api-client" import { sdkManager } from "../../../src/lib/sdk-manager" +import { runtimeEnv } from "../../../src/lib/runtime-env" +import { sseManager } from "../../../src/lib/sse-manager" +import { getFormQueue } from "../../../src/stores/forms" import { addInstance, addPendingForm, addPermissionToQueue, instances } from "../../../src/stores/instances" import { setSessions, setActiveSession, setActiveParentSession, setSessionPage, setProviders, setSessionStatus, activeSessionId, setSessionInfoByInstance } from "../../../src/stores/session-state" import { ensureWorktreesLoaded } from "../../../src/stores/worktrees" @@ -22,8 +25,19 @@ let interrupts = 0 const session: any = { id: sessionId, instanceId: id, parentId: null, title: "Fixture conversation", location: { directory: "/repo" }, projectID: "fixture", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, agent: "build", status: "idle", model: { providerId: "fixture", modelId: "fixture" } } +const fixtureSessions = new Map([[sessionId, session]]) +const replies: any[] = [] +let eventTime = 100 +const emit = (type: string, data: unknown) => (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..ba05ac4ff --- /dev/null +++ b/packages/ui/tests/browser/mobile-interruption.test.ts @@ -0,0 +1,370 @@ +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 }) +} + +// 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" + 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() + } + }) +}