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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user