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