Skip to content

Commit bc7da10

Browse files
authored
fix(app): keep question options and submit controls reachable on mobile (#60335)
1 parent 4efad23 commit bc7da10

6 files changed

Lines changed: 346 additions & 18 deletions

File tree

‎packages/app/component-tests/session-question-dock.spec.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,90 @@ story("keeps classic long answer lists inside the panel and scrolls them", async
8080
await expect(question.getByRole("radio", { name: "One pull request", exact: true })).toBeFocused()
8181
})
8282

83+
story.describe("on a 393x852 mobile viewport", () => {
84+
story.use({ hasTouch: true, isMobile: true, viewport: { width: 393, height: 852 } })
85+
86+
for (const experimental of [false, true]) {
87+
story(
88+
`keeps navigation visible and scrolls long prompts and 12 options (${experimental ? "experimental" : "classic"})`,
89+
async ({ mount, page }) => {
90+
if (experimental) {
91+
await page.addInitScript(() =>
92+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: true } })),
93+
)
94+
}
95+
96+
const component = await mount("session-question-switch--long-prompt")
97+
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
98+
await page.evaluate(() => document.fonts.ready.then(() => undefined))
99+
100+
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
101+
const dismiss = question.getByRole("button", { name: "Dismiss", exact: true })
102+
const next = question.getByRole("button", { name: "Next", exact: true })
103+
await expect(dismiss).toBeInViewport({ ratio: 1 })
104+
await expect(next).toBeInViewport({ ratio: 1 })
105+
106+
const twelfth = question.getByRole("radio", { name: /12\. Clean slate rebuild/ })
107+
await twelfth.scrollIntoViewIfNeeded()
108+
await expect(twelfth).toBeInViewport({ ratio: 1 })
109+
await twelfth.tap()
110+
await expect(twelfth).toBeChecked()
111+
await expect(next).toBeInViewport({ ratio: 1 })
112+
113+
await next.tap()
114+
115+
const back = question.getByRole("button", { name: "Back", exact: true })
116+
const submit = question.getByRole("button", { name: "Submit", exact: true })
117+
await expect(back).toBeInViewport({ ratio: 1 })
118+
await expect(submit).toBeInViewport({ ratio: 1 })
119+
120+
const detailsSlot = experimental ? '[data-slot="question-details-frame"]' : '[data-slot="question-details"]'
121+
122+
const layout = await question.evaluate((root, selector) => {
123+
const details = root.querySelector<HTMLElement>(selector)!
124+
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
125+
const footer = root.querySelector<HTMLElement>('[data-slot="question-footer"]')!
126+
const detailsRect = details.getBoundingClientRect()
127+
const optionsRect = options.getBoundingClientRect()
128+
const footerRect = footer.getBoundingClientRect()
129+
130+
details.scrollTop = details.scrollHeight
131+
const scrolledPrompt = details.scrollTop > 0
132+
133+
return {
134+
promptScrollHeight: details.scrollHeight,
135+
promptClientHeight: details.clientHeight,
136+
scrolledPrompt,
137+
optionsHeight: optionsRect.height,
138+
optionsBelowPrompt: optionsRect.top >= detailsRect.bottom - 0.5,
139+
footerBelowOptions: footerRect.top >= optionsRect.bottom - 9.5,
140+
dockFitsViewport: root.getBoundingClientRect().bottom <= innerHeight + 0.5,
141+
}
142+
}, detailsSlot)
143+
144+
expect(layout.promptScrollHeight).toBeGreaterThan(852)
145+
expect(layout.promptClientHeight).toBeGreaterThan(48)
146+
expect(layout.promptScrollHeight).toBeGreaterThan(layout.promptClientHeight)
147+
expect(layout.scrolledPrompt).toBe(true)
148+
expect(layout.optionsHeight).toBeGreaterThan(80)
149+
expect(layout.optionsBelowPrompt).toBe(true)
150+
expect(layout.footerBelowOptions).toBe(true)
151+
expect(layout.dockFitsViewport).toBe(true)
152+
153+
const lastOption = question.getByRole("radio", { name: /Export a diagnostic bundle/ })
154+
await lastOption.scrollIntoViewIfNeeded()
155+
await expect(lastOption).toBeInViewport({ ratio: 1 })
156+
await lastOption.tap()
157+
await expect(lastOption).toBeChecked()
158+
await expect(submit).toBeInViewport({ ratio: 1 })
159+
160+
await submit.tap()
161+
await expect(question).toBeHidden()
162+
},
163+
)
164+
}
165+
})
166+
83167
story(
84168
"minimizes and restores classic questions immediately for reduced motion and keyboard actions",
85169
async ({ mount, page }) => {

‎packages/app/src/session/question-switch.stories.tsx‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,161 @@ export const LongList = {
167167
/>
168168
),
169169
}
170+
171+
const longMobileOptions = [
172+
{
173+
value: "opt-1",
174+
label: "1. Incremental schema migration with dual-read verification (Recommended)",
175+
description:
176+
"Backfill every existing session row in bounded batches while reading from both tables and logging any mismatch before cutover.",
177+
},
178+
{
179+
value: "opt-2",
180+
label: "2. Blue-green SQLite snapshot swap on daemon restart",
181+
description:
182+
"Build a fresh projected database beside the live file during idle windows and atomically swap file descriptors once parity holds.",
183+
},
184+
{
185+
value: "opt-3",
186+
label: "3. Lazy per-session hydration on first transcript open",
187+
description:
188+
"Leave historical sessions untouched at startup and upgrade each transcript inside a single write transaction when opened.",
189+
},
190+
{
191+
value: "opt-4",
192+
label: "4. Shadow journal replay from durable event log",
193+
description:
194+
"Replay ordered session aggregate events into a new read-model table in the background without blocking active prompt drains.",
195+
},
196+
{
197+
value: "opt-5",
198+
label: "5. Read-only archive boundary for legacy schema versions",
199+
description:
200+
"Keep older transcripts viewable through a frozen decoder while writing all newly admitted prompts exclusively to the v4 tables.",
201+
},
202+
{
203+
value: "opt-6",
204+
label: "6. Checkpoint compaction before running structural DDL",
205+
description:
206+
"Truncate the write-ahead log, vacuum dead pages, and verify foreign-key integrity prior to adding the new composite indexes.",
207+
},
208+
{
209+
value: "opt-7",
210+
label: "7. Partitioned attachment metadata with external blob refs",
211+
description:
212+
"Move inline base64 tool outputs into content-addressed files on disk so mobile queries never scan oversized row payloads.",
213+
},
214+
{
215+
value: "opt-8",
216+
label: "8. Staged rollout gated by workspace recovery marker",
217+
description:
218+
"Enable the new projection only after all claimed top-level sessions finish draining cleanly without pending inbox items.",
219+
},
220+
{
221+
value: "opt-9",
222+
label: "9. Deterministic fixture verification before live migration",
223+
description:
224+
"Clone the workspace header and sample fifty recent conversations into a temporary file to benchmark query plans first.",
225+
},
226+
{
227+
value: "opt-10",
228+
label: "10. Reversible trigger mirror between v3 and v4 columns",
229+
description:
230+
"Install temporary SQLite triggers that keep old and new columns synchronized so an older binary can still open the workspace.",
231+
},
232+
{
233+
value: "opt-11",
234+
label: "11. Offline CLI migration subcommand with progress bar",
235+
description:
236+
"Require explicit operator confirmation via opencode db migrate and refuse automatic background writes on startup.",
237+
},
238+
{
239+
value: "opt-12",
240+
label: "12. Clean slate rebuild of derived read projections only",
241+
description:
242+
"Drop disposable summary caches and rebuild every read projection directly from the canonical event history on boot.",
243+
},
244+
]
245+
246+
const tallMobilePrompt = [
247+
"Before I run the workspace storage migration on this repository, please review the full rollout plan and confirm how failure recovery should behave if the connection drops mid-run.",
248+
"Step 1 inspects the active SQLite write-ahead log, verifies that no other local coordinator currently holds a busy execution claim, and records the starting event sequence number in a durable checkpoint row.",
249+
"Step 2 scans session_inbox for any unconsumed user or synthetic items so steered prompts admitted right before shutdown remain preserved in exact enqueue order without duplicate delivery.",
250+
"Step 3 creates the new normalized message part tables beside the existing transcript projection, keeping every original row intact until row counts, byte checksums, and foreign-key constraints match across all sessions.",
251+
"Step 4 streams large diff and terminal output blobs out of inline JSON columns into content-addressed storage files, preventing multi-megabyte rows from stalling mobile timeline queries over remote tunnels.",
252+
"Step 5 rebuilds the full-text search index and session summary projections in slices of twenty-five conversations per transaction, yielding between slices so interactive HTTP requests and live SSE updates stay responsive.",
253+
"Step 6 validates cursor pagination from both ends of the longest session timeline, checking that sticky tool headers, collapsed reasoning blocks, and pending permission or question docks restore to identical state.",
254+
"Step 7 writes a reversible compatibility view so older CLI processes inspecting the same directory fail gracefully with a clear upgrade message rather than corrupting partially migrated state.",
255+
"Step 8 runs PRAGMA integrity_check and compares aggregate token, cost, and tool-call totals before and after the projection swap to catch any silent truncation in historical metadata.",
256+
"Step 9 retains the pre-migration backup snapshot in the cache directory for seven days so you can roll back with a single command if any downstream script or plugin depends on the legacy layout.",
257+
"Step 10 revalidates workspace permissions, active MCP server tool bindings, and pending form or elicitation state so an in-flight agent session resumes cleanly without re-prompting for completed answers.",
258+
"Step 11 checks disk free space on both the database volume and temporary directory before committing the final rename, ensuring low-storage mobile hosts and containers never wedge mid-checkpoint.",
259+
"Step 12 emits a structured migration summary event with elapsed wall time, migrated row counts, reclaimed page bytes, and fallback instructions for operators monitoring remote headless servers.",
260+
"If a power loss, SIGKILL, or mobile network timeout interrupts any of these steps before the final checkpoint commits, how should the server handle the unfinished migration when it starts back up?",
261+
].join(" ")
262+
263+
// Twelve verbose options followed by a question prompt taller than a phone viewport.
264+
export const LongPrompt = {
265+
render: () => (
266+
<SessionPreview
267+
title="Plan the storage migration"
268+
description="opencode · storage-rollout"
269+
document={questionPendingDocument}
270+
request={{
271+
type: "question",
272+
value: {
273+
...activeQuestionRequest,
274+
fields: [
275+
{
276+
key: "strategy",
277+
type: "string",
278+
title: "Strategy",
279+
description: "Which migration strategy should I use for existing workspace sessions?",
280+
options: longMobileOptions,
281+
custom: true,
282+
},
283+
{
284+
key: "recovery",
285+
type: "string",
286+
title: "Recovery",
287+
description: tallMobilePrompt,
288+
options: [
289+
{
290+
value: "resume",
291+
label: "Resume from the last committed slice (Recommended)",
292+
description: "Continue from the recorded checkpoint sequence without repeating verified batches.",
293+
},
294+
{
295+
value: "rollback",
296+
label: "Roll back to the pre-migration snapshot automatically",
297+
description: "Restore the original file immediately and surface a diagnostic toast in the session.",
298+
},
299+
{
300+
value: "pause",
301+
label: "Pause startup recovery and ask before retrying",
302+
description: "Keep the workspace read-only until an explicit retry or rollback choice is confirmed.",
303+
},
304+
{
305+
value: "rebuild",
306+
label: "Discard partial projections and replay from scratch",
307+
description: "Drop the incomplete target tables and restart a fresh pass from event sequence zero.",
308+
},
309+
{
310+
value: "readonly",
311+
label: "Serve existing sessions read-only while retrying in background",
312+
description: "Allow browsing transcripts immediately and unlock new prompts once parity finishes.",
313+
},
314+
{
315+
value: "export",
316+
label: "Export a diagnostic bundle and halt migration",
317+
description: "Write schema stats and mismatch logs to disk without touching the live workspace.",
318+
},
319+
],
320+
custom: true,
321+
},
322+
],
323+
},
324+
}}
325+
/>
326+
),
327+
}

‎packages/app/src/session/requests/session-question-dock-classic.tsx‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
122122
onCleanup(observeReducedMotion(() => setStore("reducedMotion", prefersReducedMotion())))
123123

124124
let root: HTMLDivElement | undefined
125+
let detailsRef: HTMLDivElement | undefined
125126
let optionsRef: HTMLDivElement | undefined
126127
let customRef: HTMLButtonElement | undefined
127128
let customInput: HTMLInputElement | undefined
@@ -197,7 +198,10 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
197198
const dock = root.closest('[data-component="session-composer-dock"]')
198199
// In the app this row can have zero height and overflow upward; the containing panel bounds the question.
199200
const column = bounds(dock?.parentElement)
200-
const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight)
201+
const parent = dock?.parentElement?.getBoundingClientRect()
202+
const edge = parent && parent.width > 0 ? parent.bottom : Infinity
203+
const view = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : Infinity
204+
const bottom = Math.min(window.innerHeight, view, column?.bottom ?? Infinity, edge)
201205

