Delete the old top-level page files whose content was migrated into service-page tabs in slices 5-9: - pages/Media.tsx, Applications.tsx (-> MediaTab) - pages/FileBrowser.tsx, FileBrowser.impl.tsx (-> FilesTab) - pages/Actions.tsx (-> ActionsTab) - pages/Users.tsx, UsersPage.impl.tsx (replaced by Authentik tabs) - components/BackupsPage.tsx (-> JobsTab) - components/ObservabilityPage.tsx (split into Alerts/Links/Metrics tabs) - hooks/useUsers.ts (orphaned after Users page deletion) - the corresponding page test files (Media, FileBrowser, Applications, Actions, UsersPage) that tested the deleted pages directly. The service-tab components are the live implementations; ServicePage renders them. No live code references the deleted files. Docs: append an Information Architecture section to REQUIREMENTS.md documenting the services-as-hub model (nav shape, service-page tabs, service type registry, Users->Authentik, Observability split, legacy route 404s, empty state). Add a CHANGELOG entry under [Unreleased]. 92 frontend tests pass (was 112; -20 deleted page tests); 271 backend tests pass; lint/build green. Refs openspec/changes/services-as-hub-ia/ (tasks slice 11).
8.5 KiB
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
composeBodyconst (IIFE, lines ~820–985) bundles exactly the same children the desktopDialogContentrendered before:Progress(when pending) followed by<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">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
<DialogHeader>→{composeBody}→<DialogFooter>with the same Cancel /Send messagebuttons, samedisabledcondition (sendUserMessage.isPending || !selectedDeliverableRows.length || !subject.trim()), and sameonClick={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 onDialogContent. In that bandisMobile(768px) is false andisComposeMobile(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<DialogHeader><DialogTitle>{dialogTitle}</DialogTitle></DialogHeader>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 desktopisMobile=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) doesawait mutateAsyncthensetComposeOpen(false)+ clears state → R4.5 close-on-success satisfiedonCancel={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/startEditsetdraft→ footer/title reactive-swap to draft mode;saveDraftmutates thenreset()returns to list (sheet stays open);resetreturns 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 <SheetForm>… 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 <SheetForm>
or <Dialog>. 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
<SheetForm>branch +composeBodyextraction guard: ~25 lines - WidgetConfigDialog mobile
<SheetForm>branch +draftBodyextraction +!isMobilebutton guard +dialogTitle/useIsMobilelines: ~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-paneand.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 newcomponents/__tests__/WidgetConfigDialog.test.tsxare staged for the Slice 8 commit.- No staged files currently (
git diff --cachedempty). 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.