fix(workbench): refine agent dock and smooth map resizing

Unify dock surfaces, headers and dividers, and remove the visible context strip.
Limit pointer resizing to 400-640px and reveal the edge grip on hover.
Resize and redraw the map in the same layout cycle to prevent blanking and lag.
Preserve horizontal centering during conversation control press feedback.

Validation: isolated lint, typecheck, 21 focused unit tests, and 10 relevant
browser checks passed. Snapshot baselines regenerated from the isolated change.
The existing mobile alert summary test also fails on unchanged HEAD because
the expected demo alert source is unavailable in this environment.
This commit is contained in:
2026-10-08 17:34:42 +08:00
parent 7c6d06cb43
commit d2d705ed4c
19 changed files with 680 additions and 170 deletions
@@ -1,6 +1,6 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import { useEffect, type ComponentProps } from "react";
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
import { useLayoutEffect, type ComponentProps } from "react";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
@@ -11,6 +11,7 @@ type WorkbenchAgentPanelsProps = {
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
viewportWidth: number;
conditionExpanded?: boolean;
onCollapsePanel: () => void;
onExpandPanel: () => void;
onWidthCommit?: (width: number) => void;
@@ -23,28 +24,48 @@ export function WorkbenchAgentPanels({
personaState,
statusLabel,
viewportWidth,
conditionExpanded = false,
onCollapsePanel,
onExpandPanel,
onWidthCommit
}: WorkbenchAgentPanelsProps) {
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const { handlePointerDown, panelRef, resizing, width } = useAgentPanelResize(
viewportWidth,
conditionExpanded
);
useEffect(() => {
onWidthCommit?.(committedWidth);
}, [committedWidth, onWidthCommit]);
useLayoutEffect(() => {
onWidthCommit?.(width);
}, [width, onWidthCommit]);
return (
<div
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
style={{ width: panelOpen ? committedWidth : 0 }}
ref={panelRef}
className={`pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block${resizing ? " select-none" : ""}`}
style={{ width: panelOpen ? width : 0 }}
>
{panelOpen ? (
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<>
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
{!panelCollapsing ? (
<div
role="separator"
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
data-resizing={resizing}
onPointerDown={handlePointerDown}
className="group pointer-events-auto absolute inset-y-0 -right-1.5 z-10 w-3 cursor-col-resize touch-none"
>
<span className="pointer-events-none absolute inset-y-0 left-[5px] w-px bg-transparent transition-colors duration-150 group-hover:bg-blue-300/70 group-data-[resizing=true]:bg-blue-400/80 motion-reduce:transition-none" />
<span className="agent-resize-grip pointer-events-none absolute left-1/2 top-1/2 h-11 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-400 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[resizing=true]:bg-blue-500 group-data-[resizing=true]:opacity-100 motion-reduce:transition-none" />
</div>
) : null}
</>
) : (
<div className="absolute left-3 top-5">
<AgentCollapsedRail
@@ -104,7 +104,7 @@ export function WorkbenchTopBar({
};
return (
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
<header className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
<div
className={cn(
@@ -3,74 +3,55 @@ import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from "react";
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getAgentPanelDefaultWidth,
getAgentPanelMaxWidth
getWorkbenchViewportLayout
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
const panelRef = useRef<HTMLDivElement | null>(null);
const resizeLeftRef = useRef(0);
const cleanupRef = useRef<(() => void) | null>(null);
const widthRef = useRef<number | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
useEffect(() => {
if (widthRef.current === null) {
return;
}
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
widthRef.current = nextWidth;
setWidth(nextWidth);
setCommittedWidth(nextWidth);
}, [conditionExpanded, viewportWidth]);
const resize = useCallback(
(clientX: number) => {
const nextWidth = clampAgentPanelWidth(
clientX - resizeLeftRef.current,
window.innerWidth,
conditionExpanded
);
widthRef.current = nextWidth;
setWidth(nextWidth);
},
[conditionExpanded]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !panelRef.current) return;
event.preventDefault();
resizeLeftRef.current = panelRef.current.getBoundingClientRect().left;
setResizing(true);
resize(event.clientX);
cleanupRef.current?.();
const startX = event.clientX;
const startWidth = panelRef.current.getBoundingClientRect().width;
const handle = event.currentTarget;
const pointerId = event.pointerId;
handle.setPointerCapture(pointerId);
setResizing(true);
const handlePointerMove = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
pointerEvent.preventDefault();
resize(pointerEvent.clientX);
setWidth(clampAgentPanelWidth(
startWidth + pointerEvent.clientX - startX,
window.innerWidth,
conditionExpanded
));
};
const stopResizing = () => {
const stopResizing = (pointerEvent?: PointerEvent) => {
if (pointerEvent && pointerEvent.pointerId !== pointerId) return;
cleanupRef.current?.();
setResizing(false);
setCommittedWidth(widthRef.current);
};
const handleBlur = () => stopResizing();
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
window.removeEventListener("blur", handleBlur);
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
cleanupRef.current = null;
};
@@ -78,43 +59,19 @@ export function useAgentPanelResize(viewportWidth: number, conditionExpanded = f
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
},
[resize]
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
let nextWidth = currentWidth;
if (event.key === "ArrowLeft") {
nextWidth -= 16;
} else if (event.key === "ArrowRight") {
nextWidth += 16;
} else if (event.key === "Home") {
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
} else if (event.key === "End") {
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
} else {
return;
}
event.preventDefault();
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
widthRef.current = clampedWidth;
setWidth(clampedWidth);
setCommittedWidth(clampedWidth);
window.addEventListener("blur", handleBlur);
},
[conditionExpanded]
);
return {
committedWidth,
handleKeyDown,
handlePointerDown,
panelRef,
resizing,
width
width: clampAgentPanelWidth(
width ?? getWorkbenchViewportLayout(viewportWidth).agentWidth,
viewportWidth,
conditionExpanded
)
};
}
@@ -274,9 +274,25 @@ export function useWorkbenchMap({
maxZoom: MAP_MAX_ZOOM,
antialias: true,
preserveDrawingBuffer: true,
trackResize: false,
attributionControl: false
});
// MapLibre throttles automatic resizing to 50ms, which lags behind the dock edge.
const resizeObserver = new ResizeObserver(() => {
const container = map.getContainer();
const canvas = map.getCanvas();
if (!container.clientWidth || !container.clientHeight) return;
if (
canvas.clientWidth === container.clientWidth &&
canvas.clientHeight === container.clientHeight
) return;
map.resize();
// Resizing clears the drawing buffer; repaint in the same layout cycle.
map.redraw();
});
resizeObserver.observe(map.getContainer());
window.__waterNetworkMap = map;
let removeCityScene: (() => void) | undefined;
@@ -385,20 +401,6 @@ export function useWorkbenchMap({
};
void refreshScada();
let resizeFrame: number | null = null;
const resizeMap = () => map.resize();
const resizeObserver = new ResizeObserver(() => {
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = null;
resizeMap();
});
});
resizeObserver.observe(containerRef.current);
window.addEventListener("resize", resizeMap);
map.on("sourcedata", (event) => {
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
@@ -427,10 +429,6 @@ export function useWorkbenchMap({
mapRef.current = map;
return () => {
window.removeEventListener("resize", resizeMap);
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeObserver.disconnect();
scadaAbortController.abort();
if (scadaRefreshTimeoutId !== undefined) {
@@ -91,7 +91,7 @@ describe("workbench floating panels", () => {
agentWidth: 900,
conditionOpen: false
}).left
).toBe(744);
).toBe(664);
});
it("uses responsive defaults and preserves the map corridor at maximum width", () => {
@@ -99,12 +99,14 @@ describe("workbench floating panels", () => {
expect(getAgentPanelDefaultWidth(1024)).toBe(484);
expect(getAgentPanelDefaultWidth(1440)).toBe(500);
expect(getAgentPanelDefaultWidth(1536)).toBe(540);
expect(getAgentPanelMaxWidth(1024)).toBe(484);
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
expect(getAgentPanelMaxWidth(1024)).toBe(500);
expect(clampAgentPanelWidth(620, 1024)).toBe(500);
expect(clampAgentPanelWidth(300, 1440)).toBe(400);
expect(clampAgentPanelWidth(620, 1440)).toBe(620);
expect(getAgentPanelMaxWidth(1280)).toBe(516);
expect(getAgentPanelMaxWidth(1440)).toBe(676);
expect(getAgentPanelMaxWidth(1536)).toBe(720);
expect(getAgentPanelMaxWidth(1920)).toBe(720);
expect(getAgentPanelMaxWidth(1440)).toBe(640);
expect(getAgentPanelMaxWidth(1536)).toBe(640);
expect(getAgentPanelMaxWidth(1920)).toBe(640);
expect(getAgentPanelMaxWidth(1920, true)).toBe(628);
});
});
@@ -5,7 +5,8 @@ export const WORKBENCH_LAYOUT = {
wideMinWidth: 1536,
collapsedAgentWidth: 0,
collapsedAgentRailWidth: 72,
maxAgentWidth: 720,
minAgentWidth: 400,
maxAgentWidth: 640,
agentPanelLeftInset: 12,
conditionPanelRightInset: 64,
minimumAgentMapCorridor: 256,
@@ -52,10 +53,10 @@ export function clampAgentPanelWidth(
viewportWidth: number,
conditionExpanded = false
) {
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const maxWidth = getAgentPanelMaxWidth(viewportWidth, conditionExpanded);
return Math.round(
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth, conditionExpanded))
Math.min(Math.max(width, WORKBENCH_LAYOUT.minAgentWidth), maxWidth)
);
}
@@ -80,7 +81,6 @@ export function getAgentPanelDefaultWidth(viewportWidth: number, conditionExpand
export function getAgentPanelMaxWidth(viewportWidth: number, conditionExpanded = false) {
const layout = getWorkbenchViewportLayout(viewportWidth);
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const widthWithMapCorridor =
viewportWidth -
WORKBENCH_LAYOUT.agentPanelLeftInset -
@@ -88,7 +88,7 @@ export function getAgentPanelMaxWidth(viewportWidth: number, conditionExpanded =
conditionWidth -
WORKBENCH_LAYOUT.minimumAgentMapCorridor;
return Math.max(defaultWidth, Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithMapCorridor));
return Math.min(WORKBENCH_LAYOUT.maxAgentWidth, Math.max(layout.agentWidth, widthWithMapCorridor));
}
export type WorkbenchPanelState = {
@@ -1295,6 +1295,7 @@ export function MapWorkbenchPage({
personaState={agent.personaState}
statusLabel={agent.statusLabel}
viewportWidth={viewportWidth}
conditionExpanded={rightPanelExpanded}
onCollapsePanel={agent.collapsePanel}
onExpandPanel={agent.expandPanel}
onWidthCommit={setAgentPanelWidth}