Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 25 additions & 1 deletion src/components/control/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ export function ControlPanel() {
};
const [activityOpen, setActivityOpen] = useState(false);
const [selectedTab, setSelectedTab] = useState<string | null>(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<string | null>(null);
const [liveTargetTab, setLiveTargetTab] = useState<string | null>(null);
const livePanelRef = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -507,7 +526,12 @@ export function ControlPanel() {
<ProjectOperationsView
snapshots={snapshots}
selectedTab={selectedTab}
onSelect={setSelectedTab}
onSelect={(tab) => {
setSelectedTab(tab);
setProjectOpenOnPhone(true);
}}
projectOpenOnPhone={projectOpenOnPhone}
onBackToList={() => setProjectOpenOnPhone(false)}
cardProps={cardProps}
automationMode={automationPolicy.mode}
onBulkNotice={(msg) => {
Expand Down
37 changes: 34 additions & 3 deletions src/components/control/ProjectOperationsView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,13 +35,20 @@ export function ProjectOperationsView({
snapshots,
selectedTab,
onSelect,
projectOpenOnPhone = false,
onBackToList,
cardProps,
automationMode,
onBulkNotice,
}: {
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. */
Expand Down Expand Up @@ -182,7 +189,16 @@ export function ProjectOperationsView({

return (
<section id="control-projects" className="ui-control-workspace scroll-mt-24">
<aside className="ui-control-project-rail">
{/* ONE PANE AT A TIME ON A PHONE.
Both panes are full-width and stacked below `lg`, so whichever sits
second is off-screen. Ordering them was tried both ways and neither
works: list-first buried the working agent under 20 idle rows, and
detail-first (the previous fix) opened on a project you had not
chosen, with the chooser below it — so tapping a row appeared to do
nothing, because the pane it drives was already three scrolls above.
Showing one at a time is the fix the ordering was standing in for.
From `lg` both are visible side by side and this changes nothing. */}
<aside className={cn("ui-control-project-rail", projectOpenOnPhone && "max-lg:hidden")}>
<div className="border-b border-border-subtle px-4 pb-3 pt-4">
<h2 className="text-sm font-semibold text-text-primary">Projects</h2>
{/* Vocabulary matches the row badges and the fleet chips: "awaiting
Expand Down Expand Up @@ -341,7 +357,22 @@ export function ProjectOperationsView({
</div>
</aside>

<div className="ui-control-project-detail">
<div className={cn("ui-control-project-detail", !projectOpenOnPhone && "max-lg:hidden")}>
{/* The way back. A phone that swaps one full-screen pane for another
owes you an exit, and the browser's Back button is not it — the
selection is component state, not a route, so Back would leave
/control entirely. Named, not a bare chevron: it says where it
returns you to. */}
{onBackToList && (
<button
type="button"
onClick={onBackToList}
className="ui-btn-ghost ui-btn-xs mb-2 gap-1 lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" />
All projects
</button>
)}
<ProjectCard
key={selected.project.tab}
{...cardProps(selected.project)}
Expand Down
Loading