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,7 +1,6 @@
import {
Activity,
CheckCircle2,
Clock3,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
@@ -175,7 +174,6 @@ export function AgentCommandPanel({
approvalMode = "request",
uiResults = [],
reviewPanel,
workspaceContext = [],
prompt,
onCollapse,
onPromptChange,
@@ -342,11 +340,12 @@ export function AgentCommandPanel({
<MotionConfig reducedMotion="user">
<aside
aria-label="Agent 命令面板"
data-agent-presentation={presentation}
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
presentation === "desktop-dock"
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
? "surface-dock overflow-hidden rounded-none border-0 border-r"
: desktopFloating
? "acrylic-panel overflow-visible rounded-2xl border"
: "overflow-hidden rounded-none border-0 bg-transparent",
@@ -359,7 +358,7 @@ export function AgentCommandPanel({
floatingPresentation && "gap-1 overflow-visible p-1"
)}
>
<div className="relative z-30 h-16 shrink-0">
<div className={cn("relative z-30 shrink-0", presentation === "desktop-dock" ? "h-14" : "h-16")}>
<header
className={cn(
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
@@ -368,9 +367,9 @@ export function AgentCommandPanel({
historyOpen && "agent-panel-header-expanded"
)}
>
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "min-h-14 py-1.5" : "min-h-16 py-2")}>
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className="h-12 w-12" state={personaState} />
<AgentPersona className={presentation === "desktop-dock" ? "h-10 w-10" : "h-12 w-12"} state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
@@ -454,7 +453,8 @@ export function AgentCommandPanel({
id={historyPanelId}
role="region"
aria-label="Agent 历史记录"
className="agent-panel-floating-acrylic agent-panel-history-extension"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-panel-history-extension"
initial="initial"
animate="animate"
exit="exit"
@@ -468,29 +468,6 @@ export function AgentCommandPanel({
</header>
</div>
{workspaceContext.length > 0 ? (
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">
当前上下文
</span>
{workspaceContext.map((item) => (
<span
key={item.id}
className={cn(
"shrink-0 rounded-md px-2 py-1",
item.tone === "info" && "bg-blue-50 text-blue-700",
item.tone === "warning" && "bg-amber-50 text-amber-700",
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
)}
title={`${item.label}:${item.value}`}
>
{item.label} · <strong className="font-semibold">{item.value}</strong>
</span>
))}
</div>
) : null}
{floatingPresentation ? (
<AnimatePresence initial={false}>
{hasConversation && !historyOpen ? (
@@ -552,6 +529,7 @@ export function AgentCommandPanel({
{messages.length > 0 ? (
<BackendMessageList
key="messages"
presentation={presentation}
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
@@ -596,8 +574,9 @@ export function AgentCommandPanel({
streaming={streaming}
/>
<ConversationScrollButton
data-agent-presentation={presentation}
className={cn(
"agent-panel-floating-acrylic agent-conversation-scroll-button",
"agent-panel-overlay agent-panel-floating-acrylic agent-conversation-scroll-button",
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
)}
/>
@@ -659,10 +638,11 @@ export function AgentCommandPanel({
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{!qaOnly ? <ApprovalModeSelect value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-slate-500">任务协作问答</span>}
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-slate-500">任务协作问答</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
@@ -823,10 +803,12 @@ function VoiceInputButton({
}
function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
@@ -854,10 +836,11 @@ function ApprovalModeSelect({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
权限批准模式
@@ -996,10 +979,12 @@ function getLastUserMessageId(messages: AgentChatMessage[]) {
}
function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
@@ -1033,10 +1018,11 @@ function AgentModelSelect({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
模型选择
@@ -1049,7 +1035,9 @@ function AgentModelSelect({
key={model.id}
className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "border-violet-200 bg-violet-50 focus:bg-violet-50"
selected && (presentation === "desktop-dock"
? "border-blue-200 bg-blue-50 focus:bg-blue-50"
: "border-violet-200 bg-violet-50 focus:bg-violet-50")
)}
onSelect={() => onValueChange?.(model.id)}
>
@@ -1065,7 +1053,7 @@ function AgentModelSelect({
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" />
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-blue-600" : "text-violet-600"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
@@ -1177,12 +1165,12 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto w-full max-w-[360px]"
className="agent-empty-state mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className="px-2 py-4">
<div className="flex items-center gap-4">
<div className="surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<div className="agent-empty-mark surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
@@ -1202,7 +1190,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<ul
className={cn(
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
"agent-capabilities surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
@@ -1211,12 +1199,12 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<li
key={label}
className={cn(
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
"agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-300/80",
index < 2 && "border-b border-slate-300/80"
)}
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<span className="agent-capability-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
@@ -1228,6 +1216,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
}
function BackendMessageList({
presentation,
messages,
streaming,
streamRenderState,
@@ -1243,6 +1232,7 @@ function BackendMessageList({
onStopSpeech,
qaOnly
}: {
presentation: AgentCommandPanelPresentation;
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
@@ -1299,6 +1289,7 @@ function BackendMessageList({
{message.content ? (
message.role === "assistant" ? (
<AgentSpeechMessage
presentation={presentation}
messageId={message.id}
content={message.content}
disabled={streaming && index === messages.length - 1}
@@ -1348,6 +1339,7 @@ type SpeechSelection = {
};
function AgentSpeechMessage({
presentation,
messageId,
content,
disabled,
@@ -1359,6 +1351,7 @@ function AgentSpeechMessage({
onStop,
children
}: {
presentation: AgentCommandPanelPresentation;
messageId: string;
content: string;
disabled: boolean;
@@ -1472,7 +1465,8 @@ function AgentSpeechMessage({
<motion.button
key="speech-selection-action"
type="button"
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
@@ -19,7 +19,7 @@ export function AgentOperationalBrief({
const brief = createOperationalBrief(messages, statusLabel, streaming);
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="agent-panel-operational-brief agent-panel-control rounded-2xl p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
@@ -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}
+207 -4
View File
@@ -368,6 +368,15 @@ button:disabled {
auto;
}
#workspace-map .maplibregl-canvas:focus {
outline: none;
}
#workspace-map .maplibregl-canvas:focus-visible {
outline: 1px solid var(--action-focus-ring);
outline-offset: -1px;
}
.acrylic-navigation,
.acrylic-panel,
.acrylic-control,
@@ -400,6 +409,11 @@ button:disabled {
saturate(var(--acrylic-backdrop-saturation));
}
.workbench-top-bar {
border-color: var(--surface-divider);
box-shadow: none;
}
.acrylic-panel {
background-color: var(--acrylic-panel);
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
@@ -652,10 +666,6 @@ button:disabled {
}
}
.agent-panel-shell {
border-color: var(--acrylic-border);
}
.agent-panel-header {
z-index: 1;
border-top-left-radius: inherit;
@@ -956,6 +966,199 @@ button.agent-history-session-trigger:not(:disabled):active {
color: rgba(255, 255, 255, 0.86);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-surface: oklch(97.2% 0.005 250);
--agent-dock-band: oklch(98.5% 0.003 250);
--agent-dock-control-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(15, 23, 42, 0.04);
--agent-dock-overlay-surface: rgba(255, 255, 255, 0.94);
--agent-dock-overlay-backdrop: blur(12px) saturate(1.03);
--agent-dock-overlay-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
--surface-control: #ffffff;
--surface-reading: #ffffff;
--agent-user-message-surface: oklch(96.5% 0.018 255);
--agent-user-message-border: transparent;
--agent-floating-reading-item: transparent;
--agent-floating-reading-item-hover: oklch(97.2% 0.005 250);
--agent-floating-reading-active: var(--action-selection-soft);
--radius-panel: 12px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] {
background: var(--agent-dock-surface);
border-color: var(--surface-divider);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-header {
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-header > div:first-child {
background: transparent;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band {
background: var(--agent-dock-band);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band {
border-color: var(--surface-divider);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-conversation {
background: transparent;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band > .agent-panel-conversation {
background: var(--agent-dock-surface);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-control {
border-color: var(--surface-divider);
border-radius: 12px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-icon-button,
.agent-panel-shell[data-agent-presentation="desktop-dock"] form.agent-panel-control {
box-shadow: var(--agent-dock-control-shadow);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] form.agent-panel-control:focus-within {
border-color: var(--action-blue);
box-shadow: var(--agent-dock-control-shadow), 0 0 0 2px rgba(37, 99, 235, 0.12);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] :where(button) {
border-radius: 10px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] :where(button:active:not(:disabled):not(:focus-visible)),
.agent-panel-overlay[data-agent-presentation="desktop-dock"]:is(button):active:not(:disabled):not(:focus-visible),
.agent-panel-overlay[data-agent-presentation="desktop-dock"] :where(button:active:not(:disabled):not(:focus-visible)) {
translate: var(--tw-translate-x, 0) 1px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-icon-button:active {
background: var(--action-soft-pressed);
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band .surface-reading {
border-color: var(--surface-divider);
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-empty-mark {
background: var(--agent-dock-band);
box-shadow: inset 0 0 0 1px var(--surface-divider);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-capabilities {
border-radius: 0;
background: transparent;
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-capability {
min-height: 72px;
border: 0;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-capability-icon {
border-radius: 10px;
background: var(--agent-dock-band);
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-message,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-user-message {
border: 0;
border-radius: 12px;
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-message {
background: transparent;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] [data-agent-ui-result] {
border: 1px solid var(--surface-divider);
background: var(--surface-reading);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-operational-brief {
border: 0;
background: var(--agent-dock-band);
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-brief-metric {
border: 0;
background: transparent;
}
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
border: 0;
border-radius: 12px;
background-color: var(--agent-dock-overlay-surface);
background-image: none;
box-shadow: var(--agent-dock-overlay-shadow);
backdrop-filter: var(--agent-dock-overlay-backdrop);
-webkit-backdrop-filter: var(--agent-dock-overlay-backdrop);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-header-expanded {
border-color: var(--surface-divider);
box-shadow: var(--agent-dock-overlay-shadow);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-history-extension {
border-radius: 0 0 12px 12px;
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-history-extension .agent-panel-control {
border: 0;
background: transparent;
box-shadow: none;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-overlay-surface: #ffffff;
--agent-dock-overlay-backdrop: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-overlay-surface: #ffffff;
--agent-dock-overlay-backdrop: none;
}
}
@media (forced-colors: active) {
.agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-surface: Canvas;
--agent-dock-band: Canvas;
--agent-dock-control-shadow: none;
--agent-dock-overlay-surface: Canvas;
--agent-dock-overlay-backdrop: none;
--agent-dock-overlay-shadow: none;
--surface-control: Canvas;
--surface-reading: Canvas;
--surface-border: CanvasText;
--surface-divider: CanvasText;
--agent-user-message-surface: Canvas;
--agent-user-message-border: CanvasText;
--agent-floating-reading-item-hover: Canvas;
--agent-floating-reading-active: Highlight;
forced-color-adjust: auto;
}
}
.scheduled-feed-scroll,
.agent-conversation-scroll {
scrollbar-gutter: stable;
+114 -4
View File
@@ -53,12 +53,25 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar);
await expect(agentPanel).toHaveCSS("background-color", "oklch(0.965 0.012 248)");
await expect(agentPanel).toHaveCSS("background-color", "oklch(0.972 0.005 250)");
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
await expect(agentPanel).toHaveCSS("border-top-left-radius", "0px");
await expect(agentPanel).toHaveCSS("border-top-right-radius", "16px");
await expect(agentPanel).not.toHaveCSS("box-shadow", "none");
await expect(agentPanel).toHaveCSS("border-radius", "0px");
await expect(agentPanel).toHaveCSS("border-top-width", "0px");
await expect(agentPanel).toHaveCSS("border-bottom-width", "0px");
await expect(agentPanel).toHaveCSS("border-left-width", "0px");
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
await expect(agentPanel).toHaveCSS("border-right-color", "oklch(0.905 0.009 250)");
await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect(agentControls).toHaveCSS("border-top-width", "1px");
await expect(agentControls).toHaveCSS("background-color", "oklch(0.985 0.003 250)");
await expect(agentPanel.locator("form.agent-panel-control")).toHaveCSS("background-color", "rgb(255, 255, 255)");
const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" });
await expect(capabilities.getByRole("listitem")).toHaveCount(4);
await expect(capabilities).toHaveCSS("box-shadow", "none");
for (const item of await capabilities.getByRole("listitem").all()) {
await expect(item).toHaveCSS("border-right-width", "0px");
await expect(item).toHaveCSS("border-bottom-width", "0px");
}
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)");
@@ -72,8 +85,21 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(panelBox!.x).toBe(0);
const topBarBox = await topBar.boundingBox();
expect(panelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
expect(panelBox!.y + panelBox!.height).toBe(page.viewportSize()!.height);
expect(panelBox!.width).toBe(500);
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(500);
const headerBox = await agentHeader.boundingBox();
const workspaceBox = await workspace.boundingBox();
expect(headerBox!.height).toBe(topBarBox!.height);
expect(headerBox!.y).toBe(workspaceBox!.y);
await expect(topBar).toHaveCSS("box-shadow", "none");
await expect(agentHeader).toHaveCSS("box-shadow", "none");
await expect(agentHeader.locator(":scope > div").first()).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await topBar.evaluate((element) => getComputedStyle(element).borderBottomColor)).toBe(
await agentPanel.evaluate((element) => getComputedStyle(element).borderRightColor)
);
expect(Math.abs(conversationBox!.x - panelBox!.x)).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
@@ -81,6 +107,57 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await expect.poll(() => getSurfacePresentation(agentPanel)).toEqual(beforeFocus);
await page.screenshot({ path: "test-results/agent-history-ui/desktop-dock.png" });
for (const viewport of [
{ width: 1024, height: 768, panelWidth: 500 },
{ width: 1920, height: 1080, panelWidth: 540 }
]) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await expect(agentPanel).toHaveCSS("width", `${viewport.panelWidth}px`);
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(viewport.panelWidth);
const resizedPanelBox = await agentPanel.boundingBox();
expect(resizedPanelBox!.x).toBe(0);
expect(resizedPanelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
expect(resizedPanelBox!.y + resizedPanelBox!.height).toBe(viewport.height);
await page.screenshot({
path: `test-results/agent-history-ui/desktop-dock-${viewport.width}.png`
});
}
});
test("desktop Agent portal menus keep local material and respect reduced transparency", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await waitForAnimations(panel);
for (const name of ["选择 Agent 模型", "权限批准模式"]) {
const trigger = panel.getByRole("button", { name });
await trigger.click();
const menu = page.getByRole("menu");
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
await expect(menu).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await expect(menu).toHaveCSS("background-image", "none");
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
}
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-transparency", value: "reduce" }]
});
expect(await page.evaluate(() => matchMedia("(prefers-reduced-transparency: reduce)").matches)).toBe(true);
await panel.getByRole("button", { name: "选择 Agent 模型" }).click();
const menu = page.getByRole("menu");
await expect(menu).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(menu).toHaveCSS("backdrop-filter", "none");
await page.keyboard.press("Escape");
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
const history = panel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(history).toHaveCSS("backdrop-filter", "none");
});
test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
@@ -99,6 +176,8 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await expectUnifiedHistoryStructure(history);
await expect(history).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first();
@@ -118,6 +197,35 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
});
test("desktop Agent press feedback keeps pointer clicks and keyboard activation stable", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await waitForAnimations(panel);
const trigger = panel.getByRole("button", { name: "打开 Agent 历史记录" });
const history = panel.getByRole("region", { name: "Agent 历史记录" });
const before = await trigger.boundingBox();
await page.mouse.move(before!.x + before!.width / 2, before!.y + before!.height / 2);
await page.mouse.down();
const pressed = await trigger.boundingBox();
expect(pressed!.y).toBe(before!.y + 1);
expect(pressed!.width).toBe(before!.width);
await page.mouse.up();
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await expect(history).toBeVisible();
await waitForAnimations(panel);
await page.keyboard.press("Tab");
await trigger.focus();
expect(await trigger.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
const focused = await trigger.boundingBox();
await page.keyboard.down("Space");
expect(await trigger.boundingBox()).toEqual(focused);
await page.keyboard.up("Space");
await expect(history).toBeHidden();
await expect(trigger).toBeFocused();
});
test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -192,6 +300,8 @@ test("docked Agent respects forced color mode", async ({ page }) => {
await expect(navigation).toBeVisible();
await expect(navigation).toHaveCSS("backdrop-filter", "none");
await expect(navigation).toHaveCSS("box-shadow", "none");
await expect(navigation).toHaveCSS("border-right-width", "1px");
await expect(navigation).toHaveCSS("border-right-color", "rgb(0, 0, 0)");
await expect(workspace).toBeVisible();
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
await page.screenshot({ path: "test-results/agent-history-ui/forced-colors.png" });
+185
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
@@ -35,6 +36,190 @@ test("the docked Agent collapses and restores the main workspace", async ({ page
.toBe(expandedOffset);
});
test("dragging the Agent edge keeps the workspace attached and preserves the draft", async ({ page }) => {
await mockWorkbenchContext(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace");
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
const prompt = panel.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
await expect(panel.getByText("当前上下文", { exact: true })).toHaveCount(0);
await prompt.fill("保留尚未发送的诊断问题");
await expect(panel).toHaveCSS("width", "500px");
await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")) &&
!window.__waterNetworkMap?.isMoving()
);
const cameraBefore = await readCamera(page);
expect(await handle.evaluate((element) => (element as HTMLElement).tabIndex)).toBe(-1);
const grip = handle.locator(".agent-resize-grip");
await expect(grip).toHaveCSS("opacity", "0");
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await expect(grip).toHaveCSS("opacity", "1");
await page.screenshot({ path: "/tmp/nexttjwater-agent-resize-hover.png" });
await page.mouse.down();
await expect(handle).toHaveAttribute("data-resizing", "true");
await expect(panel).toHaveCSS("width", "500px");
for (const nextWidth of [540, 580, 620]) {
await page.mouse.move(nextWidth, box.y + box.height / 2);
await expect(panel).toHaveCSS("width", `${nextWidth}px`);
const panelBox = (await panel.boundingBox())!;
const workspaceBox = (await workspace.boundingBox())!;
expect(workspaceBox.x).toBe(panelBox.x + panelBox.width);
}
await page.screenshot({ path: "test-results/agent-history-ui/desktop-resizing.png" });
await page.mouse.up();
await expect(handle).toHaveAttribute("data-resizing", "false");
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
await expect.poll(() => readCamera(page)).toEqual(cameraBefore);
await page.mouse.move(1000, 450);
await expect(grip).toHaveCSS("opacity", "0");
await expect(panel).toHaveCSS("width", "620px");
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(panel).toHaveCSS("width", "620px");
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
});
async function readCamera(page: Page) {
return page.evaluate(() => {
const map = window.__waterNetworkMap!;
return {
center: map.getCenter().toArray(),
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch()
};
});
}
test("Agent dragging stays within usable widths and adapts to the viewport", async ({ page }) => {
await mockWorkbenchContext(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
const workspace = page.locator("#main-workspace");
for (const [targetX, expectedWidth] of [[250, 400], [1000, 640], [620, 620]]) {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(targetX, box.y + box.height / 2);
await page.mouse.up();
await expect(panel).toHaveCSS("width", `${expectedWidth}px`);
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(expectedWidth);
if (expectedWidth === 400) {
await page.screenshot({ path: "test-results/agent-history-ui/desktop-resize-minimum.png" });
}
}
await page.setViewportSize({ width: 1024, height: 768 });
await expect(panel).toHaveCSS("width", "500px");
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(500);
await page.setViewportSize({ width: 1920, height: 1080 });
await expect(panel).toHaveCSS("width", "620px");
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(620);
});
test("the map stays painted while the Agent and viewport change size", async ({ page }) => {
await mockWorkbenchContext(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")) &&
!window.__waterNetworkMap?.isMoving()
);
const initialAlpha = await page.evaluate(() => {
const map = window.__waterNetworkMap!;
const canvas = map.getCanvas();
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!gl) throw new Error("The map has no WebGL context");
const pixel = new Uint8Array(4);
const readAlpha = () => {
gl.readPixels(
Math.floor(gl.drawingBufferWidth / 2), Math.floor(gl.drawingBufferHeight / 2),
1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel
);
return pixel[3];
};
const state = window as typeof window & {
__mapResizeProbe?: { samples: number; blankFrames: number; sameMap: boolean };
};
const probe = { samples: 0, blankFrames: 0, sameMap: true };
state.__mapResizeProbe = probe;
map.on("resize", () => queueMicrotask(() => {
probe.samples += 1;
if (readAlpha() === 0) probe.blankFrames += 1;
probe.sameMap &&= window.__waterNetworkMap === map;
}));
map.redraw();
return readAlpha();
});
expect(initialAlpha).toBe(255);
const cameraBefore = await readCamera(page);
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(620, box.y + box.height / 2, { steps: 12 });
await page.mouse.up();
for (const viewport of [
{ width: 1280, height: 800 }, { width: 1920, height: 1080 }
]) {
await page.setViewportSize(viewport);
await expect.poll(async () => page.evaluate(() => {
const canvas = window.__waterNetworkMap!.getCanvas();
return canvas.clientWidth === document.querySelector("#workspace-map")!.clientWidth;
})).toBe(true);
}
const probe = await page.evaluate(() => (window as typeof window & {
__mapResizeProbe?: { samples: number; blankFrames: number; sameMap: boolean };
}).__mapResizeProbe!);
expect(probe.samples).toBeGreaterThan(0);
expect(probe.sameMap).toBe(true);
expect(probe.blankFrames).toBe(0);
await expect.poll(() => readCamera(page)).toEqual(cameraBefore);
});
test("the map canvas follows workspace resizing before the next paint", async ({ page }) => {
await mockWorkbenchContext(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")) &&
!window.__waterNetworkMap?.isMoving()
);
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
const container = map.getContainer();
const probe = { samples: 0, mismatches: 0, maxWidthDifference: 0 };
(window as typeof window & {
__mapSizeProbe?: typeof probe;
}).__mapSizeProbe = probe;
new ResizeObserver(() => queueMicrotask(() => {
const difference = Math.abs(container.clientWidth - map.getCanvas().clientWidth);
probe.samples += 1;
if (difference > 0) probe.mismatches += 1;
probe.maxWidthDifference = Math.max(probe.maxWidthDifference, difference);
})).observe(container);
});
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
for (const width of [620, 420, 600]) {
await page.mouse.move(width, box.y + box.height / 2, { steps: 12 });
}
await page.mouse.up();
const probe = await page.evaluate(() => (window as typeof window & {
__mapSizeProbe?: { samples: number; mismatches: number; maxWidthDifference: number };
}).__mapSizeProbe!);
expect(probe.samples).toBeGreaterThan(1);
expect(probe.maxWidthDifference).toBe(0);
});
test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
+21 -4
View File
@@ -311,7 +311,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
await expect(userMessage).toHaveCSS("background-color", "rgba(224, 229, 249, 0.92)");
await expect(userMessage).toHaveCSS("background-color", "oklch(0.965 0.018 255)");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
).not.toBe(
@@ -319,7 +319,7 @@ test("stream completion and the plan card remain positionally stable", async ({
);
await expect
.poll(() => userMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
.toBe("none");
await expect
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
@@ -331,7 +331,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(operationalBrief.getByRole("button", { name: "预览影响" })).toBeDisabled();
await expect(operationalBrief.getByRole("button", { name: "检查证据" })).toBeDisabled();
await expect(operationalBrief).toHaveCSS("position", "static");
await expect(operationalBrief).toHaveCSS("border-radius", "16px");
await expect(operationalBrief).toHaveCSS("border-radius", "12px");
// Desktop uses an inline brief above the conversation, not the legacy floating overlay.
await expect(page.locator(".agent-panel-shell")).toHaveClass(/surface-dock/);
await expect(page.locator(".agent-panel-operational-float")).toHaveCount(0);
@@ -478,7 +478,14 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(
expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true })
).toHaveCSS("white-space", "normal");
await expandedLiveProgress.evaluate(async (element) => {
const animations = element
.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
await progressToggle.click();
await expect(progressToggle).toHaveAttribute("data-state", "closed");
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
triggerProgressFailure();
@@ -603,7 +610,15 @@ test("stream completion and the plan card remain positionally stable", async ({
await page.mouse.wheel(0, -2400);
const scrollToLatestButton = page.getByRole("button", { name: "滚动到最新消息" });
await expect(scrollToLatestButton).toBeVisible();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
const scrollButtonBox = (await scrollToLatestButton.boundingBox())!;
await page.mouse.move(scrollButtonBox.x + scrollButtonBox.width / 2, scrollButtonBox.y + scrollButtonBox.height / 2);
await page.mouse.down();
const pressedScrollButtonBox = (await scrollToLatestButton.boundingBox())!;
expect(pressedScrollButtonBox.x).toBe(scrollButtonBox.x);
expect(pressedScrollButtonBox.y).toBe(scrollButtonBox.y + 1);
await page.mouse.move(scrollButtonBox.x - 20, scrollButtonBox.y);
await page.mouse.up();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
await expect
.poll(() =>
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
@@ -693,10 +708,12 @@ test("stream completion and the plan card remain positionally stable", async ({
expect(speechSelectionIconMotion.ySpread).toBeLessThanOrEqual(0.1);
const speechSelectionButton = page.getByRole("button", { name: "从这里开始朗读" });
await expect(speechSelectionButton).toBeVisible();
await expect(speechSelectionButton).toHaveAttribute("data-agent-presentation", "desktop-dock");
await expect(speechSelectionButton).toHaveCSS("border-top-width", "0px");
expect(await speechSelectionButton.evaluate(readAcrylicMaterial)).toEqual(
await scrollToLatestButton.evaluate(readAcrylicMaterial)
);
await page.screenshot({ path: "test-results/agent-history-ui/desktop-conversation.png" });
});
function createLongMarkdownResponse() {
+25 -3
View File
@@ -65,6 +65,27 @@ test.describe("Main Frame workbench", () => {
});
});
test("map focus keeps pointer interaction borderless and uses the app focus style", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await openDesktopAgent(page);
const canvas = page.locator("#workspace-map .maplibregl-canvas");
const box = (await canvas.boundingBox())!;
await canvas.click({ position: { x: box.width / 2, y: box.height / 2 } });
await expect(canvas).toBeFocused();
await expect(canvas).toHaveCSS("outline-style", "none");
await expect(canvas).toHaveCSS("border-width", "0px");
await expect(page.locator("#main-workspace")).toHaveCSS("border-width", "0px");
await page.keyboard.press("Tab");
await canvas.focus();
expect(await canvas.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
await expect(canvas).toHaveCSS("outline-style", "solid");
await expect(canvas).toHaveCSS("outline-width", "1px");
await expect(canvas).toHaveCSS("outline-color", "rgba(37, 99, 235, 0.34)");
await expect(canvas).toHaveCSS("outline-offset", "-1px");
await page.screenshot({ path: "test-results/agent-history-ui/map-pointer-focus.png" });
});
test("mobile layer panel keeps Tianditu choices usable", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await openWorkbench(page);
@@ -260,7 +281,8 @@ async function expectDesktopMainFrameGeometry(page: Page) {
const mapWindow = page.locator("#workspace-map");
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(agentPanel).toHaveCSS("width", "500px");
await expect(agentPanel).toHaveCSS("border-top-right-radius", "16px");
await expect(agentPanel).toHaveCSS("border-radius", "0px");
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
await expect(mapWindow).toHaveCSS("border-radius", "0px");
const workspaceBox = await workspace.boundingBox();
@@ -286,8 +308,8 @@ async function expectSolidMainFrameMaterials(page: Page) {
};
});
expect(materials.agentBackdrop).toBe("none");
expect(materials.agentBackground).toBe("oklch(0.965 0.012 248)");
expect(materials.agentShadow).not.toBe("none");
expect(materials.agentBackground).toBe("oklch(0.972 0.005 250)");
expect(materials.agentShadow).toBe("none");
}
async function expectLoadedChineseFont(page: Page) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 94 KiB