From beddc8e42d3d55d436774ec714793264323bf7fd Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:32:44 +0200 Subject: [PATCH] feat(control): one question per screen on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /control put both full-width panes on a phone at once — the project detail and the 21-row roster, stacked. Whichever is second is off screen, so the layout was really a choice of which half to hide, and both orderings had been tried: list first → the one working agent sat under 20 idle rows, ~3 screens down detail first → (the previous fix) the page OPENED on a project you had not chosen, with the chooser below it The second is what shipped, and using it is how the cost shows up. Tapping a row changed a highlight and nothing else, because the pane it drives was already three scrolls above — so the tap read as broken. And getting back to the composer meant scrolling up THROUGH a nested scroller: measured at 671px tall, a 336px window holding 1835px of rows, inside a page with 1746px of its own scroll. A drag near the list scrolled the list; a drag elsewhere scrolled the page. Two scrollers, one gesture, unpredictable result. Ordering was standing in for the real fix: show ONE pane at a time. roster → "which project?" (the fleet, by name, prioritised) tap → detail → "what should it do?" (state, composer, one action) ‹ All projects → back From `lg` up nothing changes: both panes side by side, no back button, the rail keeps its capped scroll so it cannot run past the detail. WHY A NEW PIECE OF STATE. `selectedTab` cannot express "the operator opened a project": ControlPanel deliberately forces a selection whenever it is null, because the desktop two-column layout must never render an empty detail pane. Correct there, and exactly what made the phone open on someone else's choice. So `projectOpenOnPhone` answers the phone's own question and desktop ignores it. A `?focus=` deep link sets it too — arriving from a push notification or the failure banner and being shown the roster instead would make the link useless exactly when it matters. The 50dvh cap on the rail list is lifted below `lg`. It was right while the list shared the screen; now the list IS the screen when it shows, so the page scroll is the only scroll — one gesture, one result. Verified in a browser at 624px and 1742px: phone shows hero + roster with no detail card, tapping opens the project with a back control, back returns; ONE scroller throughout (app-main), down from two, and page content drops 2173px → 1335px because only one pane renders. Desktop: both panes visible, back button absent. audit:responsive clean at 320/390/768/1440; pnpm run verify passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P --- src/app/globals.css | 14 +++++-- src/components/control/ControlPanel.tsx | 26 ++++++++++++- .../control/ProjectOperationsView.tsx | 37 +++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 197986b2..e7e0f585 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3926,10 +3926,18 @@ .ui-control-project-detail { @apply order-1 min-w-0 lg:order-none; } - /* The rail list is a capped scroll pane on EVERY breakpoint — on phones an - uncapped list rendered all rows full-height (~3 screens of idle projects). */ + /* Capped and scrollable from `lg`, where the rail sits beside the detail and + must not run past it. + On a phone it is NOT capped, and that is the point. The 50dvh cap was + correct while the list shared the screen with the detail card, but it made + a scroller inside a scroller: measured at 671px tall, a 336px window + holding 1835px of rows, inside a page with 1746px of its own scroll. A + drag near the list scrolled the list; a drag anywhere else scrolled the + page; neither was predictable. Now the list is the WHOLE phone screen when + it is showing, so the page scroll is the only scroll — one gesture, one + result. */ .ui-control-project-list { - @apply flex max-h-[50dvh] flex-col gap-2 overflow-y-auto p-2 lg:block lg:max-h-[calc(100dvh-20rem)] lg:space-y-1; + @apply flex flex-col gap-2 p-2 lg:block lg:max-h-[calc(100dvh-20rem)] lg:space-y-1 lg:overflow-y-auto; } @media (min-width: 64rem) { .ui-control-project-list { diff --git a/src/components/control/ControlPanel.tsx b/src/components/control/ControlPanel.tsx index 4b9af27b..a0a197e4 100644 --- a/src/components/control/ControlPanel.tsx +++ b/src/components/control/ControlPanel.tsx @@ -80,6 +80,20 @@ export function ControlPanel() { }; const [activityOpen, setActivityOpen] = useState(false); const [selectedTab, setSelectedTab] = useState(null); + /** + * Has the operator OPENED a project, as opposed to one being auto-selected? + * + * `selectedTab` cannot answer that: the guard below forces a selection + * whenever it is null, because the desktop two-column layout must never + * render an empty detail pane. Correct there, wrong on a phone — it meant + * /control opened on one project's controls before you had chosen anything, + * with the chooser below it. Tapping a row then changed only a highlight, + * because the thing it selects was already on screen three scrolls up. + * + * So the phone needs its own answer to "am I looking at the list, or at a + * project?", and this is it. Desktop ignores it entirely. + */ + const [projectOpenOnPhone, setProjectOpenOnPhone] = useState(false); const [highlightTab, setHighlightTab] = useState(null); const [liveTargetTab, setLiveTargetTab] = useState(null); const livePanelRef = useRef(null); @@ -253,6 +267,11 @@ export function ControlPanel() { // eslint-disable-next-line react-hooks/set-state-in-effect -- Deep-link handler: an App Router param change only surfaces as a re-render, so this effect IS the event handler for /control?focus=…. It resolves the target once, atomically, then selects + highlights + scrolls + clears the params; the requestKey ref already guarantees it runs once per navigation. Splitting the setStates into render-time adjustments would resolve the target twice against possibly-different data mid-refresh. if (snapshotTab) setSelectedTab(snapshotTab); + // A deep link names a project, so on a phone it must LAND on that project + // rather than on the list — arriving from a push notification or the + // failure banner's "Open on Control" and being shown the roster instead + // would make the link useless exactly when it matters. + setProjectOpenOnPhone(true); setHighlightTab(resolvedTab); setLiveTargetTab(resolvedTab); if (liveDetailsRef.current) liveDetailsRef.current.open = true; @@ -507,7 +526,12 @@ export function ControlPanel() { { + setSelectedTab(tab); + setProjectOpenOnPhone(true); + }} + projectOpenOnPhone={projectOpenOnPhone} + onBackToList={() => setProjectOpenOnPhone(false)} cardProps={cardProps} automationMode={automationPolicy.mode} onBulkNotice={(msg) => { diff --git a/src/components/control/ProjectOperationsView.tsx b/src/components/control/ProjectOperationsView.tsx index 1a5142b7..eadffcc1 100644 --- a/src/components/control/ProjectOperationsView.tsx +++ b/src/components/control/ProjectOperationsView.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { Search } from "lucide-react"; +import { ChevronLeft, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { compactRelativeDate } from "@/lib/dates"; import { postJson } from "@/lib/api/fetch"; @@ -35,6 +35,8 @@ export function ProjectOperationsView({ snapshots, selectedTab, onSelect, + projectOpenOnPhone = false, + onBackToList, cardProps, automationMode, onBulkNotice, @@ -42,6 +44,11 @@ export function ProjectOperationsView({ snapshots: ProjectOperationsSnapshot[] | null; selectedTab: string | null; onSelect: (tab: string) => void; + /** Phone only: is the operator looking at a project, or at the roster? + * Ignored from `lg` up, where both panes are on screen together. */ + projectOpenOnPhone?: boolean; + /** Phone only: return to the roster. */ + onBackToList?: () => void; cardProps: (project: ProjectState) => CardBaseProps; automationMode: AutoInjectMode; /** Toast after bulk build/pause on the selected rail rows. */ @@ -182,7 +189,16 @@ export function ProjectOperationsView({ return (
-