From b01e8707ee26635ea7b9aa922a33bc5015d248e2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:07:54 -0400 Subject: [PATCH 1/2] feat(sessions): full-screen mobile question sheet with persisted draft answers --- .../components/session/FormPrompt.test.tsx | 136 ++++++++++++++- .../src/components/session/FormPrompt.tsx | 160 ++++++++++++------ frontend/src/lib/formDrafts.ts | 91 ++++++++++ frontend/src/pages/SessionDetail.tsx | 134 ++++++++------- .../SessionDetail.form-prompt.test.tsx | 43 ++++- 5 files changed, 443 insertions(+), 121 deletions(-) create mode 100644 frontend/src/lib/formDrafts.ts diff --git a/frontend/src/components/session/FormPrompt.test.tsx b/frontend/src/components/session/FormPrompt.test.tsx index 45c42320b..b47b89e0c 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,36 @@ 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')) }) }) @@ -628,6 +669,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 +754,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..6e7d01027 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 { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { X, ChevronDown, ChevronUp, Check, Loader2 } 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 = {} @@ -70,12 +66,33 @@ interface FormPromptProps { export function FormPrompt({ form, onReply, onCancel, onMinimize }: 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 +149,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 +158,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 +171,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 +184,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 +204,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 +226,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 +238,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,12 +397,12 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
)} -
+
@@ -358,9 +410,9 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr size="sm" onClick={handleSubmit} disabled={isSubmitting || !canSubmit} - className="flex-1 h-8 sm:h-10 text-xs sm:text-sm bg-success hover:bg-success/90 text-success-foreground" + className="flex-1 h-[52px] rounded-[14px] text-[17px] font-bold bg-success hover:bg-success/90 text-success-foreground md:h-10 md:rounded-md md:text-sm md:font-medium" > - {isSubmitting ? : 'Submit'} + {isSubmitting ? : 'Submit'}
@@ -379,13 +431,13 @@ function FieldLabel({ required: boolean }) { return ( -
-