feat: refactor artifact analysis workspace

This commit is contained in:
2026-08-19 16:09:51 +08:00
parent bfadc95f93
commit d2f2895c36
26 changed files with 998 additions and 2278 deletions
@@ -1,436 +1,109 @@
import {
Map as MapIcon,
PanelsTopLeft,
Sparkles
} from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { AnalysisDocumentView } from "./analysis-document-view";
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
import {
MAX_ANALYSIS_WINDOWS,
applyWorkspaceLayout,
closeAnalysisWindow,
createInitialWorkspaceState,
focusWorkspaceWindow,
maximizeWorkspaceWindow,
minimizeWorkspaceWindow,
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect,
type WorkspaceBounds,
type WorkspaceLayoutMode,
type WorkspaceRect,
type WorkspaceState
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar";
import type {
AnalysisArtifact,
ArtifactRequestedView,
WorkbenchSurfaceMode
} from "./workspace-model";
import {
getTaskViewColumnCount,
getTaskViewPreviewRects,
getTaskViewSourceRect,
WORKSPACE_LAYOUT_LABELS
} from "./workspace-task-view-layout";
import { WorkspaceTaskView } from "./workspace-task-view";
import { WorkspaceWindow } from "./workspace-window";
type WorkbenchMainFrameProps = {
mapContent: ReactNode;
mapOverlay?: ReactNode;
activeArtifact: AnalysisArtifact | null;
pendingArtifact: AnalysisArtifact | null;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onOpenPendingArtifact: () => void;
onDestroyPendingArtifact: () => void;
onSetArtifactView: (view: ArtifactRequestedView) => void;
onCollapseArtifact: () => void;
onDestroyArtifact: () => void;
};
export type WorkbenchMainFrameHandle = {
openTestAnalysis: () => void;
};
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
type WorkspaceDragState = {
sourceId: string;
sourceRect: WorkspaceRect;
targetId: string | null;
};
export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, WorkbenchMainFrameProps>(function WorkbenchMainFrame({
export function WorkbenchMainFrame({
mapContent,
mapOverlay
}, ref) {
const workspaceRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<WorkspaceDragState | null>(null);
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null);
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
const [desktop, setDesktop] = useState(false);
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
const [taskViewOpen, setTaskViewOpen] = useState(false);
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map");
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
const taskViewWindows = useMemo(
() => [workspace.map, ...workspace.analyses],
[workspace.analyses, workspace.map]
);
const taskViewPreviewRects = useMemo(
() => getTaskViewPreviewRects(taskViewWindows.length, bounds),
[bounds, taskViewWindows.length]
);
useEffect(() => {
const element = workspaceRef.current;
if (!element) return;
const updateBounds = () => {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const nextBounds = { width: rect.width, height: rect.height };
setBounds(nextBounds);
setWorkspace((current) => resizeWorkspace(current, nextBounds));
}
};
updateBounds();
const observer = new ResizeObserver(updateBounds);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!taskViewOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setTaskViewOpen(false);
setWorkspaceAnnouncement("已关闭任务视图");
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [taskViewOpen]);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setDesktop(mediaQuery.matches);
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const openTestDocument = useCallback(() => {
if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) {
showMapNotice({
tone: "warning",
title: "临时窗口已达上限",
message: "请关闭一个 Agent 分析窗口后继续测试。"
});
return;
}
const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]);
useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]);
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
const root = workspaceRef.current;
if (!root) return null;
return [...root.querySelectorAll<HTMLElement>("[data-workspace-window-id]")]
.filter((element) => element.dataset.workspaceWindowId !== sourceId)
.filter((element) => {
const rect = element.getBoundingClientRect();
const inset = Math.min(12, rect.width / 4, rect.height / 4);
return rect.width > 0 && rect.height > 0
&& pointer.x >= rect.left + inset
&& pointer.x <= rect.right - inset
&& pointer.y >= rect.top + inset
&& pointer.y <= rect.bottom - inset;
})
.sort((a, b) => Number(b.style.zIndex || 0) - Number(a.style.zIndex || 0))[0]
?.dataset.workspaceWindowId ?? null;
}, []);
const startWindowMove = useCallback((sourceId: string, sourceRect: WorkspaceRect) => {
const next = { sourceId, sourceRect, targetId: null };
dragStateRef.current = next;
setDragState(next);
setWorkspaceAnnouncement("");
}, []);
const previewWindowMove = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
const current = dragStateRef.current;
if (!current || current.sourceId !== sourceId) return;
const targetId = findSwapTarget(sourceId, pointer);
if (current.targetId === targetId) return;
const next = { ...current, targetId };
dragStateRef.current = next;
setDragState(next);
}, [findSwapTarget]);
const finishWindowMove = useCallback((
sourceId: string,
rect: WorkspaceRect,
pointer: { x: number; y: number }
) => {
const current = dragStateRef.current;
const targetId = current?.sourceId === sourceId
? current.targetId ?? findSwapTarget(sourceId, pointer)
: findSwapTarget(sourceId, pointer);
if (targetId) {
setWorkspace((state) => swapWorkspaceWindows(state, sourceId, targetId, bounds));
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}${getWorkspaceWindowTitle(workspace, targetId)}已交换位置`);
} else {
setWorkspace((state) => updateWorkspaceWindowRect(state, sourceId, rect, bounds));
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}已转为自由排列`);
}
dragStateRef.current = null;
setDragState(null);
}, [bounds, findSwapTarget, workspace]);
const cancelWindowMove = useCallback(() => {
dragStateRef.current = null;
setDragState(null);
setWorkspaceAnnouncement("已取消窗口移动");
}, []);
function focusTaskViewWindow(windowId: string) {
setTaskViewFocusWindowId(windowId);
window.requestAnimationFrame(() => {
workspaceRef.current
?.querySelector<HTMLButtonElement>(`[data-task-view-window-id="${windowId}"]`)
?.focus();
});
}
function openTaskView() {
const windowId = workspace.activeWindowId;
setTaskViewFocusWindowId(windowId);
setTaskViewOpen(true);
setWorkspaceAnnouncement(`任务视图已打开,共 ${taskViewWindows.length} 个窗口`);
window.requestAnimationFrame(() => focusTaskViewWindow(windowId));
}
function closeTaskView() {
setTaskViewOpen(false);
setWorkspaceAnnouncement("已关闭任务视图");
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
}
function activateTaskViewWindow(windowId: string) {
const title = getWorkspaceWindowTitle(workspace, windowId);
setWorkspace((current) => maximizeWorkspaceWindow(current, windowId));
setTaskViewOpen(false);
setWorkspaceAnnouncement(`${title}已铺满主视图`);
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
}
function applyTaskViewLayout(mode: WorkspaceLayoutMode) {
setWorkspace((current) => applyWorkspaceLayout(
focusWorkspaceWindow(current, taskViewFocusWindowId),
mode,
bounds
));
setTaskViewOpen(false);
setWorkspaceAnnouncement(`已应用${WORKSPACE_LAYOUT_LABELS[mode]}布局`);
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
}
function handleTaskViewWindowKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number
) {
const columns = getTaskViewColumnCount(taskViewWindows.length);
let nextIndex = index;
if (event.key === "ArrowLeft") nextIndex = Math.max(0, index - 1);
else if (event.key === "ArrowRight") nextIndex = Math.min(taskViewWindows.length - 1, index + 1);
else if (event.key === "ArrowUp") nextIndex = Math.max(0, index - columns);
else if (event.key === "ArrowDown") nextIndex = Math.min(taskViewWindows.length - 1, index + columns);
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = taskViewWindows.length - 1;
else return;
event.preventDefault();
const nextWindow = taskViewWindows[nextIndex];
if (nextWindow) focusTaskViewWindow(nextWindow.id);
}
mapOverlay,
activeArtifact,
pendingArtifact,
surfaceMode,
mapSplitAvailable,
onOpenPendingArtifact,
onDestroyPendingArtifact,
onSetArtifactView,
onCollapseArtifact,
onDestroyArtifact
}: WorkbenchMainFrameProps) {
const showMap = surfaceMode !== "focus";
const showArtifact = activeArtifact && surfaceMode !== "map_only";
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 lg:left-[var(--workbench-agent-current-width)]"
data-testid="workbench-main-frame"
>
<div
ref={workspaceRef}
id="main-workspace"
data-layout-mode={workspace.layout.mode}
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
data-layout-mode={surfaceMode}
className={cn(
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
surfaceMode !== "map_split" && "grid-cols-1"
)}
>
<WorkspaceWindow
id={workspace.map.id}
title={workspace.map.title}
mode={desktop ? workspace.map.mode : "docked"}
rect={workspace.map.rect}
zIndex={workspace.map.zIndex}
active={workspace.activeWindowId === workspace.map.id}
kind="map"
swapTarget={dragState?.targetId === workspace.map.id}
taskViewPreview={taskViewOpen && taskViewPreviewRects[0] ? {
sourceRect: getTaskViewSourceRect(
workspace.map.mode,
workspace.map.rect,
workspace.map.restoreRect,
bounds
),
targetRect: taskViewPreviewRects[0],
index: 0
} : undefined}
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id, bounds))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
onMoveStart={(rect) => startWindowMove(workspace.map.id, rect)}
onMovePreview={(_, pointer) => previewWindowMove(workspace.map.id, pointer)}
onMoveEnd={(rect, pointer) => finishWindowMove(workspace.map.id, rect, pointer)}
onMoveCancel={cancelWindowMove}
<section
id="workspace-map"
aria-label="供水管网地图工作区"
aria-hidden={!showMap}
className={cn(
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
!showMap && "hidden"
)}
>
{mapContent}
{mapOverlay}
</WorkspaceWindow>
{activeArtifact && surfaceMode === "map_only" ? (
<button
type="button"
onClick={() => onSetArtifactView("focus")}
className="pointer-events-auto absolute left-3 top-3 z-20 inline-flex h-10 items-center gap-2 rounded-md bg-slate-950 px-3 text-xs font-semibold text-white shadow-[0_8px_24px_rgba(15,23,42,0.22)] hover:bg-slate-800 active:scale-95 lg:left-4 lg:top-4"
>
<ArrowLeft size={15} aria-hidden="true" />
<FileChartColumnIncreasing size={15} aria-hidden="true" />
</button>
) : null}
</section>
{workspace.analyses.map((window, index) => (
<WorkspaceWindow
key={window.id}
id={window.id}
title={window.title}
mode={window.mode}
rect={window.rect}
zIndex={window.zIndex}
active={workspace.activeWindowId === window.id}
kind="analysis"
swapTarget={dragState?.targetId === window.id}
taskViewPreview={taskViewOpen && taskViewPreviewRects[index + 1] ? {
sourceRect: getTaskViewSourceRect(
window.mode,
window.rect,
window.restoreRect,
bounds
),
targetRect: taskViewPreviewRects[index + 1],
index: index + 1
} : undefined}
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id, bounds))}
onMoveStart={(rect) => startWindowMove(window.id, rect)}
onMovePreview={(_, pointer) => previewWindowMove(window.id, pointer)}
onMoveEnd={(rect, pointer) => finishWindowMove(window.id, rect, pointer)}
onMoveCancel={cancelWindowMove}
{activeArtifact ? (
<section
id="artifact-workspace"
aria-label={`${activeArtifact.title}工作区`}
aria-hidden={!showArtifact}
className={cn("relative min-h-0 min-w-0 overflow-hidden", !showArtifact && "hidden")}
>
<AnalysisDocumentView document={window.document} />
</WorkspaceWindow>
))}
{taskViewOpen ? (
<WorkspaceTaskView
windows={taskViewWindows}
previewRects={taskViewPreviewRects}
focusedWindowId={taskViewFocusWindowId}
layoutMode={workspace.layout.mode}
onClose={closeTaskView}
onFocusWindow={setTaskViewFocusWindowId}
onSelectWindow={activateTaskViewWindow}
onWindowKeyDown={handleTaskViewWindowKeyDown}
onLayoutChange={applyTaskViewLayout}
/>
<AnalysisArtifactWorkspace
artifact={activeArtifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
onSetView={onSetArtifactView}
onCollapse={onCollapseArtifact}
onDestroy={onDestroyArtifact}
/>
</section>
) : null}
</div>
<div
className="pointer-events-auto absolute bottom-0 left-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:flex"
role="toolbar"
aria-label="工作区窗口任务栏"
>
<button
ref={taskViewButtonRef}
type="button"
aria-label="任务视图"
aria-pressed={taskViewOpen}
title="任务视图"
className={cn(
"grid h-8 w-10 shrink-0 place-items-center rounded-md transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
taskViewOpen
? "bg-blue-600 text-white shadow-[0_1px_3px_rgba(37,99,235,0.28)]"
: "text-slate-700 hover:bg-white"
)}
onClick={taskViewOpen ? closeTaskView : openTaskView}
>
<PanelsTopLeft size={17} aria-hidden="true" />
</button>
<span className="mx-1 h-5 w-px shrink-0 bg-slate-300" aria-hidden="true" />
<TaskbarButton
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
minimized={workspace.map.mode === "minimized"}
icon={<MapIcon size={15} aria-hidden="true" />}
label="供水管网地图"
onClick={() => {
setTaskViewOpen(false);
setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id));
}}
{pendingArtifact ? (
<ArtifactPeekBar
artifact={pendingArtifact}
surfaceMode={surfaceMode}
onOpen={onOpenPendingArtifact}
onDestroy={onDestroyPendingArtifact}
/>
{workspace.analyses.map((window) => (
<TaskbarButton
key={window.id}
active={workspace.activeWindowId === window.id && window.mode !== "minimized"}
minimized={window.mode === "minimized"}
icon={<Sparkles size={14} aria-hidden="true" />}
label={window.title}
onClick={() => {
setTaskViewOpen(false);
setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id));
}}
/>
))}
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} </span>
</div>
<span className="sr-only" aria-live="polite" aria-atomic="true">{workspaceAnnouncement}</span>
{!desktop ? <span className="sr-only"></span> : null}
) : null}
</div>
);
});
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
aria-label={`${minimized ? "恢复" : "切换到"}${label}`}
title={label}
className={cn(
"flex h-8 max-w-56 items-center gap-2 rounded-md px-3 text-xs font-medium transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : minimized ? "bg-slate-200 text-slate-600 hover:bg-white" : "text-slate-700 hover:bg-white/80"
)}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</button>
);
}
function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
if (windowId === state.map.id) return state.map.title;
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
}