diff --git a/frontend/src/components/session/FormPrompt.test.tsx b/frontend/src/components/session/FormPrompt.test.tsx
index 45c42320b..dbb41451a 100644
--- a/frontend/src/components/session/FormPrompt.test.tsx
+++ b/frontend/src/components/session/FormPrompt.test.tsx
@@ -1,6 +1,6 @@
-import { render as renderRaw, screen, waitFor, within } from '@testing-library/react'
+import { fireEvent, render as renderRaw, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactElement } from 'react'
import type { FormInfo } from '@opencode-manager/shared/opencode'
import { KeyboardShortcutsProvider, useShortcutActions } from '@/contexts/KeyboardShortcutsContext'
@@ -19,6 +19,10 @@ function AbortLayer({ onAbort }: { onAbort: () => void }) {
return null
}
+function getHeaderDismissButton() {
+ return screen.getAllByRole('button', { name: 'Dismiss' })[0]
+}
+
const selectForm: FormInfo = {
id: 'form-1',
sessionID: 'session-1',
@@ -37,6 +41,14 @@ const selectForm: FormInfo = {
}
describe('FormPrompt', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ })
+
+ afterEach(() => {
+ sessionStorage.clear()
+ })
+
it('submits the selected option keyed by field key', async () => {
const onReply = vi.fn().mockResolvedValue(undefined)
render()
@@ -84,7 +96,7 @@ describe('FormPrompt', () => {
const onCancel = vi.fn().mockResolvedValue(undefined)
render()
- await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
+ await userEvent.click(getHeaderDismissButton())
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('form-1'))
})
@@ -519,7 +531,60 @@ describe('FormPrompt', () => {
expect(featuresAlpha).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Dismiss form' })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: 'Dismiss' })).toHaveLength(2)
+ })
+ })
+
+ describe('mobile sheet controls', () => {
+ it('minimizes from the grab handle', async () => {
+ const onMinimize = vi.fn()
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Drag to minimize' }))
+
+ expect(onMinimize).toHaveBeenCalledTimes(1)
+ })
+
+ it('minimizes from the mobile header control', async () => {
+ const onMinimize = vi.fn()
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Minimize form' }))
+
+ expect(onMinimize).toHaveBeenCalledTimes(1)
+ })
+
+ it('dismisses from the mobile header link', async () => {
+ const onCancel = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ await userEvent.click(getHeaderDismissButton())
+
+ await waitFor(() => expect(onCancel).toHaveBeenCalledWith('form-1'))
+ })
+ })
+
+ describe('side question button', () => {
+ it('opens the side question from the footer button', async () => {
+ const onAskSideQuestion = vi.fn()
+ render(
+
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'Ask a side question' }))
+
+ expect(onAskSideQuestion).toHaveBeenCalledTimes(1)
+ })
+
+ it('omits the side question button when no handler is provided', () => {
+ render()
+
+ expect(screen.queryByRole('button', { name: 'Ask a side question' })).not.toBeInTheDocument()
})
})
@@ -628,6 +693,75 @@ describe('FormPrompt', () => {
})
})
+ describe('draft persistence', () => {
+ const draftKey = 'opencode-manager:form-draft:form-1'
+
+ it('restores answers after unmount and remount', async () => {
+ const onReply = vi.fn().mockResolvedValue(undefined)
+ const first = render()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Production' }))
+ first.unmount()
+
+ render()
+
+ expect(screen.getByRole('button', { name: 'Production' })).toHaveAttribute('aria-pressed', 'true')
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
+ await waitFor(() => expect(onReply).toHaveBeenCalledWith('form-1', { q0: 'production' }))
+ })
+
+ it('clears the draft after a successful submit', async () => {
+ const onReply = vi.fn().mockResolvedValue(undefined)
+ const first = render()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Production' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
+ await waitFor(() => expect(sessionStorage.getItem(draftKey)).toBeNull())
+ first.unmount()
+
+ render()
+
+ expect(screen.getByRole('button', { name: 'Production' })).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ it('clears the draft after a successful dismiss', async () => {
+ const onCancel = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Production' }))
+ await userEvent.click(getHeaderDismissButton())
+
+ await waitFor(() => expect(onCancel).toHaveBeenCalledWith('form-1'))
+ await waitFor(() => expect(sessionStorage.getItem(draftKey)).toBeNull())
+ })
+
+ it('falls back to defaults when the stored draft is corrupt', () => {
+ const form: FormInfo = {
+ id: 'form-corrupt',
+ sessionID: 'session-1',
+ title: 'Choose',
+ fields: [
+ {
+ key: 'q0',
+ title: 'Environment',
+ type: 'string',
+ options: [
+ { value: 'staging', label: 'Staging' },
+ { value: 'production', label: 'Production' },
+ ],
+ default: 'staging',
+ },
+ ],
+ }
+ sessionStorage.setItem('opencode-manager:form-draft:form-corrupt', '{not json')
+
+ render()
+
+ expect(screen.getByRole('button', { name: 'Staging' })).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByRole('button', { name: 'Production' })).toHaveAttribute('aria-pressed', 'false')
+ })
+ })
+
describe('keyboard shortcuts', () => {
it('minimizes once on Escape while expanded without triggering a registered session abort', async () => {
const sessionAbort = vi.fn()
@@ -644,5 +778,23 @@ describe('FormPrompt', () => {
expect(onMinimize).toHaveBeenCalledTimes(1)
expect(sessionAbort).not.toHaveBeenCalled()
})
+
+ it('ignores Escape from inside a dialog but minimizes from the document body', () => {
+ const onMinimize = vi.fn()
+ render(
+ <>
+
+
+
+
+ >
+ )
+
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Dialog action' }), { key: 'Escape' })
+ expect(onMinimize).not.toHaveBeenCalled()
+
+ fireEvent.keyDown(document.body, { key: 'Escape' })
+ expect(onMinimize).toHaveBeenCalledTimes(1)
+ })
})
})
diff --git a/frontend/src/components/session/FormPrompt.tsx b/frontend/src/components/session/FormPrompt.tsx
index cde00e1ff..0cb9af6c1 100644
--- a/frontend/src/components/session/FormPrompt.tsx
+++ b/frontend/src/components/session/FormPrompt.tsx
@@ -1,13 +1,15 @@
-import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
-import { X, ChevronDown, ChevronUp, Check, Loader2 } from 'lucide-react'
+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
+import { X, ChevronDown, ChevronUp, Check, Loader2, MessageCircleQuestion } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { ExternalFieldCard } from '@/components/ui/external-field-card'
import type { FormAnswer, FormField, FormInfo } from '@opencode-manager/shared/opencode'
import { buildAnswer, hasMissingAnswers, visibleFields } from '@/lib/formFields'
+import { clearFormDraft, loadFormDraft, saveFormDraft, type FormDraft } from '@/lib/formDrafts'
import { cn } from '@/lib/utils'
import { showToast } from '@/lib/toast'
import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext'
+import { useMobile, useSwipeDismiss } from '@/hooks/useMobile'
type AnswerableFormField = Exclude
@@ -31,13 +33,7 @@ function usesCustomInput(field: AnswerableFormField): boolean {
return field.type === 'string' && field.custom === true && (field.options?.length ?? 0) > 0
}
-interface FormPromptState {
- answer: FormAnswer
- customValues: Record
- customDrafts: Record
-}
-
-function buildInitialState(fields: AnswerableFormField[]): FormPromptState {
+function buildInitialState(fields: AnswerableFormField[]): FormDraft {
const answer: FormAnswer = {}
const customValues: Record = {}
const customDrafts: Record = {}
@@ -66,16 +62,38 @@ interface FormPromptProps {
onReply: (formID: string, answer: FormAnswer) => Promise
onCancel: (formID: string) => Promise
onMinimize?: () => void
+ onAskSideQuestion?: () => void
}
-export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptProps) {
+export function FormPrompt({ form, onReply, onCancel, onMinimize, onAskSideQuestion }: FormPromptProps) {
const candidateFields = form.fields.filter(isAnswerableField)
- const [state, setState] = useState(() => buildInitialState(candidateFields))
+ const [state, setState] = useState(() => loadFormDraft(form.id) ?? buildInitialState(candidateFields))
const { answer, customValues, customDrafts } = state
const [expandedCustom, setExpandedCustom] = useState(null)
const [isMinimized, setIsMinimized] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
+ const draftClearedRef = useRef(false)
+ const mobileHeaderRef = useRef(null)
const formControlId = useId()
+ const isMobile = useMobile()
+
+ const handleMinimize = useCallback(() => {
+ setIsMinimized(true)
+ onMinimize?.()
+ }, [onMinimize])
+
+ const { bind, swipeStyles } = useSwipeDismiss(handleMinimize, {
+ enabled: !isMinimized,
+ threshold: 96,
+ suspendsRouteSwipe: false,
+ })
+
+ useEffect(() => bind(mobileHeaderRef.current), [bind])
+
+ useEffect(() => {
+ if (draftClearedRef.current) return
+ saveFormDraft(form.id, state)
+ }, [form.id, state])
const controlId = (key: string) => `${formControlId}-${key}`
@@ -132,11 +150,6 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
const canSubmit = !hasMissingAnswers(form.fields, effectiveAnswers)
const buildSubmission = (): FormAnswer => buildAnswer(form.fields, effectiveAnswers)
- const handleMinimize = () => {
- setIsMinimized(true)
- onMinimize?.()
- }
-
useShortcutActions({
abort: !isMinimized && !expandedCustom ? handleMinimize : undefined,
})
@@ -146,6 +159,8 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
setIsSubmitting(true)
try {
await onReply(form.id, buildSubmission())
+ draftClearedRef.current = true
+ clearFormDraft(form.id)
} catch {
showToast.error('Failed to submit form')
} finally {
@@ -157,6 +172,8 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
setIsSubmitting(true)
try {
await onCancel(form.id)
+ draftClearedRef.current = true
+ clearFormDraft(form.id)
} catch {
showToast.error('Failed to dismiss form')
} finally {
@@ -168,6 +185,7 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return
if (e.key === 'Escape' && !isMinimized && !expandedCustom) {
+ if (e.target instanceof Element && e.target.closest('[role="dialog"], [role="alertdialog"]')) return
setIsMinimized(true)
onMinimize?.()
}
@@ -187,7 +205,7 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
const selected = typeof value === 'boolean' ? value : undefined
return (
-
+
setFieldAnswer(field.key, true)} />
setFieldAnswer(field.key, false)} />
@@ -209,7 +227,7 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
max={typeof field.maximum === 'number' ? field.maximum : undefined}
step={field.type === 'integer' ? 1 : 'any'}
onChange={(e) => setFieldAnswer(field.key, e.target.value === '' ? '' : Number(e.target.value))}
- className="w-full rounded-lg border border-border bg-muted/60 dark:bg-muted/5 px-3 py-2 text-sm focus:border-primary focus:outline-none"
+ className="w-full rounded-lg border border-border bg-muted/60 dark:bg-muted/5 px-3.5 py-3 text-base focus:border-primary focus:outline-none md:px-3 md:py-2 md:text-sm"
/>
)
@@ -221,7 +239,7 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
const customValue = customDrafts[field.key] ?? ''
return (
-
+
{field.options.map((option) => (
-
+
{field.options.map((option) => (
setFieldAnswer(field.key, e.target.value)}
placeholder={field.placeholder ?? 'Type your answer...'}
- className="min-h-[60px] sm:min-h-[80px] text-[16px] sm:text-xs md:text-sm resize-none border-primary/30 focus:border-primary"
+ className="min-h-[60px] md:min-h-[80px] text-[16px] md:text-sm resize-none border-primary/30 focus:border-primary"
/>
)
}
return (
-
-
+
+
+
+
+
+
+ {form.title || 'Form'}
+
+
+
+
+
+
{!isMinimized && (
-
+
{renderFields.map((field) =>
field.type === 'external' ? (
@@ -345,22 +398,34 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
)}
-
+
+ {onAskSideQuestion && (
+
+ )}
@@ -379,13 +444,13 @@ function FieldLabel({
required: boolean
}) {
return (
-
-