202206
const around =
203207
dock instanceof HTMLElement
@@ -232,7 +236,14 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
232236
focusFrame = requestAnimationFrame(() => {
233237
focusFrame = undefined
234238
const el = next === options().length ? customRef : optsRef[next]
235-
el?.focus()
239+
el?.focus({ preventScroll: true })
240+
const row = el?.closest<HTMLElement>('[data-slot="question-option"]')
241+
242+
if (!row || !optionsRef) return
243+
optionsRef.scrollTop = Math.min(
244+
row.offsetTop,
245+
Math.max(optionsRef.scrollTop, row.offsetTop + row.offsetHeight - optionsRef.clientHeight),
246+
)
236247
})
237248
}
238249

@@ -247,9 +258,14 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
247258
})
248259
}
249260

250-
update()
261+
measure()
251262
makeEventListener(window, "resize", update)
252263

264+
if (window.visualViewport) {
265+
makeEventListener(window.visualViewport, "resize", update)
266+
makeEventListener(window.visualViewport, "scroll", update)
267+
}
268+
253269
const dock = root?.closest('[data-component="session-composer-dock"]')
254270
const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')
255271
createResizeObserver([dock, scroller], update)
@@ -527,7 +543,12 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
527543
setStore("tab", tab)
528544
setStore("editing", false)
529545

