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 ( -
-