fix: hover card, preferences modal, timeline scroll, multi-drag

- ResourceHoverCard: add isInitialLoading to useEffect deps so
  mouseover/mouseout listeners attach after canvas mounts
- PreferencesModal: lift prefsOpen state to AppShell, render modal
  outside sidebar's backdrop-blur stacking context
- Timeline page: constrain to max-h-[100dvh] overflow-hidden so
  horizontal scrollbar is accessible without scrolling to bottom
- Multi-drag: pass selectedAllocationIds from ref at drag completion
  to prevent stale closure in onMultiDragComplete callback

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 20:30:43 +01:00
parent 5ffc0d92e4
commit c02f453679
4 changed files with 24 additions and 9 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ const TimelineView = dynamic(
export default function TimelinePage() {
return (
<div className="app-page flex h-full flex-col gap-5 pb-6">
<div className="app-page flex max-h-[100dvh] flex-col gap-5 overflow-hidden pb-6">
<div className="app-page-header">
<div>
<h1 className="app-page-title">Timeline</h1>
+16 -3
View File
@@ -295,15 +295,16 @@ function SidebarContent({
sidebarCollapsed,
onToggleCollapse,
onNavClick,
onPrefsOpen,
}: {
userRole: string;
onChatOpen: () => void;
sidebarCollapsed: boolean;
onToggleCollapse: () => void;
onNavClick?: () => void;
onPrefsOpen: () => void;
}) {
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
const activeHrefSet = useMemo(() => {
const set = new Set<string>();
@@ -594,7 +595,7 @@ function SidebarContent({
<NavTooltip label="Preferences" show={sidebarCollapsed}>
<button
type="button"
onClick={() => setPrefsOpen(true)}
onClick={onPrefsOpen}
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
@@ -646,7 +647,6 @@ function SidebarContent({
</NavTooltip>
</div>
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
</>
);
}
@@ -660,11 +660,13 @@ function DesktopSidebar({
onChatOpen,
sidebarCollapsed,
onToggleCollapse,
onPrefsOpen,
}: {
userRole: string;
onChatOpen: () => void;
sidebarCollapsed: boolean;
onToggleCollapse: () => void;
onPrefsOpen: () => void;
}) {
return (
<nav
@@ -679,6 +681,7 @@ function DesktopSidebar({
onChatOpen={onChatOpen}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapse={onToggleCollapse}
onPrefsOpen={onPrefsOpen}
/>
</nav>
);
@@ -693,11 +696,13 @@ function MobileSidebar({
onClose,
userRole,
onChatOpen,
onPrefsOpen,
}: {
open: boolean;
onClose: () => void;
userRole: string;
onChatOpen: () => void;
onPrefsOpen: () => void;
}) {
return (
<AnimatePresence>
@@ -738,6 +743,10 @@ function MobileSidebar({
sidebarCollapsed={false}
onToggleCollapse={() => {}}
onNavClick={onClose}
onPrefsOpen={() => {
onPrefsOpen();
onClose();
}}
/>
</motion.nav>
</>
@@ -754,6 +763,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
const [chatOpen, setChatOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [prefsOpen, setPrefsOpen] = useState(false);
const pathname = usePathname();
const contentRef = useRef<HTMLElement>(null);
@@ -802,6 +812,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
onChatOpen={() => setChatOpen(true)}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapse={handleToggleCollapse}
onPrefsOpen={() => setPrefsOpen(true)}
/>
{/* Mobile sidebar overlay */}
@@ -810,6 +821,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
onClose={() => setMobileOpen(false)}
userRole={userRole}
onChatOpen={() => setChatOpen(true)}
onPrefsOpen={() => setPrefsOpen(true)}
/>
{/* Main content area */}
@@ -844,6 +856,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
</svg>
</button>
)}
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
</ThemeProvider>
);
}
@@ -135,8 +135,8 @@ export function TimelineView() {
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
},
onMultiDragComplete: (daysDelta, mode) => {
const ids = multiSelectState.selectedAllocationIds;
onMultiDragComplete: (daysDelta, mode, selectedIds) => {
const ids = selectedIds ?? multiSelectState.selectedAllocationIds;
if (ids.length > 0 && daysDelta !== 0) {
pushBatchHistoryRef.current(ids, daysDelta, mode);
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
@@ -547,7 +547,7 @@ function TimelineViewContent({
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [resourceHover?.resourceId, isInitialLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() {
+4 -2
View File
@@ -178,7 +178,7 @@ export function useTimelineDrag({
onRangeSelected?: (info: RangeSelectedInfo) => void;
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
onShiftClickAlloc?: (allocationId: string) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode, selectedIds?: string[]) => void;
}) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
@@ -394,7 +394,9 @@ export function useTimelineDrag({
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
if (finalDelta !== 0) {
onMultiDragCompleteRef.current?.(finalDelta, dragMode);
// Pass IDs from ref to avoid stale closure in the callback
const ids = multiSelectRef.current.selectedAllocationIds;
onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids);
}
}