diff --git a/src/features/workbench/components/workbench-agent-panels.tsx b/src/features/workbench/components/workbench-agent-panels.tsx index b699ca1..27bcc1b 100644 --- a/src/features/workbench/components/workbench-agent-panels.tsx +++ b/src/features/workbench/components/workbench-agent-panels.tsx @@ -36,7 +36,7 @@ export function WorkbenchAgentPanels({ return (
{panelOpen ? ( ) : ( -
+
{ ).toEqual({ top: 72, right: 952, bottom: 32, left: 476 }); }); + it("does not reserve map width for the collapsed Agent launcher", () => { + expect( + getWorkbenchCameraPadding(1440, { + agentOpen: false, + conditionOpen: false + }).left + ).toBe(24); + }); + it("uses the committed Agent width without exceeding the hard limit", () => { expect( getWorkbenchCameraPadding(1920, { diff --git a/src/features/workbench/layout/workbench-layout.ts b/src/features/workbench/layout/workbench-layout.ts index 097e222..d3af352 100644 --- a/src/features/workbench/layout/workbench-layout.ts +++ b/src/features/workbench/layout/workbench-layout.ts @@ -3,7 +3,7 @@ export const WORKBENCH_LAYOUT = { desktopMinWidth: 1024, persistentConditionMinWidth: 1280, wideMinWidth: 1536, - collapsedAgentWidth: 72, + collapsedAgentWidth: 0, maxAgentWidth: 720, agentPanelLeftInset: 12, conditionPanelRightInset: 64, diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 383ea7d..ee5bdf2 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -32,7 +32,6 @@ import { } from "@/features/map/core"; import { env } from "@/shared/config/env"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; -import { cn } from "@/shared/ui/cn"; import { AgentTaskTicker } from "./components/agent-task-ticker"; import { MapDevPanel } from "./components/map-dev-panel"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; @@ -41,10 +40,10 @@ import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { OperationalTimeline } from "./operational-timeline/operational-timeline"; import { - canDockMapScaleBesideTimeline, createOperationalEvents, getOperationalEventSummary, OPERATIONAL_TIMELINE_LAYOUT, + resolveTimelineScaleLayout, type OperationalEvent, type OperationalTimelineMode } from "./operational-timeline/operational-timeline-model"; @@ -216,11 +215,14 @@ export function MapWorkbenchPage({ () => getOperationalEventSummary(operationalEvents), [operationalEvents] ); - const scaleInfoBesideTimeline = canDockMapScaleBesideTimeline( + const timelineScaleLayout = resolveTimelineScaleLayout( businessWorkspaceWidth, timelineMode, timelineVisible ); + const timelineBottom = isLargeScreen + ? timelineScaleLayout.timelineBottom + : 12; useEffect(() => { setTimelineMode((current) => { @@ -927,8 +929,8 @@ export function MapWorkbenchPage({ !timelineVisible ? 0 : !isLargeScreen - ? OPERATIONAL_TIMELINE_LAYOUT.mobileSummaryHeight - : OPERATIONAL_TIMELINE_LAYOUT[timelineMode].height + ? OPERATIONAL_TIMELINE_LAYOUT.mobileSummaryHeight + timelineBottom + : OPERATIONAL_TIMELINE_LAYOUT[timelineMode].height + timelineBottom }px` } as CSSProperties } @@ -975,7 +977,7 @@ export function MapWorkbenchPage({ onCollapseArtifact={artifactWorkspace.collapseArtifact} onDestroyArtifact={artifactWorkspace.destroyArtifact} timelineMode={timelineMode} - reserveMapScaleSpace={scaleInfoBesideTimeline} + timelineBottom={timelineBottom} timelineContent={( timelineVisible ? <>
@@ -1022,17 +1024,17 @@ export function MapWorkbenchPage({
- +
)} diff --git a/src/features/workbench/operational-timeline/operational-timeline-model.test.ts b/src/features/workbench/operational-timeline/operational-timeline-model.test.ts index 69c1d8a..4159bb2 100644 --- a/src/features/workbench/operational-timeline/operational-timeline-model.test.ts +++ b/src/features/workbench/operational-timeline/operational-timeline-model.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ScheduledConditionItem } from "../types"; import { - canDockMapScaleBesideTimeline, + canCenterTimelineBesideMapScale, clusterOperationalEvents, - createOperationalEvents + createOperationalEvents, + resolveTimelineScaleLayout } from "./operational-timeline-model"; const workOrder: ScheduledConditionItem = { @@ -44,9 +45,20 @@ describe("operational timeline model", () => { expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2); }); - it("docks scale info beside a floating timeline only when the business area is wide enough", () => { - expect(canDockMapScaleBesideTimeline(2048, "expanded", true)).toBe(true); - expect(canDockMapScaleBesideTimeline(1380, "expanded", true)).toBe(false); - expect(canDockMapScaleBesideTimeline(1380, "expanded", false)).toBe(true); + it("keeps a centered timeline beside scale info only when both side margins are wide enough", () => { + expect(canCenterTimelineBesideMapScale(2560, "expanded", true)).toBe(true); + expect(canCenterTimelineBesideMapScale(2048, "expanded", true)).toBe(false); + expect(canCenterTimelineBesideMapScale(1380, "expanded", false)).toBe(true); + }); + + it("keeps scale info in the map corner and moves the timeline when horizontal space is tight", () => { + expect(resolveTimelineScaleLayout(2560, "expanded", true)).toEqual({ + scaleBottom: 0, + timelineBottom: 24 + }); + expect(resolveTimelineScaleLayout(2048, "expanded", true)).toEqual({ + scaleBottom: 0, + timelineBottom: 56 + }); }); }); diff --git a/src/features/workbench/operational-timeline/operational-timeline-model.ts b/src/features/workbench/operational-timeline/operational-timeline-model.ts index c6e3dab..034441e 100644 --- a/src/features/workbench/operational-timeline/operational-timeline-model.ts +++ b/src/features/workbench/operational-timeline/operational-timeline-model.ts @@ -31,15 +31,22 @@ export type OperationalEventCluster = { }; export const OPERATIONAL_TIMELINE_LAYOUT = { - compact: { height: 72, maxWidth: 980 }, + compact: { height: 88, maxWidth: 980 }, expanded: { height: 240, maxWidth: 1180 }, summary: { height: 52, maxWidth: 760 }, mobileSummaryHeight: 48, mapScaleSafeWidth: 520, - floatingGap: 48 + floatingGap: 48, + edgeInset: 24, + scaleCollisionOffset: 56 } as const; -export function canDockMapScaleBesideTimeline( +export type TimelineScaleLayout = { + scaleBottom: number; + timelineBottom: number; +}; + +export function canCenterTimelineBesideMapScale( businessWorkspaceWidth: number, mode: OperationalTimelineMode, visible: boolean @@ -48,8 +55,30 @@ export function canDockMapScaleBesideTimeline( const layout = OPERATIONAL_TIMELINE_LAYOUT[mode]; return businessWorkspaceWidth >= layout.maxWidth + - OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + - OPERATIONAL_TIMELINE_LAYOUT.floatingGap; + 2 * ( + OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + + OPERATIONAL_TIMELINE_LAYOUT.floatingGap + ); +} + +export function resolveTimelineScaleLayout( + businessWorkspaceWidth: number, + mode: OperationalTimelineMode, + visible: boolean +): TimelineScaleLayout { + const scaleClearsCenteredTimeline = canCenterTimelineBesideMapScale( + businessWorkspaceWidth, + mode, + visible + ); + + return { + scaleBottom: 0, + timelineBottom: + visible && !scaleClearsCenteredTimeline + ? OPERATIONAL_TIMELINE_LAYOUT.scaleCollisionOffset + : OPERATIONAL_TIMELINE_LAYOUT.edgeInset + }; } export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] { diff --git a/src/features/workbench/operational-timeline/operational-timeline.tsx b/src/features/workbench/operational-timeline/operational-timeline.tsx index 3238863..9abc7b6 100644 --- a/src/features/workbench/operational-timeline/operational-timeline.tsx +++ b/src/features/workbench/operational-timeline/operational-timeline.tsx @@ -1,13 +1,14 @@ import { CalendarClock, Check, - ChevronDown, - ChevronUp, Clock3, OctagonAlert, + PanelBottomClose, + PanelBottomOpen, RotateCcw, TriangleAlert } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/shared/ui/cn"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"; import { @@ -51,18 +52,24 @@ export function OperationalTimeline({ onClearSelection, onReturnNow }: OperationalTimelineProps) { + const prefersReducedMotion = useReducedMotion(); const summary = getOperationalEventSummary(events); const clusters = clusterOperationalEvents(events, mobile ? 60 : 10); const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null; const cursorPosition = getDayPosition(cursorTime); const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height; + const sizeTransition = prefersReducedMotion + ? { duration: 0 } + : { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const }; if (mode === "summary" && !mobile) { return ( -
+ ); } return ( -
@@ -124,8 +133,16 @@ export function OperationalTimeline({
- {mode === "expanded" || mobile ? ( -
+ + {mode === "expanded" || mobile ? ( +
{LANES.map((lane) => ( @@ -150,11 +167,18 @@ export function OperationalTimeline({
-
+ ) : ( -
+ -
+
{clusters.map((cluster, index) => (
-
+ )} -
+ +
); } @@ -316,26 +341,35 @@ function TimelineActions({ onReturnNow: () => void; }) { return ( -
+
{onModeChange ? ( - + <> +
); diff --git a/src/features/workbench/workspace/workbench-main-frame.tsx b/src/features/workbench/workspace/workbench-main-frame.tsx index 6704a6d..1a42461 100644 --- a/src/features/workbench/workspace/workbench-main-frame.tsx +++ b/src/features/workbench/workspace/workbench-main-frame.tsx @@ -1,4 +1,5 @@ import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; import { cn } from "@/shared/ui/cn"; import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model"; @@ -15,7 +16,7 @@ type WorkbenchMainFrameProps = { mapOverlay?: ReactNode; timelineContent: ReactNode; timelineMode: "compact" | "expanded" | "summary"; - reserveMapScaleSpace: boolean; + timelineBottom: number; activeArtifact: AnalysisArtifact | null; pendingArtifact: AnalysisArtifact | null; surfaceMode: WorkbenchSurfaceMode; @@ -32,7 +33,7 @@ export function WorkbenchMainFrame({ mapOverlay, timelineContent, timelineMode, - reserveMapScaleSpace, + timelineBottom, activeArtifact, pendingArtifact, surfaceMode, @@ -43,8 +44,12 @@ export function WorkbenchMainFrame({ onCollapseArtifact, onDestroyArtifact }: WorkbenchMainFrameProps) { + const prefersReducedMotion = useReducedMotion(); const showMap = surfaceMode !== "focus"; const showArtifact = activeArtifact && surfaceMode !== "map_only"; + const timelineTransition = prefersReducedMotion + ? { duration: 0 } + : { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }; return (
-
- {timelineContent} -
+ + {timelineContent ? ( + + {timelineContent} + + ) : null} + +
); }