From f19471ab58ee5a7f4aec450f6c9e5665994944aa Mon Sep 17 00:00:00 2001 From: 0x1355 <0x1355@gmail.com> Date: Thu, 19 Mar 2026 10:39:13 +0100 Subject: [PATCH] Fix stale job detail showing wrong tab actions on tab switch (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When switching between job tabs (e.g. Discovered → Applied), the detail panel would briefly show the previously selected job with the new tab's action buttons — confusing and error-prone. Three coordinated fixes: - setActiveTab now checks if the selected job's status fits the target tab, keeping it when valid (e.g. Discovered → All Jobs) and clearing it otherwise. - New visibleSelectedJob useMemo guard synchronously nulls out the selected job when it doesn't belong to the active tab, eliminating the one-frame flash caused by the data hook's useEffect lag. - Auto-select effect now handles the case where a job passes the status check but gets filtered out by source/sponsor/salary filters. --- .../client/pages/OrchestratorPage.test.tsx | 7 ++- .../src/client/pages/OrchestratorPage.tsx | 44 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 84e0014..86fb483 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -1090,13 +1090,16 @@ describe("OrchestratorPage", () => { ); }); + // Update mock so selectedJob matches the discovered tab — visibleSelectedJob + // filters out jobs whose status doesn't belong to the active tab. + mockSelectedJob = job2; + fireEvent.click(screen.getByTestId("select-job-2")); pressKey("r"); await waitFor(() => { expect(toast.message).toHaveBeenCalledWith("Moving job to Ready..."); - // Mock useOrchestratorData returns selectedJob as job-1 always - expect(api.processJob).toHaveBeenCalledWith("job-1"); + expect(api.processJob).toHaveBeenCalledWith("job-2"); }); }); diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 27642d0..ff4617b 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -7,7 +7,7 @@ import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar"; import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog"; import { useDemoInfo } from "../hooks/useDemoInfo"; -import type { FilterTab } from "./orchestrator/constants"; +import { type FilterTab, tabs } from "./orchestrator/constants"; import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar"; import { JobCommandBar } from "./orchestrator/JobCommandBar"; import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; @@ -93,13 +93,6 @@ export const OrchestratorPage: React.FC = () => { : false, ); - const setActiveTab = useCallback( - (newTab: FilterTab) => { - navigateWithContext(newTab, selectedJobId); - }, - [navigateWithContext, selectedJobId], - ); - const handleSelectJobId = useCallback( (id: string | null) => { navigateWithContext(activeTab, id); @@ -154,6 +147,35 @@ export const OrchestratorPage: React.FC = () => { salaryFilter, sort, ); + const setActiveTab = useCallback( + (newTab: FilterTab) => { + // Keep selected job if it belongs to the target tab, otherwise clear it. + // The auto-select effect will pick the first job on desktop when cleared. + const tabDef = tabs.find((t) => t.id === newTab); + const selectedItem = selectedJobId + ? jobs.find((j) => j.id === selectedJobId) + : null; + const jobFitsTab = + selectedItem && + (tabDef?.statuses.length === 0 || + tabDef?.statuses.includes(selectedItem.status)); + navigateWithContext(newTab, jobFitsTab ? selectedJobId : null); + }, + [navigateWithContext, selectedJobId, jobs], + ); + + // Synchronously null-out selectedJob when it doesn't belong to the current + // tab. The data hook resolves selectedJob from the full (unfiltered) job list + // via useEffect, so it lags by one render frame after a tab switch — without + // this guard the detail panel would briefly show the old job with the new + // tab's action buttons. + const visibleSelectedJob = useMemo(() => { + if (!selectedJob) return null; + const tabDef = tabs.find((t) => t.id === activeTab); + if (!tabDef || tabDef.statuses.length === 0) return selectedJob; + return tabDef.statuses.includes(selectedJob.status) ? selectedJob : null; + }, [selectedJob, activeTab]); + const counts = useMemo(() => getJobCounts(jobs), [jobs]); const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]); const { @@ -222,7 +244,7 @@ export const OrchestratorPage: React.FC = () => { activeTab, activeJobs, selectedJobId, - selectedJob, + selectedJob: visibleSelectedJob, selectedJobIds, isDesktop, handleSelectJobId, @@ -394,7 +416,7 @@ export const OrchestratorPage: React.FC = () => { {