From 760370c93f14b1ca5094265d22503990bd7256c4 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 19 Aug 2026 16:53:21 +0800 Subject: [PATCH] fix: refine floating workspace controls Keep ScaleInfo pinned to the map corner and move the centered timeline above collisions, preventing the previous conditional offset from lifting the scale. Remove collapsed Agent rail width reservation and add timeline state transitions. --- .../components/workbench-agent-panels.tsx | 4 +- .../workbench/layout/workbench-layout.test.ts | 9 ++ .../workbench/layout/workbench-layout.ts | 2 +- src/features/workbench/map-workbench-page.tsx | 28 +++--- .../operational-timeline-model.test.ts | 24 +++-- .../operational-timeline-model.ts | 39 ++++++-- .../operational-timeline.tsx | 88 +++++++++++++------ .../workspace/workbench-main-frame.tsx | 39 +++++--- 8 files changed, 168 insertions(+), 65 deletions(-) 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} + +
); }