# Slice 8 Review — Message compose + WidgetConfigDialog mobile forms **Change:** `mobile-responsive-parity` · **Slice:** 8 (R4.1, R4.2, R4.4) **Reviewer mode:** fresh adversarial · **Date:** 2026-06-26 **Verdict: commit** (no blockers; two non-blocking suggestions) --- ## Verification commands ``` cd frontend && npm run lint → 0 errors, 2 warnings (PRE-EXISTING, confirmed via git stash) cd frontend && npm run build → ✓ built (tsc -b + vite), 1977 modules cd frontend && npm run test → 28 files, 116 tests passed ``` The two lint warnings (`react-hooks/exhaustive-deps` on `baseRows`/`rows` useMemo, UsersPage.impl.tsx:150/189) exist on the committed Slice 7 tree and are unrelated to this diff. --- ## 1. Desktop non-regression — CONFIRMED for BOTH components ### UsersPage compose (`UsersPage.impl.tsx`) - The shared `composeBody` const (IIFE, lines ~820–985) bundles exactly the same children the desktop `DialogContent` rendered before: `Progress` (when pending) followed by `
` containing the error/success alerts, queue banner, recipient badges, subject input, formatting toolbar, body textarea, preview iframe, and attachment UI. - Desktop branch (lines ~1029–1072) renders `` → `{composeBody}` → `` with the same Cancel / `Send message` buttons, same `disabled` condition (`sendUserMessage.isPending || !selectedDeliverableRows.length || !subject.trim()`), and same `onClick={handleSend}`. Token-identical to the pre-slice output. - **768–900px range preserved:** the desktop branch still applies `isComposeMobile` (`useComposeViewport("(max-width: 900px)")`, line 139) as the fullscreen className on `DialogContent`. In that band `isMobile` (768px) is false and `isComposeMobile` (900px) is true → Dialog renders fullscreen. Unchanged. ### WidgetConfigDialog (`WidgetConfigDialog.tsx`) - `draftBody` (lines ~284–470) is the shared const covering both the draft branch and the list branch. Desktop path renders `{dialogTitle}` then `{draftBody}`. - `dialogTitle` (line ~459) reproduces the exact original ternary: `draft ? (draft.id ? "Edit widget" : "Add widget") : "Dashboard widgets"`. - The inline Back/Save buttons in the draft branch are gated by `{!isMobile ? (...) : null}` (line ~332). On desktop `isMobile=false` → they render identically to before. List branch content (sorted instance rows, reorder/enable/edit/delete actions, Add-widget buttons, help text) is byte-for-byte the same JSX, only re-indented. - Confirmed token-identical desktop output. --- ## 2. Compose SheetForm wiring — CONFIRMED `UsersPage.impl.tsx` mobile branch (lines ~1006–1027): - `title="Message selected users"` ✓ - `onSave={handleSend}` ✓ — `handleSend` (line 365) does `await mutateAsync` then `setComposeOpen(false)` + clears state → **R4.5 close-on-success satisfied** - `onCancel={closeCompose}` ✓ — `closeCompose` (line 317) closes + `sendUserMessage.reset()` - `isPending={sendUserMessage.isPending}` ✓ - `saveDisabled={!selectedDeliverableRows.length || !subject.trim()}` ✓ (mirrors desktop) - `saveLabel="Send message"` ✓ - Attachment UI (Paperclip + remove badges) is inside `composeBody`, preserved ✓ --- ## 3. WidgetConfigDialog two-mode SheetForm — CONFIRMED Mobile branch (lines ~471–488): - `title={dialogTitle}` → "Dashboard widgets" (list) / "Add widget" | "Edit widget" (draft) ✓ - `onSave={draft ? saveDraft : () => handleClose(false)}` — list "Done" closes, draft saves ✓ - `onCancel={draft ? reset : () => handleClose(false)}` — **draft Cancel = reset (back to list, NOT close)**, list Cancel closes ✓ - `saveLabel={draft ? "Save widget" : "Done"}` ✓ - `isPending={draft ? saveWidget.isPending : false}` ✓ - `onOpenChange={(next) => { if (!next) handleClose(next); }}` ✓ - List↔draft↔save flow intact: `startAddBuiltIn`/`startAddService`/`startEdit` set `draft` → footer/title reactive-swap to draft mode; `saveDraft` mutates then `reset()` returns to list (sheet stays open); `reset` returns to list without closing ✓ - The "both close in list mode" redundancy (Done + Cancel both call `handleClose(false)`) is functional and matches the documented intent ✓ --- ## 4. Rules of Hooks — CONFIRMED clean **WidgetConfigDialog:** all hooks (`useWidgetInstances`, `useServiceInstances`, `useTasks`, `useSaveWidgetInstance`, `useDeleteWidgetInstance`, `useState`, `useMemo`, `useIsMobile`) are called unconditionally at the top of the component before the `if (isMobile) return …` early return. `useIsMobile()` is placed after `draftBinding` (a plain derived value, not a hook) — no ordering violation. ESLint `react-hooks/rules-of-hooks` produced **0 errors**. **UsersPage:** the compose branch uses an IIFE `{(() => { … })()}` that declares `composeBody` as a JSX const (no hooks, no state) and returns either `` or ``. No hooks are called inside the IIFE; no state is introduced or leaked. Clean. --- ## 5. IIFE pattern — CONFIRMED correct The IIFE only constructs a local `composeBody` JSX expression and branches on the already-computed `isMobile` boolean. It introduces no closures over hooks, performs no side effects, and returns a single root element. It does not leak state. The only cost is readability (a moderately large nested expression), which is acceptable. --- ## 6. Test quality — ADEQUATE (one suggestion) - **UsersPage compose mobile test** (UsersPage.test.tsx:345–376): real behavioral assertion — selects a deliverable user via the mobile card checkbox, opens compose, and asserts the SheetForm title ("Message selected users"), the "Send message" footer button, and the Subject input render. Not a pure smoke test. - **WidgetConfigDialog tests** (new file, 2 cases): desktop asserts the Dialog heading "Dashboard widgets"; mobile asserts the SheetForm title + "Done" footer button. These are **smoke-level only** — they do not exercise the draft-mode footer ("Save widget"), the `reset`-back-to-list Cancel behavior, or the list→draft→save round trip. See Suggestion S1. All 3 new tests pass; AC8 (Vitest case per touched component at <768px and ≥768px) is satisfied. --- ## 7. Diff size — CONFIRMED mostly re-indentation `git diff --stat`: 472 insertions / 391 deletions across 3 files (~863 changed lines). The actual behavioral delta is small and bounded: - compose mobile `` branch + `composeBody` extraction guard: ~25 lines - WidgetConfigDialog mobile `` branch + `draftBody` extraction + `!isMobile` button guard + `dialogTitle`/`useIsMobile` lines: ~30 lines - New + updated tests: ~65 lines The remaining ~740 lines are extraction/re-indentation of unchanged JSX into the shared consts, consistent with the task brief. No scope creep: no backend, no other pages, no new dependencies. --- ## Suggestions (non-blocking) **S1 — WidgetConfigDialog mobile draft-mode test.** Add one mobile test that opens the dialog, taps an "Add widget" button, and asserts the footer swaps to "Save widget" and that Cancel returns to the list view (title reverts to "Dashboard widgets") without closing the sheet. This would cover the most error-prone part of the two-mode wiring and is currently untested. **S2 — R4.5 dirty-state outside-click confirm (cross-slice, not slice-8).** `SheetForm` does not implement the spec'd "do not close on outside-click while the form is dirty" guard; Radix `Sheet` dismisses the overlay by default, calling `onOpenChange(false)`. This is a property of the shared primitive landed in Slice 1 and inherited by Slices 6, 7, and 8 — not a regression introduced here. Flagging as a residual risk to be addressed when the SheetForm primitive is revisited (or accept the deviation explicitly in the verify report). --- ## Residual risks / repo hygiene - `swap-pane` and `.pi-tmp/` are untracked and unrelated to this slice; ensure only the three intended files (`WidgetConfigDialog.tsx`, `UsersPage.impl.tsx`, `UsersPage.test.tsx`) plus the new `components/__tests__/WidgetConfigDialog.test.tsx` are staged for the Slice 8 commit. - No staged files currently (`git diff --cached` empty). Good. --- ## Conclusion Desktop output is token-identical for both components; mobile SheetForm wiring is correct for compose (single mode) and WidgetConfigDialog (two-mode list/draft); Rules of Hooks and the IIFE are clean; lint/build/test are green. **Verdict: commit.**