530-
if (optionsRef) setStore("optionsHeight", optionsRef.scrollHeight)
546+
if (detailsRef) detailsRef.scrollTop = 0
547+
548+
if (optionsRef) {
549+
optionsRef.scrollTop = 0
550+
setStore("optionsHeight", optionsRef.scrollHeight)
551+
}
531552

532553
if (!store.minimized) focus(pickFocus(tab))
533554
}
@@ -547,6 +568,8 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
547568

548569
const minimize = () => {
549570
if (sending()) return
571+
572+
if (optionsRef) setStore("optionsHeight", optionsRef.offsetHeight)
550573
setStore("editing", false)
551574
setStore("minimized", true)
552575
}
@@ -606,7 +629,11 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
606629
/>
607630
</div>
608631
</div>
609-
<div data-slot="question-details">
632+
<div
633+
data-slot="question-details"
634+
data-scrollable={!store.minimized || undefined}
635+
ref={(el) => (detailsRef = el)}
636+
>
610637
<div
611638
data-slot="question-text"
612639
style={{
@@ -694,7 +721,7 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit
694721
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
695722
classList={{ "pointer-events-none": hidden() > 0.1 }}
696723
style={{
697-
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
724+
"max-height": hidden() > 0 ? `${Math.max(0, store.optionsHeight * (1 - hidden()))}px` : undefined,
698725
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
699726
visibility: optionsOff() ? "hidden" : "visible",
700727
}}

0 commit comments

Comments
 (0)