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
+2 -1
View File
@@ -124,5 +124,6 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
); );
expect(navigationMaterial.backdropFilter).toBe("none"); expect(navigationMaterial.backdropFilter).toBe("none");
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)"); expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toBeVisible(); await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
}); });
@@ -10,6 +10,7 @@ type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps; panelProps: AgentPanelProps;
panelOpen: boolean; panelOpen: boolean;
panelCollapsing: boolean; panelCollapsing: boolean;
artifactActive: boolean;
conditionExpanded: boolean; conditionExpanded: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"]; personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string; statusLabel: string;
@@ -23,6 +24,7 @@ export function WorkbenchAgentPanels({
panelProps, panelProps,
panelOpen, panelOpen,
panelCollapsing, panelCollapsing,
artifactActive,
conditionExpanded, conditionExpanded,
personaState, personaState,
statusLabel, statusLabel,
@@ -42,14 +44,20 @@ export function WorkbenchAgentPanels({
return ( return (
<div <div
ref={resize.panelRef} ref={resize.panelRef}
className="pointer-events-none absolute bottom-14 left-3 top-24 z-50 hidden max-w-[720px] lg:block" className={cn(
style={{ width: resize.width ?? defaultWidth }} "pointer-events-none absolute z-50 hidden lg:block",
!artifactActive && panelOpen && "workbench-agent-dock",
artifactActive && panelOpen
? "bottom-4 left-3 top-20 max-w-[720px]"
: "bottom-0 left-0 top-14"
)}
style={{ width: panelOpen ? resize.width ?? defaultWidth : 72 }}
> >
{panelOpen ? ( {panelOpen ? (
<> <>
<AgentCommandPanel <AgentCommandPanel
{...panelProps} {...panelProps}
presentation="desktop-floating" presentation={artifactActive ? "desktop-floating" : "desktop-dock"}
collapsing={panelCollapsing} collapsing={panelCollapsing}
onCollapse={onCollapsePanel} onCollapse={onCollapsePanel}
/> />
@@ -73,11 +81,13 @@ export function WorkbenchAgentPanels({
</div> </div>
</> </>
) : ( ) : (
<AgentCollapsedRail <div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
personaState={personaState} <AgentCollapsedRail
statusLabel={statusLabel} personaState={personaState}
onExpand={onExpandPanel} statusLabel={statusLabel}
/> onExpand={onExpandPanel}
/>
</div>
)} )}
</div> </div>
); );
@@ -148,8 +148,8 @@ export function WorkbenchTopBar({
<div className="hidden lg:block"> <div className="hidden lg:block">
<button <button
type="button" type="button"
aria-label="测试打开 Agent 多图表窗口" aria-label="生成 Agent 模拟分析成果"
title="测试打开 Agent 多图表窗口" title="生成 Agent 模拟分析成果"
data-selection-tone="soft" data-selection-tone="soft"
onClick={onCreateTestAnalysis} onClick={onCreateTestAnalysis}
className={cn("group px-2", headerControlButtonClassName)} className={cn("group px-2", headerControlButtonClassName)}
@@ -43,6 +43,9 @@ describe("workbench responsive layout", () => {
); );
expect(result.current.isLargeScreen).toBe(true); expect(result.current.isLargeScreen).toBe(true);
expect(result.current.shouldShowConditionFeed).toBe(false);
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.shouldShowConditionFeed).toBe(true); expect(result.current.shouldShowConditionFeed).toBe(true);
act(() => result.current.handleConditionExpandedChange(true)); act(() => result.current.handleConditionExpandedChange(true));
@@ -4,7 +4,6 @@ import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
const CONDITION_FEED_EXIT_MS = 170; const CONDITION_FEED_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440; const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
const CONDITION_FEED_DEFAULT_MIN_WIDTH = 1280;
const LARGE_SCREEN_QUERY = "(min-width: 1024px)"; const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
type WorkbenchMobileSheet = "agent" | "condition" | null; type WorkbenchMobileSheet = "agent" | "condition" | null;
@@ -32,7 +31,7 @@ export function useWorkbenchResponsiveLayout({
WORKBENCH_LAYOUT.desktop.agentWidth WORKBENCH_LAYOUT.desktop.agentWidth
); );
const [conditionFeedVisible, setConditionFeedVisible] = useState(false); const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true); const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false); const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null); const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half"); const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
@@ -49,7 +48,7 @@ export function useWorkbenchResponsiveLayout({
}; };
handleViewportChange(); handleViewportChange();
setConditionFeedVisible(window.innerWidth >= CONDITION_FEED_DEFAULT_MIN_WIDTH); setConditionFeedVisible(false);
mediaQuery.addEventListener("change", handleViewportChange); mediaQuery.addEventListener("change", handleViewportChange);
window.addEventListener("resize", handleViewportChange); window.addEventListener("resize", handleViewportChange);
+11
View File
@@ -17,3 +17,14 @@ export type {
LineLayerGroupStylePatch, LineLayerGroupStylePatch,
PointLayerGroupStylePatch PointLayerGroupStylePatch
} from "./map/layer-group-style"; } from "./map/layer-group-style";
export type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
ArtifactLifecycle,
ArtifactMapRelation,
ArtifactRequestedView,
ArtifactType,
ArtifactViewMode,
WorkbenchSurfaceMode
} from "./workspace/workspace-model";
+50 -18
View File
@@ -44,9 +44,9 @@ import {
} from "./components/toolbar-panel"; } from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar"; import { WorkbenchTopBar } from "./components/workbench-top-bar";
import { import {
WorkbenchMainFrame, WorkbenchMainFrame
type WorkbenchMainFrameHandle
} from "./workspace/workbench-main-frame"; } from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session"; import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
@@ -104,7 +104,6 @@ export function MapWorkbenchPage({
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mainFrameRef = useRef<WorkbenchMainFrameHandle | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null); const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false); const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false); const [impactVisible, setImpactVisible] = useState(false);
@@ -147,6 +146,8 @@ export function MapWorkbenchPage({
onFrontendAction: handleFrontendAction, onFrontendAction: handleFrontendAction,
getAccessToken getAccessToken
}); });
const collapseAgentPanel = agent.collapsePanel;
const expandAgentPanel = agent.expandPanel;
const clearActiveTool = useCallback(() => { const clearActiveTool = useCallback(() => {
setActiveToolId(null); setActiveToolId(null);
}, []); }, []);
@@ -173,10 +174,22 @@ export function MapWorkbenchPage({
activeToolOpen: activeToolId !== null, activeToolOpen: activeToolId !== null,
agentPanelOpen: agent.panelOpen, agentPanelOpen: agent.panelOpen,
devPanelOpen, devPanelOpen,
collapseAgentPanel: agent.collapsePanel, collapseAgentPanel,
expandAgentPanel: agent.expandPanel, expandAgentPanel,
onClearActiveTool: clearActiveTool onClearActiveTool: clearActiveTool
}); });
const artifactWorkspace = useArtifactWorkspace(viewportWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const agentOverlaysWorkspace = artifactActive && leftPanelOpen;
useEffect(() => {
if (artifactActive) {
collapseAgentPanel();
setDetailFeature(null);
return;
}
expandAgentPanel();
}, [artifactActive, collapseAgentPanel, expandAgentPanel]);
function openAgentWorkspaceForViewport() { function openAgentWorkspaceForViewport() {
openAgentPanelForViewport(); openAgentPanelForViewport();
@@ -199,7 +212,7 @@ export function MapWorkbenchPage({
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef, mapRef,
mapReady, mapReady,
leftPanelOpen, leftPanelOpen: agentOverlaysWorkspace,
rightPanelOpen, rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded, conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth agentPanelWidth
@@ -238,7 +251,11 @@ export function MapWorkbenchPage({
}); });
const taskTickerAvailable = runningTickerConditions.length > 0; const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker = const shouldShowTaskTicker =
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable; !artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& taskTickerVisible
&& taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion const taskTickerEnterState = prefersReducedMotion
? { ? {
opacity: 1, opacity: 1,
@@ -618,7 +635,7 @@ export function MapWorkbenchPage({
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14), zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
padding: getResponsiveWorkbenchPadding( padding: getResponsiveWorkbenchPadding(
map, map,
leftPanelOpen, agentOverlaysWorkspace,
rightPanelOpen, rightPanelOpen,
rightPanelExpanded, rightPanelExpanded,
agentPanelWidth agentPanelWidth
@@ -776,7 +793,11 @@ export function MapWorkbenchPage({
{ {
...WORKBENCH_LAYOUT_CSS_VARIABLES, ...WORKBENCH_LAYOUT_CSS_VARIABLES,
"--workbench-agent-current-width": `${ "--workbench-agent-current-width": `${
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth artifactActive
? WORKBENCH_LAYOUT.collapsedAgentWidth
: leftPanelOpen
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth
}px` }px`
} as CSSProperties } as CSSProperties
} }
@@ -802,7 +823,7 @@ export function MapWorkbenchPage({
onCompareScenario={handleCompareScenario} onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport} onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent} onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onCreateTestAnalysis={devPanelEnabled ? () => mainFrameRef.current?.openTestAnalysis() : undefined} onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
onShowDataStatus={handleShowDataStatus} onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles} onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts} onShowShortcuts={handleShowShortcuts}
@@ -811,7 +832,15 @@ export function MapWorkbenchPage({
/> />
<WorkbenchMainFrame <WorkbenchMainFrame
ref={mainFrameRef} activeArtifact={artifactWorkspace.activeArtifact}
pendingArtifact={artifactWorkspace.pendingArtifact}
surfaceMode={artifactWorkspace.surfaceMode}
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
onOpenPendingArtifact={artifactWorkspace.openPreview}
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
onSetArtifactView={artifactWorkspace.setView}
onCollapseArtifact={artifactWorkspace.collapseArtifact}
onDestroyArtifact={artifactWorkspace.destroyArtifact}
mapContent={( mapContent={(
<div <div
ref={mapContainerRef} ref={mapContainerRef}
@@ -842,6 +871,7 @@ export function MapWorkbenchPage({
panelProps={agentPanelProps} panelProps={agentPanelProps}
panelOpen={agent.panelOpen} panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing} panelCollapsing={agent.panelCollapsing}
artifactActive={artifactActive}
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded} conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
personaState={agent.personaState} personaState={agent.personaState}
statusLabel={agent.statusLabel} statusLabel={agent.statusLabel}
@@ -880,7 +910,7 @@ export function MapWorkbenchPage({
/> />
) : null} ) : null}
{mobileSheet === null ? ( {mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden"> <div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
<ToolbarPanel <ToolbarPanel
{...toolbarPanelProps} {...toolbarPanelProps}
@@ -912,7 +942,7 @@ export function MapWorkbenchPage({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden"> <div className={`absolute z-30 lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "bottom-[4.25rem] right-3" : "bottom-3 left-3 right-3"}`}>
{mobileSheet === null ? ( {mobileSheet === null ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
@@ -924,11 +954,13 @@ export function MapWorkbenchPage({
> >
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} /> <AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
</button> </button>
<MapToolbar {artifactWorkspace.surfaceMode !== "focus" ? (
items={toolbarItems} <MapToolbar
orientation="horizontal" items={toolbarItems}
className="h-11 justify-center" orientation="horizontal"
/> className="h-11 justify-center"
/>
) : null}
<button <button
type="button" type="button"
aria-label="打开工况任务" aria-label="打开工况任务"
@@ -1,12 +1,20 @@
import type { AnalysisDocument } from "./workspace-model"; import type { AnalysisArtifact } from "./workspace-model";
type FixtureFactory = (id: string, generatedAt: string) => AnalysisDocument; type FixtureFactory = (id: string, generatedAt: string) => AnalysisArtifact;
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({ const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
id, id,
type: "diagnosis",
lifecycle: "reviewed",
revision: 3,
title: "北辰分区压力异常诊断", title: "北辰分区压力异常诊断",
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况", subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
scope: "北辰供水分区 · 最近 6 小时",
confidence: "92.6%",
generatedAt, generatedAt,
preferredView: "map_split",
mapRelation: "required",
blocks: [ blocks: [
{ {
id: "pressure-metrics", id: "pressure-metrics",
@@ -65,14 +73,28 @@ const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" } { time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
] ]
} }
],
sources: ["SCADA 压力遥测", "泵站运行记录", "供水分区拓扑"],
limitations: ["BC-P-026 在 09:12 至 09:24 存在数据缺口", "尚未纳入现场阀门开度复核结果"],
actions: [
{ id: "locate", label: "定位异常测点", description: "在地图中定位 7 个异常测点", tone: "primary" },
{ id: "review", label: "标记为已复核", description: "将当前版本进入复核完成状态", tone: "neutral" }
] ]
}); });
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({ const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
id, id,
type: "metric_analysis",
lifecycle: "draft",
revision: 2,
title: "供水服务分区平衡分析", title: "供水服务分区平衡分析",
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道", subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
scope: "六个服务分区 · 今日峰值预测",
confidence: "88.4%",
generatedAt, generatedAt,
preferredView: "focus",
mapRelation: "none",
blocks: [ blocks: [
{ {
id: "zone-metrics", id: "zone-metrics",
@@ -129,14 +151,28 @@ const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。" "执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
] ]
} }
],
sources: ["分区日供水量", "短期需求预测", "连通能力台账"],
limitations: ["需求预测未包含临时大型活动增量", "联络线能力采用最近一次校核值"],
actions: [
{ id: "compare", label: "生成方案对比", description: "基于当前结论创建调配模拟", tone: "primary" },
{ id: "export", label: "导出摘要", description: "导出当前分析摘要", tone: "neutral" }
] ]
}); });
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({ const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
id, id,
type: "simulation",
lifecycle: "published",
revision: 5,
title: "低压事件调度处置流程", title: "低压事件调度处置流程",
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程", subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
scope: "北辰低压事件 · 30 分钟模拟",
confidence: "90.8%",
generatedAt, generatedAt,
preferredView: "map_split",
mapRelation: "required",
blocks: [ blocks: [
{ {
id: "flow-metrics", id: "flow-metrics",
@@ -196,12 +232,18 @@ const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" } { name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
] ]
} }
],
sources: ["低压事件诊断 R3", "调度规则库", "水力模拟结果"],
limitations: ["模拟未计入突发大用户需求变化", "远控动作必须由值班调度二次确认"],
actions: [
{ id: "prepare", label: "准备受控执行", description: "生成执行前确认清单", tone: "primary" },
{ id: "archive", label: "归档方案", description: "保留当前模拟版本", tone: "neutral" }
] ]
}); });
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const; const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
export function createAnalysisDocumentFixture(index: number, instance: number): AnalysisDocument { export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS; const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
const now = new Date(); const now = new Date();
return factory( return factory(
@@ -1,175 +1,425 @@
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-react"; import {
Activity,
AlertTriangle,
Check,
ChevronRight,
Clock3,
Database,
FileSearch,
Focus,
Map as MapIcon,
Minimize2,
PanelRightClose,
PanelRightOpen,
ShieldCheck,
Sparkles,
Trash2,
X
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import type { import type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock, AnalysisBlock,
AnalysisDocument, AnalysisMetric,
AnalysisMetric ArtifactRequestedView,
WorkbenchSurfaceMode
} from "./workspace-model"; } from "./workspace-model";
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) { type AnalysisArtifactWorkspaceProps = {
return ( artifact: AnalysisArtifact;
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN"> surfaceMode: WorkbenchSurfaceMode;
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm lg:pl-16"> mapSplitAvailable: boolean;
<div className="min-w-0"> onSetView: (view: ArtifactRequestedView) => void;
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700"> onCollapse: () => void;
<Sparkles size={14} aria-hidden="true" /> onDestroy: () => void;
Agent };
</div>
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
<Clock3 size={13} aria-hidden="true" />
{document.generatedAt}
</div>
</header>
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5"> export function AnalysisArtifactWorkspace({
{document.blocks.map((block) => ( artifact,
<AnalysisBlockView key={block.id} block={block} /> surfaceMode,
))} mapSplitAvailable,
onSetView,
onCollapse,
onDestroy
}: AnalysisArtifactWorkspaceProps) {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
const selectedBlock = useMemo(
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
[artifact.blocks, selectedBlockId]
);
useEffect(() => {
setSelectedBlockId(null);
}, [artifact.id]);
useEffect(() => {
if (!selectedBlock) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSelectedBlockId(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedBlock]);
return (
<article
aria-label={`${artifact.title}分析成果`}
className="relative grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden bg-[#eef2f6] text-slate-900"
lang="zh-CN"
>
<ArtifactHeader artifact={artifact} />
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
<section aria-labelledby="artifact-summary-heading">
<div className="mb-3 flex items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950"></h3>
</div>
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
</div>
{metricBlock?.kind === "metric-grid" ? (
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
</div>
) : null}
</section>
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
<div className="mb-3 flex items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950"></h3>
</div>
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} </span>
</div>
<div className="grid grid-cols-12 gap-3.5">
{evidenceBlocks.map((block) => (
<AnalysisBlockView
key={block.id}
block={block}
selected={block.id === selectedBlockId}
onSelect={() => setSelectedBlockId(block.id)}
/>
))}
</div>
</section>
<ArtifactProvenance artifact={artifact} />
</div> </div>
<ArtifactActionBar
artifact={artifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
detailOpen={Boolean(selectedBlock)}
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
onSetView={onSetView}
onCollapse={onCollapse}
onDestroy={onDestroy}
/>
{selectedBlock ? (
<ArtifactDetailDrawer
artifact={artifact}
block={selectedBlock}
onClose={() => setSelectedBlockId(null)}
/>
) : null}
</article> </article>
); );
} }
function AnalysisBlockView({ block }: { block: AnalysisBlock }) { function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
const spanClass = block.span === 5 ? "col-span-12 xl:col-span-5" : block.span === 7 ? "col-span-12 xl:col-span-7" : "col-span-12"; return (
<header className="relative z-10 bg-white px-4 py-4 shadow-[0_1px_0_rgba(15,23,42,0.08),0_5px_18px_rgba(15,23,42,0.04)] xl:px-5">
if (block.kind === "metric-grid") { <div className="flex items-start justify-between gap-5">
return ( <div className="min-w-0">
<section className={spanClass} aria-labelledby={`${block.id}-title`}> <div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3> <span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4"> <Sparkles size={14} aria-hidden="true" />
{block.metrics.map((metric) => ( Agent Artifact
<MetricTile key={metric.label} metric={metric} /> </span>
))} <span aria-hidden="true">·</span>
<span>{artifactTypeLabel(artifact.type)}</span>
<span aria-hidden="true">·</span>
<span>R{artifact.revision}</span>
</div>
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
</div> </div>
</section> <div className="flex shrink-0 flex-col items-end gap-2">
); <LifecycleBadge lifecycle={artifact.lifecycle} />
} <span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
<Clock3 size={13} aria-hidden="true" />
{artifact.generatedAt}
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
<span><strong className="font-medium text-slate-700"></strong> {artifact.scope}</span>
<span><strong className="font-medium text-slate-700"></strong> {artifact.confidence}</span>
<span><strong className="font-medium text-slate-700"></strong> {mapRelationLabel(artifact.mapRelation)}</span>
</div>
</header>
);
}
function AnalysisBlockView({
block,
selected,
onSelect
}: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
selected: boolean;
onSelect: () => void;
}) {
const spanClass = block.span === 5
? "col-span-12 2xl:col-span-5"
: block.span === 7
? "col-span-12 2xl:col-span-7"
: "col-span-12";
return (
<section
aria-labelledby={`${block.id}-title`}
className={cn(
"group min-w-0 overflow-hidden bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]",
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
spanClass,
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
)}
>
<button
type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]"
aria-label={`查看${block.title}详情`}
onClick={onSelect}
>
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700">
<FileSearch size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
</span>
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
</button>
<div className="border-t border-slate-100 p-4">
<AnalysisBlockBody block={block} />
</div>
</section>
);
}
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
if (block.kind === "chart") { if (block.kind === "chart") {
return ( return (
<AnalysisSection block={block} className={spanClass}> <div className="h-[258px] min-h-0">
<div className="h-[258px] min-h-0"> <ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
<ReactECharts </div>
notMerge
lazyUpdate
style={{ width: "100%", height: "100%" }}
option={createChartOption(block)}
/>
</div>
</AnalysisSection>
); );
} }
if (block.kind === "process-flow") { if (block.kind === "process-flow") {
return ( return (
<AnalysisSection block={block} className={spanClass}> <ol className="space-y-0" aria-label={block.title}>
<ol className="space-y-0" aria-label={block.title}> {block.nodes.map((node, index) => (
{block.nodes.map((node, index) => ( <li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0"> {index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
{index < block.nodes.length - 1 ? ( <span className={cn(
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> "relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
) : null} node.status === "complete" && "bg-emerald-100 text-emerald-700",
<span node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
className={cn( node.status === "pending" && "bg-slate-100 text-slate-500"
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full", )}>
node.status === "complete" && "bg-emerald-100 text-emerald-700", {node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]", </span>
node.status === "pending" && "bg-slate-100 text-slate-500" <div className="min-w-0 pt-0.5">
)} <div className="flex items-center gap-2">
> <p className="text-sm font-semibold text-slate-900">{node.label}</p>
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />} {node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700"></span> : null}
</span>
<div className="min-w-0 pt-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700"></span> : null}
</div>
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
</div> </div>
</li> <p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
))} </div>
</ol> </li>
</AnalysisSection> ))}
</ol>
); );
} }
if (block.kind === "data-table") { if (block.kind === "data-table") {
return ( return (
<AnalysisSection block={block} className={spanClass}> <div className="overflow-x-auto">
<div className="overflow-x-auto"> <table className="w-full min-w-[520px] border-collapse text-sm">
<table className="w-full min-w-[520px] border-collapse text-sm"> <thead>
<thead> <tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500"> {block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
{block.columns.map((column) => ( </tr>
<th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th> </thead>
))} <tbody>
{block.rows.map((row, index) => (
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{block.rows.map((row, index) => ( </table>
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0"> </div>
{block.columns.map((column) => (
<td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</AnalysisSection>
); );
} }
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
}
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return ( return (
<AnalysisSection block={block} className={spanClass}> <section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
<div className="space-y-3 text-sm leading-7 text-slate-700"> <div className="bg-[#e7edf3] px-4 py-3.5">
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)} <h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" /></h3>
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
</div> </div>
</AnalysisSection> <div className="bg-amber-50 px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" /></h3>
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}> {item}</li>)}</ul>
</div>
</section>
); );
} }
function AnalysisSection({ function ArtifactDetailDrawer({
artifact,
block, block,
className, onClose
children
}: { }: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>; artifact: AnalysisArtifact;
className: string; block: AnalysisBlock;
children: React.ReactNode; onClose: () => void;
}) { }) {
return ( return (
<section className={cn("min-w-0 overflow-hidden rounded-lg bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]", className)} aria-labelledby={`${block.id}-title`}> <aside aria-label="证据详情" className="absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain bg-white shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5"> <div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700"><FileSearch size={16} aria-hidden="true" /></span> <div>
<div className="min-w-0"> <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3> <h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
</div> </div>
</header> <button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95">
<div className="p-4">{children}</div> <X size={18} aria-hidden="true" />
</section> </button>
</div>
<div className="space-y-5 p-5">
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
</div>
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
</div>
<div className="bg-slate-100 p-4">
<p className="text-xs font-semibold text-slate-700"></p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.confidence}</dd>
</dl>
</div>
</div>
</aside>
);
}
function ArtifactActionBar({
artifact,
surfaceMode,
mapSplitAvailable,
detailOpen,
onToggleDetail,
onSetView,
onCollapse,
onDestroy
}: {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
detailOpen: boolean;
onToggleDetail: () => void;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void;
onDestroy: () => void;
}) {
return (
<footer className="relative z-20 flex min-h-14 items-center justify-between gap-3 bg-white px-3 shadow-[0_-1px_0_rgba(15,23,42,0.08),0_-6px_18px_rgba(15,23,42,0.04)]">
<div className="flex min-w-0 items-center gap-1">
{artifact.mapRelation !== "none" ? (
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
</button>
) : null}
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button>
</div>
</footer>
);
}
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
return (
<button
type="button"
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}${action.description}` })}
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex"
>
<ShieldCheck size={15} aria-hidden="true" />
{action.label}
</button>
); );
} }
function MetricTile({ metric }: { metric: AnalysisMetric }) { function MetricTile({ metric }: { metric: AnalysisMetric }) {
return ( return (
<div className="min-w-0 rounded-lg bg-white px-4 py-3.5 shadow-[0_1px_4px_rgba(15,23,42,0.12)]"> <div className="min-w-0 bg-white px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" /> <span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p> <p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
</div> </div>
<p className="mt-2 text-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p> <p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
<p className="mt-1 truncate text-xs text-slate-500" title={metric.detail}>{metric.detail}</p> <p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
</div> </div>
); );
} }
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
}
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
if (type === "diagnosis") return "异常诊断";
if (type === "simulation") return "方案模拟";
return "指标分析";
}
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
if (relation === "required") return "强关联";
if (relation === "optional") return "可选";
return "无空间依赖";
}
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) { function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
return { return {
animationDuration: 260, animationDuration: 260,
@@ -0,0 +1,55 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
export function ArtifactPeekBar({
artifact,
surfaceMode,
onOpen,
onDestroy
}: {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
onOpen: () => void;
onDestroy: () => void;
}) {
return (
<aside
aria-label="Agent 成果预览"
className={cn(
"pointer-events-auto absolute bottom-4 z-30 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 bg-slate-950 px-3 py-2.5 text-slate-100 shadow-[0_18px_44px_rgba(15,23,42,0.28)]",
"bottom-16 left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]"
)}
>
<span className="hidden h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-300 sm:grid">
<FileChartColumnIncreasing size={21} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold text-blue-300">
<Sparkles size={12} aria-hidden="true" />
· R{artifact.revision}
</div>
<p className="mt-1 truncate text-sm font-semibold text-slate-50">{artifact.title}</p>
<p className="mt-0.5 line-clamp-1 text-xs text-slate-400">{artifact.summary}</p>
</div>
<button
type="button"
onClick={onOpen}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-md bg-blue-500 px-3 text-xs font-semibold text-white hover:bg-blue-400 active:scale-95"
>
<ChevronUp size={15} aria-hidden="true" />
</button>
<button
type="button"
aria-label="销毁预览成果"
title="销毁预览成果"
onClick={onDestroy}
className="grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 hover:bg-white/10 hover:text-rose-300 active:scale-95"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</aside>
);
}
@@ -0,0 +1,67 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import {
collapseActiveArtifact,
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
resolveWorkbenchSurfaceMode,
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
type ArtifactRequestedView
} from "./workspace-model";
export function useArtifactWorkspace(viewportWidth: number) {
const [state, setState] = useState(createInitialArtifactWorkspaceState);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
const createTestArtifactPreview = useCallback(() => {
const artifact = createAnalysisArtifactFixture(
fixtureIndexRef.current,
fixtureInstanceRef.current
);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const openPreview = useCallback(() => {
setState(openPendingArtifact);
}, []);
const collapseArtifact = useCallback(() => {
setState(collapseActiveArtifact);
}, []);
const destroyPreview = useCallback(() => {
setState(destroyPendingArtifact);
}, []);
const destroyArtifact = useCallback(() => {
setState(destroyActiveArtifact);
}, []);
const setView = useCallback((view: ArtifactRequestedView) => {
setState((current) => requestArtifactView(current, view));
}, []);
const surfaceMode = useMemo(
() => resolveWorkbenchSurfaceMode(state, viewportWidth),
[state, viewportWidth]
);
return {
...state,
surfaceMode,
mapSplitAvailable: viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
createTestArtifactPreview,
openPreview,
collapseArtifact,
destroyPreview,
destroyArtifact,
setView
};
}
@@ -1,436 +1,109 @@
import { import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
Map as MapIcon, import type { ReactNode } from "react";
PanelsTopLeft,
Sparkles
} from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { AnalysisDocumentView } from "./analysis-document-view"; import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures"; import { ArtifactPeekBar } from "./artifact-peek-bar";
import { import type {
MAX_ANALYSIS_WINDOWS, AnalysisArtifact,
applyWorkspaceLayout, ArtifactRequestedView,
closeAnalysisWindow, WorkbenchSurfaceMode
createInitialWorkspaceState,
focusWorkspaceWindow,
maximizeWorkspaceWindow,
minimizeWorkspaceWindow,
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect,
type WorkspaceBounds,
type WorkspaceLayoutMode,
type WorkspaceRect,
type WorkspaceState
} from "./workspace-model"; } 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 = { type WorkbenchMainFrameProps = {
mapContent: ReactNode; mapContent: ReactNode;
mapOverlay?: 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 = { export function WorkbenchMainFrame({
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({
mapContent, mapContent,
mapOverlay mapOverlay,
}, ref) { activeArtifact,
const workspaceRef = useRef<HTMLDivElement | null>(null); pendingArtifact,
const dragStateRef = useRef<WorkspaceDragState | null>(null); surfaceMode,
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null); mapSplitAvailable,
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS); onOpenPendingArtifact,
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS)); onDestroyPendingArtifact,
const [desktop, setDesktop] = useState(false); onSetArtifactView,
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null); onCollapseArtifact,
const [taskViewOpen, setTaskViewOpen] = useState(false); onDestroyArtifact
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map"); }: WorkbenchMainFrameProps) {
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState(""); const showMap = surfaceMode !== "focus";
const fixtureIndexRef = useRef(0); const showArtifact = activeArtifact && surfaceMode !== "map_only";
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);
}
return ( return (
<div <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" data-testid="workbench-main-frame"
> >
<div <div
ref={workspaceRef}
id="main-workspace" id="main-workspace"
data-layout-mode={workspace.layout.mode} data-layout-mode={surfaceMode}
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10" 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 <section
id={workspace.map.id} id="workspace-map"
title={workspace.map.title} aria-label="供水管网地图工作区"
mode={desktop ? workspace.map.mode : "docked"} aria-hidden={!showMap}
rect={workspace.map.rect} className={cn(
zIndex={workspace.map.zIndex} "relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
active={workspace.activeWindowId === workspace.map.id} !showMap && "hidden"
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}
> >
{mapContent} {mapContent}
{mapOverlay} {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) => ( {activeArtifact ? (
<WorkspaceWindow <section
key={window.id} id="artifact-workspace"
id={window.id} aria-label={`${activeArtifact.title}工作区`}
title={window.title} aria-hidden={!showArtifact}
mode={window.mode} className={cn("relative min-h-0 min-w-0 overflow-hidden", !showArtifact && "hidden")}
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}
> >
<AnalysisDocumentView document={window.document} /> <AnalysisArtifactWorkspace
</WorkspaceWindow> artifact={activeArtifact}
))} surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
{taskViewOpen ? ( onSetView={onSetArtifactView}
<WorkspaceTaskView onCollapse={onCollapseArtifact}
windows={taskViewWindows} onDestroy={onDestroyArtifact}
previewRects={taskViewPreviewRects} />
focusedWindowId={taskViewFocusWindowId} </section>
layoutMode={workspace.layout.mode}
onClose={closeTaskView}
onFocusWindow={setTaskViewFocusWindowId}
onSelectWindow={activateTaskViewWindow}
onWindowKeyDown={handleTaskViewWindowKeyDown}
onLayoutChange={applyTaskViewLayout}
/>
) : null} ) : null}
</div> </div>
<div {pendingArtifact ? (
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" <ArtifactPeekBar
role="toolbar" artifact={pendingArtifact}
aria-label="工作区窗口任务栏" surfaceMode={surfaceMode}
> onOpen={onOpenPendingArtifact}
<button onDestroy={onDestroyPendingArtifact}
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));
}}
/> />
{workspace.analyses.map((window) => ( ) : null}
<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}
</div> </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 ?? "工作区窗口";
} }
@@ -1,230 +1,105 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import { import {
MAX_ANALYSIS_WINDOWS, ARTIFACT_MAP_SPLIT_MIN_WIDTH,
applyWorkspaceLayout, collapseActiveArtifact,
closeAnalysisWindow, createInitialArtifactWorkspaceState,
createInitialWorkspaceState, destroyActiveArtifact,
maximizeWorkspaceWindow, destroyPendingArtifact,
minimizeWorkspaceWindow, openPendingArtifact,
openAnalysisDocument, previewAnalysisArtifact,
resizeWorkspace, requestArtifactView,
restoreWorkspaceWindow, resolveWorkbenchSurfaceMode
setWorkspacePrimaryWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect,
type AnalysisDocument,
type WorkspaceState
} from "./workspace-model"; } from "./workspace-model";
const bounds = { width: 1180, height: 760 }; describe("artifact workspace state", () => {
it("previews generated artifacts without taking over the workspace", () => {
const initial = createInitialArtifactWorkspaceState();
const artifact = createAnalysisArtifactFixture(0, 0);
const previewed = previewAnalysisArtifact(initial, artifact);
function documentAt(index: number): AnalysisDocument { expect(previewed.pendingArtifact).toBe(artifact);
return { expect(previewed.activeArtifact).toBeNull();
id: `analysis-${index}`, expect(resolveWorkbenchSurfaceMode(previewed, 1920)).toBe("default");
title: `分析 ${index}`,
subtitle: "测试",
generatedAt: "10:40:00",
blocks: []
};
}
function openDocuments(count: number) {
return Array.from({ length: count }, (_, index) => index + 1).reduce(
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
createInitialWorkspaceState(bounds)
);
}
function visibleIds(state: WorkspaceState) {
return [state.map, ...state.analyses]
.filter((window) => window.mode !== "minimized")
.map((window) => window.id);
}
describe("workspace window lifecycle", () => {
it("keeps the map visible under the first analysis and restores it after the last window closes", () => {
const initial = createInitialWorkspaceState(bounds);
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
expect(opened.map.mode).toBe("docked");
expect(opened.analyses).toHaveLength(1);
const closed = closeAnalysisWindow(opened, "analysis-1");
expect(closed.analyses).toHaveLength(0);
expect(closed.map.mode).toBe("docked");
expect(closed.activeWindowId).toBe("workspace-map");
}); });
it("supports minimize, maximize and bounded restore", () => { it("opens a map-related artifact in split mode only when enough width exists", () => {
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds); const artifact = createAnalysisArtifactFixture(0, 0);
const minimized = minimizeWorkspaceWindow(opened, "analysis-1"); const opened = openPendingArtifact(
expect(minimized.analyses[0]?.mode).toBe("minimized"); previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
const restored = restoreWorkspaceWindow(minimized, "analysis-1", bounds);
expect(restored.analyses[0]?.mode).toBe("floating");
const maximized = maximizeWorkspaceWindow(restored, "analysis-1");
expect(maximized.analyses[0]?.mode).toBe("maximized");
const resized = updateWorkspaceWindowRect(
maximized,
"analysis-1",
{ x: -100, y: -80, width: 4000, height: 3000 },
bounds
);
expect(resized.analyses[0]?.rect).toEqual({ x: 12, y: 12, width: 1156, height: 736 });
});
it("caps transient analysis windows without deleting an existing result", () => {
const state = openDocuments(MAX_ANALYSIS_WINDOWS);
const overflow = openAnalysisDocument(state, documentAt(99), bounds);
expect(overflow).toBe(state);
expect(overflow.analyses).toHaveLength(MAX_ANALYSIS_WINDOWS);
});
it("reclamps current and restore rectangles when the workspace shrinks", () => {
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
const moved = updateWorkspaceWindowRect(
opened,
"analysis-1",
{ x: 500, y: 260, width: 660, height: 480 },
bounds
); );
const resized = resizeWorkspace(moved, { width: 800, height: 600 }); expect(opened.activeArtifact).toBe(artifact);
const analysis = resized.analyses[0]; expect(opened.pendingArtifact).toBeNull();
expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 }); expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH)).toBe("map_split");
expect(analysis?.restoreRect).toEqual(analysis?.rect); expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus");
expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788);
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
}); });
});
describe("workspace layout presets", () => { it("keeps non-spatial artifacts focused at every desktop width", () => {
it("places the active window first, keeps the map visible and minimizes primary-layout overflow", () => { const artifact = createAnalysisArtifactFixture(1, 0);
const arranged = applyWorkspaceLayout(openDocuments(5), "primary", bounds); const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
expect(arranged.layout.mode).toBe("primary"); );
expect(arranged.layout.orderedWindowIds[0]).toBe("analysis-5");
expect(visibleIds(arranged)).toEqual(["workspace-map", "analysis-4", "analysis-5"]);
expect(arranged.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
const primary = arranged.analyses.find((window) => window.id === "analysis-5");
expect(primary?.rect.width).toBeGreaterThan(arranged.map.rect.width);
expect(primary?.rect.height).toBe(bounds.height - 16);
});
it("restores every window into a bounded grid", () => {
const arranged = applyWorkspaceLayout(openDocuments(6), "grid", bounds);
const windows = [arranged.map, ...arranged.analyses];
expect(windows).toHaveLength(7);
expect(windows.every((window) => window.mode === "floating")).toBe(true);
for (const window of windows) {
expect(window.rect.x).toBeGreaterThanOrEqual(8);
expect(window.rect.y).toBeGreaterThanOrEqual(8);
expect(window.rect.x + window.rect.width).toBeLessThanOrEqual(bounds.width - 8);
expect(window.rect.y + window.rect.height).toBeLessThanOrEqual(bounds.height - 8);
}
});
it("uses two visible slots for split and restores all windows when changing presets", () => {
const primary = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
const split = applyWorkspaceLayout(primary, "split", bounds);
expect(visibleIds(split)).toHaveLength(2);
expect(visibleIds(split)).toContain("workspace-map");
expect(split.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
const grid = applyWorkspaceLayout(split, "grid", bounds); expect(artifact.mapRelation).toBe("none");
expect(visibleIds(grid)).toHaveLength(5); expect(resolveWorkbenchSurfaceMode(opened, 1920)).toBe("focus");
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("focus");
}); });
it("auto-inserts a new Agent result into the current layout", () => { it("supports explicit focus and map restoration for spatial artifacts", () => {
const primary = applyWorkspaceLayout(openDocuments(2), "primary", bounds); const artifact = createAnalysisArtifactFixture(2, 0);
const opened = openAnalysisDocument(primary, documentAt(3), bounds); const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
const focused = requestArtifactView(opened, "focus");
const restored = requestArtifactView(focused, "map_split");
expect(opened.layout.mode).toBe("primary"); expect(resolveWorkbenchSurfaceMode(focused, 1920)).toBe("focus");
expect(opened.layout.orderedWindowIds[0]).toBe("analysis-3"); expect(resolveWorkbenchSurfaceMode(restored, 1920)).toBe("map_split");
expect(opened.activeWindowId).toBe("analysis-3");
expect(opened.map.mode).not.toBe("minimized");
}); });
it("reflows after manual minimize, taskbar restore and workspace resize", () => { it("supports a map-only page while a narrow Artifact remains active", () => {
const arranged = applyWorkspaceLayout(openDocuments(4), "primary", bounds); const artifact = createAnalysisArtifactFixture(0, 0);
const minimized = minimizeWorkspaceWindow(arranged, "analysis-4", bounds); const opened = openPendingArtifact(
const replacement = minimized.analyses.find((window) => window.id === "analysis-3"); previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
expect(minimized.analyses.find((window) => window.id === "analysis-4")?.minimizedByLayout).toBe(false); const mapOnly = requestArtifactView(opened, "map_only");
expect(replacement?.mode).toBe("floating");
const restored = restoreWorkspaceWindow(minimized, "analysis-4", bounds);
expect(restored.layout.orderedWindowIds[0]).toBe("analysis-4");
expect(restored.analyses.find((window) => window.id === "analysis-4")?.mode).toBe("floating");
const resized = resizeWorkspace(restored, { width: 900, height: 620 }); expect(resolveWorkbenchSurfaceMode(mapOnly, 390)).toBe("map_only");
expect(resized.analyses.find((window) => window.id === "analysis-4")?.rect.height).toBe(604); expect(mapOnly.activeArtifact).toBe(artifact);
}); });
it("supports cascade layout and an explicit primary-window command", () => { it("collapses the active artifact back into a peek", () => {
const cascade = applyWorkspaceLayout(openDocuments(3), "cascade", bounds); const artifact = createAnalysisArtifactFixture(0, 0);
expect(visibleIds(cascade)).toHaveLength(4); const opened = openPendingArtifact(
expect(cascade.analyses[0]?.rect.x).toBeGreaterThan(cascade.analyses[1]?.rect.x ?? 0); previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
const collapsed = collapseActiveArtifact(opened);
const primary = applyWorkspaceLayout(cascade, "primary", bounds); expect(collapsed.activeArtifact).toBeNull();
const promoted = setWorkspacePrimaryWindow(primary, "analysis-1", bounds); expect(collapsed.pendingArtifact).toBe(artifact);
expect(promoted.layout.orderedWindowIds[0]).toBe("analysis-1"); expect(resolveWorkbenchSurfaceMode(collapsed, 1920)).toBe("default");
expect(promoted.analyses.find((window) => window.id === "analysis-1")?.rect.width).toBeGreaterThan(promoted.map.rect.width);
}); });
});
describe("workspace window exchange", () => {
it("swaps layout slots and preserves the order after resize", () => {
const arranged = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
const mapRect = arranged.map.rect;
const primaryRect = arranged.analyses.find((window) => window.id === "analysis-2")?.rect;
const swapped = swapWorkspaceWindows(arranged, "workspace-map", "analysis-2", bounds);
expect(swapped.map.rect).toEqual(primaryRect); it("replaces the active artifact only after the new preview is opened", () => {
expect(swapped.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual(mapRect); const first = createAnalysisArtifactFixture(0, 0);
const second = createAnalysisArtifactFixture(2, 1);
const resized = resizeWorkspace(swapped, { width: 1000, height: 700 }); const opened = openPendingArtifact(
expect(resized.layout.orderedWindowIds.indexOf("workspace-map")).toBeLessThan( previewAnalysisArtifact(createInitialArtifactWorkspaceState(), first)
resized.layout.orderedWindowIds.indexOf("analysis-2")
); );
expect(resized.map.rect.width).toBeGreaterThan(resized.analyses.find((window) => window.id === "analysis-2")!.rect.width); const withNewPreview = previewAnalysisArtifact(opened, second);
});
it("exchanges full window roles and rectangles in free layout", () => { expect(withNewPreview.activeArtifact).toBe(first);
const initial = createInitialWorkspaceState(bounds); expect(withNewPreview.pendingArtifact).toBe(second);
const mapRect = initial.map.rect; expect(openPendingArtifact(withNewPreview).activeArtifact).toBe(second);
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
const analysisRect = opened.analyses[0]!.rect;
const restoredMap = { ...opened, map: { ...opened.map, mode: "docked" as const } };
const swapped = swapWorkspaceWindows(restoredMap, "analysis-1", "workspace-map", bounds);
expect(swapped.analyses[0]?.mode).toBe("docked");
expect(swapped.analyses[0]?.rect).toEqual(mapRect);
expect(swapped.map.mode).toBe("floating");
expect(swapped.map.rect).toEqual(analysisRect);
}); });
it("exits automatic layout when a window is placed in blank workspace", () => { it("destroys pending and active artifacts independently", () => {
const arranged = applyWorkspaceLayout(openDocuments(2), "grid", bounds); const artifact = createAnalysisArtifactFixture(0, 0);
const moved = updateWorkspaceWindowRect( const previewed = previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact);
arranged, const opened = openPendingArtifact(previewed);
"analysis-2",
{ x: 90, y: 70, width: 420, height: 360 },
bounds
);
expect(moved.layout.mode).toBe("free"); expect(destroyPendingArtifact(previewed).pendingArtifact).toBeNull();
expect(moved.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual({ expect(destroyActiveArtifact(opened).activeArtifact).toBeNull();
x: 90,
y: 70,
width: 420,
height: 360
});
}); });
}); });
@@ -1,19 +1,11 @@
export const MAX_ANALYSIS_WINDOWS = 6; export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = 1360;
export type WorkspaceRect = { export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
x: number; export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
y: number; export type ArtifactViewMode = "map_split" | "focus";
width: number; export type ArtifactRequestedView = ArtifactViewMode | "map_only";
height: number; export type ArtifactMapRelation = "required" | "optional" | "none";
}; export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView;
export type WorkspaceBounds = {
width: number;
height: number;
};
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
export type WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
export type AnalysisMetric = { export type AnalysisMetric = {
label: string; label: string;
@@ -78,548 +70,100 @@ export type AnalysisBlock =
paragraphs: string[]; paragraphs: string[];
}; };
export type AnalysisDocument = { export type AnalysisArtifactAction = {
id: string; id: string;
label: string;
description: string;
tone: "primary" | "neutral" | "warning";
};
export type AnalysisArtifact = {
id: string;
type: ArtifactType;
lifecycle: ArtifactLifecycle;
revision: number;
title: string; title: string;
subtitle: string; subtitle: string;
summary: string;
scope: string;
confidence: string;
generatedAt: string; generatedAt: string;
preferredView: ArtifactViewMode;
mapRelation: ArtifactMapRelation;
blocks: AnalysisBlock[]; blocks: AnalysisBlock[];
sources: string[];
limitations: string[];
actions: AnalysisArtifactAction[];
}; };
type WorkspaceWindowState = { export type ArtifactWorkspaceState = {
mode: WorkspaceWindowMode; pendingArtifact: AnalysisArtifact | null;
rect: WorkspaceRect; activeArtifact: AnalysisArtifact | null;
restoreRect: WorkspaceRect; requestedView: ArtifactRequestedView;
zIndex: number;
minimizedByLayout: boolean;
}; };
export type AnalysisWorkspaceWindow = WorkspaceWindowState & { export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState {
id: string;
kind: "agent-analysis";
title: string;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = WorkspaceWindowState & {
id: "workspace-map";
kind: "map";
title: string;
};
export type WorkspaceState = {
map: MapWorkspaceWindow;
analyses: AnalysisWorkspaceWindow[];
activeWindowId: string;
nextZIndex: number;
layout: {
mode: WorkspaceLayoutMode;
orderedWindowIds: string[];
};
};
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
const MAP_WINDOW_ID = "workspace-map";
const LAYOUT_INSET = 8;
const LAYOUT_GAP = 8;
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
const mapRect = getDefaultMapRect(bounds);
return { return {
map: { pendingArtifact: null,
id: MAP_WINDOW_ID, activeArtifact: null,
kind: "map", requestedView: "map_split"
title: "供水管网地图",
mode: "docked",
rect: mapRect,
restoreRect: mapRect,
zIndex: 1,
minimizedByLayout: false
},
analyses: [],
activeWindowId: MAP_WINDOW_ID,
nextZIndex: 2,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
}; };
} }
export function openAnalysisDocument( export function previewAnalysisArtifact(
state: WorkspaceState, state: ArtifactWorkspaceState,
document: AnalysisDocument, artifact: AnalysisArtifact
bounds: WorkspaceBounds ): ArtifactWorkspaceState {
): WorkspaceState { return { ...state, pendingArtifact: artifact };
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state; }
const rect = getDefaultAnalysisRect(bounds, state.analyses.length); export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
const window: AnalysisWorkspaceWindow = { if (!state.pendingArtifact) return state;
id: document.id, return {
kind: "agent-analysis", pendingArtifact: null,
title: document.title, activeArtifact: state.pendingArtifact,
mode: "floating", requestedView: getPreferredArtifactView(state.pendingArtifact)
rect,
restoreRect: rect,
zIndex: state.nextZIndex,
minimizedByLayout: false,
document
}; };
const opened: WorkspaceState = {
...state,
analyses: [...state.analyses, window],
activeWindowId: window.id,
nextZIndex: state.nextZIndex + 1,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([window.id, MAP_WINDOW_ID, ...state.layout.orderedWindowIds])
}
};
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
return opened;
} }
export function closeAnalysisWindow( export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
state: WorkspaceState, if (!state.activeArtifact) return state;
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
const analyses = state.analyses.filter((window) => window.id !== windowId);
if (analyses.length === state.analyses.length) return state;
if (analyses.length === 0) {
return {
...state,
map: {
...state.map,
mode: "docked",
minimizedByLayout: false,
zIndex: state.nextZIndex
},
analyses,
activeWindowId: MAP_WINDOW_ID,
nextZIndex: state.nextZIndex + 1,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
};
}
const orderedWindowIds = state.layout.orderedWindowIds.filter((id) => id !== windowId);
const nextActive = state.activeWindowId === windowId
? orderedWindowIds.find((id) => getWindowById({ ...state, analyses }, id)?.mode !== "minimized")
: state.activeWindowId;
const closed: WorkspaceState = {
...state,
analyses,
activeWindowId: nextActive ?? MAP_WINDOW_ID,
layout: { ...state.layout, orderedWindowIds }
};
return state.layout.mode === "free" ? closed : arrangeWorkspaceLayout(closed, bounds);
}
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
return updateWindowById(
{ ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
windowId,
(window) => ({ ...window, zIndex: state.nextZIndex })
);
}
export function minimizeWorkspaceWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
const minimized = updateWindowById(state, windowId, (window) => ({
...window,
mode: "minimized",
minimizedByLayout: false
}));
return state.layout.mode === "free" ? minimized : arrangeWorkspaceLayout(minimized, bounds);
}
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
return focusWorkspaceWindow(
updateWindowById(state, windowId, (window) => ({
...window,
mode: "maximized",
minimizedByLayout: false
})),
windowId
);
}
export function restoreWorkspaceWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
const window = getWindowById(state, windowId);
if (!window) return state;
if (state.layout.mode !== "free") {
const restored = updateWindowById(
{
...state,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
},
windowId,
(current) => ({ ...current, mode: "floating", minimizedByLayout: false })
);
return focusWorkspaceWindow(arrangeWorkspaceLayout(restored, bounds), windowId);
}
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const rect = clampWorkspaceRect(window.restoreRect, bounds, kind);
return focusWorkspaceWindow(
updateWindowById(state, windowId, (current) => ({
...current,
mode: "floating",
rect,
minimizedByLayout: false
})),
windowId
);
}
export function updateWorkspaceWindowRect(
state: WorkspaceState,
windowId: string,
rect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const nextRect = clampWorkspaceRect(rect, bounds, kind);
return updateWindowById(
{
...state,
layout: { ...state.layout, mode: "free" }
},
windowId,
(window) => ({
...window,
mode: "floating",
rect: nextRect,
restoreRect: nextRect,
minimizedByLayout: false
})
);
}
export function applyWorkspaceLayout(
state: WorkspaceState,
mode: WorkspaceLayoutMode,
bounds: WorkspaceBounds
): WorkspaceState {
if (mode === "free") {
return { ...state, layout: { ...state.layout, mode } };
}
const orderedWindowIds = uniqueIds([
state.activeWindowId,
MAP_WINDOW_ID,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id),
...state.layout.orderedWindowIds
]);
const restored = mapAllWorkspaceWindows(
{
...state,
layout: { mode, orderedWindowIds }
},
(window) => ({ ...window, mode: "floating", minimizedByLayout: false })
);
return arrangeWorkspaceLayout(restored, bounds, true);
}
export function setWorkspacePrimaryWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
if (!getWindowById(state, windowId) || state.layout.mode === "free") return state;
const next = {
...state,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
};
return focusWorkspaceWindow(arrangeWorkspaceLayout(next, bounds), windowId);
}
export function swapWorkspaceWindows(
state: WorkspaceState,
sourceId: string,
targetId: string,
bounds: WorkspaceBounds
): WorkspaceState {
const source = getWindowById(state, sourceId);
const target = getWindowById(state, targetId);
if (!source || !target || sourceId === targetId || source.mode === "minimized" || target.mode === "minimized") {
return state;
}
if (state.layout.mode !== "free") {
const order = normalizeWindowOrder(state);
const sourceIndex = order.indexOf(sourceId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0) return state;
[order[sourceIndex], order[targetIndex]] = [order[targetIndex], order[sourceIndex]];
const swapped = { ...state, layout: { ...state.layout, orderedWindowIds: order } };
return focusWorkspaceWindow(arrangeWorkspaceLayout(swapped, bounds), sourceId);
}
const swapped = updateWindowById(
updateWindowById(state, sourceId, (window) => ({
...window,
mode: target.mode,
rect: target.rect,
restoreRect: target.restoreRect,
minimizedByLayout: false
})),
targetId,
(window) => ({
...window,
mode: source.mode,
rect: source.rect,
restoreRect: source.restoreRect,
minimizedByLayout: false
})
);
return focusWorkspaceWindow(swapped, sourceId);
}
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(state, bounds);
return { return {
...state, ...state,
map: { pendingArtifact: state.activeArtifact,
...state.map, activeArtifact: null
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
},
analyses: state.analyses.map((window) => ({
...window,
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}))
}; };
} }
export function clampWorkspaceRect( export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
rect: WorkspaceRect, return state.pendingArtifact ? { ...state, pendingArtifact: null } : state;
bounds: WorkspaceBounds,
kind: "map" | "analysis"
): WorkspaceRect {
const minimumWidth = kind === "map" ? 320 : 360;
const minimumHeight = kind === "map" ? 240 : 280;
const availableWidth = Math.max(280, bounds.width - 24);
const availableHeight = Math.max(240, bounds.height - 24);
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight);
return {
x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)),
y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)),
width,
height
};
} }
function arrangeWorkspaceLayout( export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
state: WorkspaceState, return state.activeArtifact
bounds: WorkspaceBounds, ? { ...state, activeArtifact: null, requestedView: "map_split" }
restoreAll = false : state;
): WorkspaceState {
if (state.layout.mode === "free") return state;
const orderedWindowIds = normalizeWindowOrder(state);
const eligibleIds = orderedWindowIds.filter((id) => {
const window = getWindowById(state, id);
return window && (restoreAll || window.mode !== "minimized" || window.minimizedByLayout);
});
const visibleIds = selectVisibleWindowIds(eligibleIds, state.layout.mode);
const visibleSet = new Set(visibleIds);
const eligibleSet = new Set(eligibleIds);
const rects = getLayoutRects(state.layout.mode, visibleIds.length, bounds);
let next = mapAllWorkspaceWindows(state, (window) => {
const visibleIndex = visibleIds.indexOf(window.id);
if (visibleIndex >= 0) {
const rect = rects[visibleIndex] ?? window.rect;
return {
...window,
mode: !restoreAll && window.mode === "maximized" ? "maximized" : "floating",
rect,
restoreRect: rect,
minimizedByLayout: false
};
}
if (eligibleSet.has(window.id) && !visibleSet.has(window.id)) {
return { ...window, mode: "minimized", minimizedByLayout: true };
}
return window;
});
const activeWindowId = visibleSet.has(next.activeWindowId)
? next.activeWindowId
: visibleIds[0] ?? next.activeWindowId;
next = {
...next,
activeWindowId,
layout: { ...next.layout, orderedWindowIds }
};
return next;
} }
function selectVisibleWindowIds(ids: string[], mode: WorkspaceLayoutMode) { export function requestArtifactView(
const capacity = mode === "split" ? 2 : mode === "primary" ? 3 : ids.length; state: ArtifactWorkspaceState,
const selected = ids.slice(0, capacity); view: ArtifactRequestedView
if ( ): ArtifactWorkspaceState {
(mode === "split" || mode === "primary") && if (!state.activeArtifact) return state;
ids.includes(MAP_WINDOW_ID) && return { ...state, requestedView: view };
!selected.includes(MAP_WINDOW_ID)
) {
selected[selected.length - 1] = MAP_WINDOW_ID;
}
return selected;
} }
function getLayoutRects(mode: WorkspaceLayoutMode, count: number, bounds: WorkspaceBounds): WorkspaceRect[] { export function resolveWorkbenchSurfaceMode(
if (count === 0) return []; state: ArtifactWorkspaceState,
const width = Math.max(1, bounds.width - LAYOUT_INSET * 2); viewportWidth: number
const height = Math.max(1, bounds.height - LAYOUT_INSET * 2); ): WorkbenchSurfaceMode {
const artifact = state.activeArtifact;
if (mode === "primary") { if (!artifact) return "default";
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }]; if (state.requestedView === "map_only") return "map_only";
const primaryWidth = Math.floor((width - LAYOUT_GAP) * 0.68); if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
const secondaryWidth = width - LAYOUT_GAP - primaryWidth; return viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
const secondaryHeight = count === 2 ? height : Math.floor((height - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: primaryWidth, height },
{ x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP, y: LAYOUT_INSET, width: secondaryWidth, height: secondaryHeight },
...(count === 3
? [{
x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP,
y: LAYOUT_INSET + secondaryHeight + LAYOUT_GAP,
width: secondaryWidth,
height: height - secondaryHeight - LAYOUT_GAP
}]
: [])
];
}
if (mode === "split") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const firstWidth = Math.floor((width - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: firstWidth, height },
{
x: LAYOUT_INSET + firstWidth + LAYOUT_GAP,
y: LAYOUT_INSET,
width: width - firstWidth - LAYOUT_GAP,
height
}
];
}
if (mode === "cascade") {
const windowWidth = Math.max(280, Math.floor(width * 0.78));
const windowHeight = Math.max(240, Math.floor(height * 0.78));
return Array.from({ length: count }, (_, index) => ({
x: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.width - windowWidth - LAYOUT_INSET)),
y: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.height - windowHeight - LAYOUT_INSET)),
width: Math.min(windowWidth, width),
height: Math.min(windowHeight, height)
}));
}
const columns = count === 1 ? 1 : count <= 4 ? 2 : 3;
const rows = Math.ceil(count / columns);
const cellWidth = Math.floor((width - LAYOUT_GAP * (columns - 1)) / columns);
const cellHeight = Math.floor((height - LAYOUT_GAP * (rows - 1)) / rows);
return Array.from({ length: count }, (_, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
return {
x: LAYOUT_INSET + column * (cellWidth + LAYOUT_GAP),
y: LAYOUT_INSET + row * (cellHeight + LAYOUT_GAP),
width: column === columns - 1 ? width - column * (cellWidth + LAYOUT_GAP) : cellWidth,
height: row === rows - 1 ? height - row * (cellHeight + LAYOUT_GAP) : cellHeight
};
});
} }
function normalizeWindowOrder(state: WorkspaceState) { function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
const ids = new Set(allWorkspaceWindows(state).map((window) => window.id)); return artifact.mapRelation === "none" ? "focus" : artifact.preferredView;
return uniqueIds([
...state.layout.orderedWindowIds,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id)
]).filter((id) => ids.has(id));
}
function getWindowById(state: WorkspaceState, windowId: string): MapWorkspaceWindow | AnalysisWorkspaceWindow | undefined {
return windowId === MAP_WINDOW_ID ? state.map : state.analyses.find((window) => window.id === windowId);
}
function updateWindowById(
state: WorkspaceState,
windowId: string,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
if (windowId === MAP_WINDOW_ID) {
return { ...state, map: update(state.map) };
}
return {
...state,
analyses: state.analyses.map((window) => window.id === windowId ? update(window) : window)
};
}
function mapAllWorkspaceWindows(
state: WorkspaceState,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
return {
...state,
map: update(state.map),
analyses: state.analyses.map((window) => update(window))
};
}
function allWorkspaceWindows(state: WorkspaceState) {
return [state.map, ...state.analyses] as Array<MapWorkspaceWindow | AnalysisWorkspaceWindow>;
}
function uniqueIds(ids: string[]) {
return [...new Set(ids)];
}
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));
const cascade = (index % 5) * 28;
return clampWorkspaceRect(
{ x: 24 + cascade, y: 20 + cascade, width, height },
bounds,
"analysis"
);
}
function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect {
return clampWorkspaceRect(
{
x: Math.max(12, bounds.width * 0.36),
y: Math.max(12, bounds.height * 0.18),
width: bounds.width * 0.6,
height: bounds.height * 0.72
},
bounds,
"map"
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
} }
@@ -1,81 +0,0 @@
import type {
WorkspaceBounds,
WorkspaceLayoutMode,
WorkspaceRect
} from "./workspace-model";
const HORIZONTAL_INSET = 28;
const TOP_INSET = 48;
const LAYOUT_RAIL_SPACE = 112;
const PREVIEW_GAP = 20;
const LABEL_SPACE = 28;
export const WORKSPACE_LAYOUT_LABELS: Record<WorkspaceLayoutMode, string> = {
free: "自由排列",
primary: "主次",
split: "双栏",
grid: "网格",
cascade: "层叠"
};
export function getTaskViewPreviewRects(
count: number,
bounds: WorkspaceBounds
): WorkspaceRect[] {
if (count <= 0) return [];
const columns = getTaskViewColumnCount(count);
const rows = Math.ceil(count / columns);
const availableWidth = Math.max(1, bounds.width - HORIZONTAL_INSET * 2);
const availableHeight = Math.max(
1,
bounds.height - TOP_INSET - LAYOUT_RAIL_SPACE
);
const cellWidth = Math.max(
1,
(availableWidth - PREVIEW_GAP * (columns - 1)) / columns
);
const cellHeight = Math.max(
1,
(availableHeight - PREVIEW_GAP * (rows - 1)) / rows
);
const previewHeight = Math.max(1, cellHeight - LABEL_SPACE);
const occupiedColumns = Math.min(count, columns);
const firstRowOffset = (availableWidth
- occupiedColumns * cellWidth
- Math.max(0, occupiedColumns - 1) * PREVIEW_GAP) / 2;
return Array.from({ length: count }, (_, index) => {
const row = Math.floor(index / columns);
const column = index % columns;
const itemsInRow = Math.min(columns, count - row * columns);
const rowOffset = row === 0
? firstRowOffset
: (availableWidth
- itemsInRow * cellWidth
- Math.max(0, itemsInRow - 1) * PREVIEW_GAP) / 2;
return {
x: HORIZONTAL_INSET + rowOffset + column * (cellWidth + PREVIEW_GAP),
y: TOP_INSET + row * (cellHeight + PREVIEW_GAP),
width: cellWidth,
height: previewHeight
};
});
}
export function getTaskViewColumnCount(count: number) {
return count <= 1 ? 1 : count <= 4 ? 2 : 3;
}
export function getTaskViewSourceRect(
mode: "docked" | "floating" | "maximized" | "minimized",
rect: WorkspaceRect,
restoreRect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceRect {
if (mode === "docked" || mode === "maximized") {
return { x: 0, y: 0, width: bounds.width, height: bounds.height };
}
return mode === "minimized" ? restoreRect : rect;
}
@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getTaskViewPreviewRects,
getTaskViewSourceRect
} from "./workspace-task-view-layout";
const bounds = { width: 1440, height: 804 };
describe("workspace task view geometry", () => {
it.each([1, 2, 4, 7])("keeps %i previews inside the task view area", (count) => {
const rects = getTaskViewPreviewRects(count, bounds);
expect(rects).toHaveLength(count);
for (const rect of rects) {
expect(rect.x).toBeGreaterThanOrEqual(28);
expect(rect.y).toBeGreaterThanOrEqual(48);
expect(rect.x + rect.width).toBeLessThanOrEqual(bounds.width - 28);
expect(rect.y + rect.height).toBeLessThanOrEqual(bounds.height - 112);
expect(rect.width).toBeGreaterThan(0);
expect(rect.height).toBeGreaterThan(0);
}
});
it("centers an incomplete last row", () => {
const rects = getTaskViewPreviewRects(7, bounds);
const last = rects.at(-1);
expect(last).toBeDefined();
expect(Math.abs((last!.x + last!.width / 2) - bounds.width / 2)).toBeLessThan(1);
});
it("uses the visible workspace for filled windows and restore geometry for minimized windows", () => {
const rect = { x: 80, y: 60, width: 720, height: 520 };
const restoreRect = { x: 120, y: 90, width: 660, height: 480 };
expect(getTaskViewSourceRect("docked", rect, restoreRect, bounds)).toEqual({
x: 0,
y: 0,
width: bounds.width,
height: bounds.height
});
expect(getTaskViewSourceRect("minimized", rect, restoreRect, bounds)).toBe(restoreRect);
expect(getTaskViewSourceRect("floating", rect, restoreRect, bounds)).toBe(rect);
});
});
@@ -1,197 +0,0 @@
import { Map as MapIcon, PanelsTopLeft, Sparkles } from "lucide-react";
import type { KeyboardEvent } from "react";
import { cn } from "@/shared/ui/cn";
import type {
WorkspaceLayoutMode,
WorkspaceRect,
WorkspaceState
} from "./workspace-model";
import { WORKSPACE_LAYOUT_LABELS } from "./workspace-task-view-layout";
const SNAP_LAYOUT_OPTIONS: Array<{
mode: WorkspaceLayoutMode;
description: string;
minimumWindows: number;
}> = [
{ mode: "primary", description: "一个主窗口与辅助窗口", minimumWindows: 2 },
{ mode: "split", description: "两个窗口等宽并排", minimumWindows: 2 },
{ mode: "grid", description: "全部窗口自适应平铺", minimumWindows: 2 },
{ mode: "cascade", description: "全部窗口依次层叠", minimumWindows: 2 },
{ mode: "free", description: "保留窗口的自由位置", minimumWindows: 1 }
];
type TaskViewWindow = WorkspaceState["map"] | WorkspaceState["analyses"][number];
export function WorkspaceTaskView({
windows,
previewRects,
focusedWindowId,
layoutMode,
onClose,
onFocusWindow,
onSelectWindow,
onWindowKeyDown,
onLayoutChange
}: {
windows: TaskViewWindow[];
previewRects: WorkspaceRect[];
focusedWindowId: string;
layoutMode: WorkspaceLayoutMode;
onClose: () => void;
onFocusWindow: (windowId: string) => void;
onSelectWindow: (windowId: string) => void;
onWindowKeyDown: (event: KeyboardEvent<HTMLButtonElement>, index: number) => void;
onLayoutChange: (mode: WorkspaceLayoutMode) => void;
}) {
function handleDialogKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== "Tab") return;
const controls = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")]
.filter((control) => control.tabIndex >= 0);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
return (
<>
<div
aria-hidden="true"
className="absolute inset-0 z-[2147482000] cursor-default bg-[#e7edf3]/96"
onPointerDown={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label="任务视图"
className="pointer-events-none absolute inset-0 z-[2147482200]"
onKeyDown={handleDialogKeyDown}
>
<div className="absolute left-7 top-4 flex items-center gap-2 text-slate-700">
<PanelsTopLeft size={17} aria-hidden="true" />
<span className="text-sm font-semibold"></span>
<span className="text-xs text-slate-500 tabular-nums">{windows.length} </span>
</div>
{windows.map((window, index) => {
const rect = previewRects[index];
if (!rect) return null;
const focused = focusedWindowId === window.id;
const minimized = window.mode === "minimized";
return (
<button
key={window.id}
type="button"
data-task-view-window-id={window.id}
aria-label={`打开${window.title}并铺满主视图${minimized ? ",当前已最小化" : ""}`}
tabIndex={0}
className={cn(
"group pointer-events-auto absolute rounded-md transition-[background-color,box-shadow,scale] duration-150 active:scale-[0.99] focus-visible:outline-hidden motion-reduce:transition-none",
focused
? "bg-blue-500/8 shadow-[0_0_0_2px_rgba(37,99,235,0.92)]"
: "[@media(hover:hover)]:hover:bg-white/45 [@media(hover:hover)]:hover:shadow-[0_0_0_2px_rgba(148,163,184,0.8)]"
)}
style={{
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height + 28
}}
onClick={() => onSelectWindow(window.id)}
onFocus={() => onFocusWindow(window.id)}
onPointerEnter={() => onFocusWindow(window.id)}
onKeyDown={(event) => onWindowKeyDown(event, index)}
>
<span className="absolute bottom-0 left-1/2 flex max-w-[calc(100%-0.75rem)] -translate-x-1/2 items-center gap-1.5 rounded-md bg-slate-950/88 px-2.5 py-1 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(15,23,42,0.2)]">
{window.kind === "map" ? <MapIcon size={13} aria-hidden="true" /> : <Sparkles size={13} aria-hidden="true" />}
<span className="truncate">{window.title}</span>
{minimized ? <span className="shrink-0 text-[10px] text-slate-300"></span> : null}
</span>
</button>
);
})}
<div className="pointer-events-auto absolute bottom-4 left-1/2 flex -translate-x-1/2 items-end gap-1.5 rounded-xl bg-white px-3 py-2.5 shadow-[0_12px_32px_rgba(15,23,42,0.18)]">
<span className="mr-1 self-center text-xs font-semibold text-slate-500"></span>
{SNAP_LAYOUT_OPTIONS.map((option) => {
const disabled = windows.length < option.minimumWindows;
const active = layoutMode === option.mode;
return (
<button
key={option.mode}
type="button"
aria-label={`${WORKSPACE_LAYOUT_LABELS[option.mode]}${option.description}`}
aria-pressed={active}
title={option.description}
disabled={disabled}
className={cn(
"group/layout flex h-16 w-[76px] flex-col items-center justify-center gap-1 rounded-lg text-[11px] font-semibold transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-40",
active ? "bg-blue-50 text-blue-700" : "text-slate-600 hover:bg-slate-100"
)}
onClick={() => onLayoutChange(option.mode)}
>
<SnapLayoutDiagram mode={option.mode} active={active} />
{WORKSPACE_LAYOUT_LABELS[option.mode]}
</button>
);
})}
</div>
</div>
</>
);
}
function SnapLayoutDiagram({ mode, active }: { mode: WorkspaceLayoutMode; active: boolean }) {
const cellClass = active ? "bg-blue-600" : "bg-slate-400 group-hover/layout:bg-slate-500";
if (mode === "primary") {
return (
<span className="grid h-7 w-11 grid-cols-[2fr_1fr] gap-0.5" aria-hidden="true">
<span className={cn("rounded-[2px]", cellClass)} />
<span className="grid grid-rows-2 gap-0.5">
<span className={cn("rounded-[2px]", cellClass)} />
<span className={cn("rounded-[2px]", cellClass)} />
</span>
</span>
);
}
if (mode === "split") {
return (
<span className="grid h-7 w-11 grid-cols-2 gap-0.5" aria-hidden="true">
<span className={cn("rounded-[2px]", cellClass)} />
<span className={cn("rounded-[2px]", cellClass)} />
</span>
);
}
if (mode === "grid") {
return (
<span className="grid h-7 w-11 grid-cols-2 grid-rows-2 gap-0.5" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => <span key={index} className={cn("rounded-[2px]", cellClass)} />)}
</span>
);
}
if (mode === "cascade") {
return (
<span className="relative h-7 w-11" aria-hidden="true">
<span className={cn("absolute left-0 top-0 h-5 w-8 rounded-[2px] opacity-50", cellClass)} />
<span className={cn("absolute bottom-0 right-0 h-5 w-8 rounded-[2px]", cellClass)} />
</span>
);
}
return (
<span className="relative h-7 w-11" aria-hidden="true">
<span className={cn("absolute left-0 top-1 h-4 w-6 rounded-[2px] opacity-60", cellClass)} />
<span className={cn("absolute bottom-0 right-0 h-4 w-7 rounded-[2px]", cellClass)} />
</span>
);
}
@@ -1,362 +0,0 @@
import {
Grip,
Maximize2,
Minimize2,
Minus,
X
} from "lucide-react";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactNode
} from "react";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
export type WorkspaceTaskViewPreview = {
sourceRect: WorkspaceRect;
targetRect: WorkspaceRect;
index: number;
};
type WorkspaceWindowProps = {
id: string;
title: string;
mode: WorkspaceWindowMode;
rect: WorkspaceRect;
zIndex: number;
active: boolean;
kind: "map" | "analysis";
swapTarget?: boolean;
taskViewPreview?: WorkspaceTaskViewPreview;
children: ReactNode;
onFocus: () => void;
onRectChange: (rect: WorkspaceRect) => void;
onMinimize: () => void;
onMaximize: () => void;
onRestore: () => void;
onClose?: () => void;
onMoveStart?: (rect: WorkspaceRect) => void;
onMovePreview?: (rect: WorkspaceRect, pointer: { x: number; y: number }) => void;
onMoveEnd?: (rect: WorkspaceRect, pointer: { x: number; y: number }) => void;
onMoveCancel?: () => void;
};
export function WorkspaceWindow({
id,
title,
mode,
rect,
zIndex,
active,
kind,
swapTarget = false,
taskViewPreview,
children,
onFocus,
onRectChange,
onMinimize,
onMaximize,
onRestore,
onClose,
onMoveStart,
onMovePreview,
onMoveEnd,
onMoveCancel
}: WorkspaceWindowProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const cleanupPointerActionRef = useRef<(() => void) | null>(null);
const [dragRect, setDragRect] = useState<WorkspaceRect | null>(null);
const rectRef = useRef(rect);
rectRef.current = rect;
const floating = mode === "floating";
const displayedRect = dragRect ?? rect;
const displayedZIndex = taskViewPreview
? 2_147_482_100 + taskViewPreview.index
: swapTarget
? 2_147_483_000
: zIndex;
const style: CSSProperties = taskViewPreview
? getTaskViewStyle(taskViewPreview, displayedZIndex)
: dragRect
? { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex }
: mode === "docked"
? { inset: 0, zIndex: displayedZIndex }
: mode === "maximized"
? { inset: 0, zIndex: displayedZIndex }
: { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
const beginPointerAction = (
event: ReactPointerEvent,
action: "move" | ResizeDirection
) => {
const moving = action === "move";
if (
(!moving && !floating)
|| (moving && mode !== "floating" && mode !== "docked" && mode !== "maximized")
|| event.button !== 0
) return;
if (
event.target instanceof Element
&& (
(!moving && event.target.closest("button"))
|| (moving && event.target.closest("[data-window-operation]"))
)
) return;
event.preventDefault();
event.stopPropagation();
onFocus();
const startX = event.clientX;
const startY = event.clientY;
const startRect = rectRef.current;
const sectionBounds = sectionRef.current?.getBoundingClientRect();
const workspaceBounds = sectionRef.current?.parentElement?.getBoundingClientRect();
const pointerOffsetX = sectionBounds ? event.clientX - sectionBounds.left : 24;
const pointerOffsetY = sectionBounds ? event.clientY - sectionBounds.top : 22;
let moved = false;
if (moving) onMoveStart?.(startRect);
const handleMove = (pointerEvent: PointerEvent) => {
const deltaX = pointerEvent.clientX - startX;
const deltaY = pointerEvent.clientY - startY;
if (!moved && Math.hypot(deltaX, deltaY) < 3) return;
moved = true;
if (moving) {
const nextRect = mode !== "floating" && workspaceBounds
? {
...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
y: pointerEvent.clientY - workspaceBounds.top - Math.min(pointerOffsetY, 40)
}
: resizeRect(startRect, "move", deltaX, deltaY);
setDragRect(nextRect);
onMovePreview?.(nextRect, { x: pointerEvent.clientX, y: pointerEvent.clientY });
return;
}
onRectChange(resizeRect(startRect, action, deltaX, deltaY));
};
const cleanup = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleCancel);
window.removeEventListener("keydown", handleKeyDown);
cleanupPointerActionRef.current = null;
};
const handleUp = (pointerEvent: PointerEvent) => {
cleanup();
if (!moving || !moved) {
if (moving) onMoveCancel?.();
setDragRect(null);
return;
}
const finalRect = mode !== "floating" && workspaceBounds
? {
...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
y: pointerEvent.clientY - workspaceBounds.top - Math.min(pointerOffsetY, 40)
}
: resizeRect(startRect, "move", pointerEvent.clientX - startX, pointerEvent.clientY - startY);
setDragRect(null);
onMoveEnd?.(finalRect, { x: pointerEvent.clientX, y: pointerEvent.clientY });
};
const handleCancel = () => {
cleanup();
setDragRect(null);
if (moving) onMoveCancel?.();
};
const handleKeyDown = (keyboardEvent: KeyboardEvent) => {
if (keyboardEvent.key !== "Escape") return;
keyboardEvent.preventDefault();
handleCancel();
};
cleanupPointerActionRef.current?.();
cleanupPointerActionRef.current = cleanup;
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp, { once: true });
window.addEventListener("pointercancel", handleCancel, { once: true });
if (moving) window.addEventListener("keydown", handleKeyDown);
};
const handleOperationKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!floating || !event.key.startsWith("Arrow")) return;
event.preventDefault();
const step = event.altKey ? 2 : 12;
const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
onRectChange(
event.shiftKey
? { ...rect, width: rect.width + deltaX, height: rect.height + deltaY }
: { ...rect, x: rect.x + deltaX, y: rect.y + deltaY }
);
};
const handleWindowPointerDownCapture = (event: ReactPointerEvent<HTMLElement>) => {
onFocus();
if (event.altKey) beginPointerAction(event, "move");
};
return (
<section
ref={sectionRef}
id={id}
data-workspace-window-id={id}
aria-label={title}
aria-hidden={taskViewPreview ? true : undefined}
inert={taskViewPreview ? true : undefined}
className={cn(
"group/workspace-window absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[transform,box-shadow,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
taskViewPreview ? "pointer-events-none flex" : "pointer-events-auto",
taskViewPreview
? kind === "analysis" ? "hidden lg:flex" : "flex"
: mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
(taskViewPreview || mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
)}
style={style}
onPointerDownCapture={handleWindowPointerDownCapture}
>
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
{active && !taskViewPreview ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
data-window-operation
aria-label={`打开${title}窗口操作`}
title={`${title}窗口操作`}
className="absolute left-2 top-2 z-40 hidden h-10 w-10 place-items-center rounded-md bg-slate-950/84 text-white opacity-0 shadow-[0_6px_18px_rgba(15,23,42,0.24)] transition-[opacity,background-color,scale] duration-150 [@media(hover:hover)]:group-hover/workspace-window:grid [@media(hover:hover)]:group-hover/workspace-window:opacity-100 hover:bg-slate-950 active:scale-[0.96] focus:grid focus:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 data-[state=open]:grid data-[state=open]:opacity-100 lg:grid"
onKeyDown={handleOperationKeyDown}
onPointerDown={(event) => event.stopPropagation()}
>
<Grip size={18} aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-window-operation
side="bottom"
align="start"
sideOffset={6}
className="w-52 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.2)] motion-reduce:animate-none"
>
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMinimize}>
<Minus size={16} aria-hidden="true" />
</DropdownMenuItem>
{mode === "maximized" || mode === "docked" ? (
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onRestore}>
<Minimize2 size={15} aria-hidden="true" />
</DropdownMenuItem>
) : (
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMaximize}>
<Maximize2 size={15} aria-hidden="true" />
</DropdownMenuItem>
)}
{onClose ? (
<>
<DropdownMenuSeparator className="my-1 bg-slate-200" />
<DropdownMenuItem
className="min-h-10 rounded-md text-xs font-semibold text-rose-700 focus:bg-rose-50 focus:text-rose-800"
onSelect={onClose}
>
<X size={16} aria-hidden="true" />
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{swapTarget && !taskViewPreview ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-2 z-30 grid place-items-center rounded-md bg-blue-50/90 opacity-100 ring-2 ring-inset ring-blue-600 transition-opacity duration-150 motion-reduce:transition-none"
>
<span className="rounded-md bg-blue-700 px-3 py-2 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(29,78,216,0.28)]">
</span>
</div>
) : null}
{floating && !taskViewPreview ? RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
aria-hidden="true"
className={cn("hidden lg:block", resizeHandleClass(direction))}
onPointerDown={(event) => beginPointerAction(event, direction)}
/>
)) : null}
</section>
);
}
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
function getTaskViewStyle(
preview: WorkspaceTaskViewPreview,
zIndex: number
): CSSProperties {
const { sourceRect, targetRect } = preview;
const scale = Math.min(
targetRect.width / sourceRect.width,
targetRect.height / sourceRect.height
);
const renderedWidth = sourceRect.width * scale;
const renderedHeight = sourceRect.height * scale;
const targetLeft = targetRect.x + (targetRect.width - renderedWidth) / 2;
const targetTop = targetRect.y + (targetRect.height - renderedHeight) / 2;
return {
left: sourceRect.x,
top: sourceRect.y,
width: sourceRect.width,
height: sourceRect.height,
transform: `translate(${targetLeft - sourceRect.x}px, ${targetTop - sourceRect.y}px) scale(${scale})`,
transformOrigin: "top left",
zIndex
};
}
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
const west = direction.includes("w");
const east = direction.includes("e");
const north = direction.includes("n");
const south = direction.includes("s");
return {
x: west ? rect.x + deltaX : rect.x,
y: north ? rect.y + deltaY : rect.y,
width: rect.width + (east ? deltaX : west ? -deltaX : 0),
height: rect.height + (south ? deltaY : north ? -deltaY : 0)
};
}
function resizeHandleClass(direction: ResizeDirection) {
const shared = "absolute z-20 block touch-none";
const classes: Record<ResizeDirection, string> = {
n: "left-2 right-2 top-0 h-2 cursor-n-resize",
ne: "right-0 top-0 h-3 w-3 cursor-ne-resize",
e: "bottom-2 right-0 top-2 w-2 cursor-e-resize",
se: "bottom-0 right-0 h-3 w-3 cursor-se-resize",
s: "bottom-0 left-2 right-2 h-2 cursor-s-resize",
sw: "bottom-0 left-0 h-3 w-3 cursor-sw-resize",
w: "bottom-2 left-0 top-2 w-2 cursor-w-resize",
nw: "left-0 top-0 h-3 w-3 cursor-nw-resize"
};
return `${shared} ${classes[direction]}`;
}
+5 -39
View File
@@ -899,49 +899,15 @@ button.agent-history-session-trigger:not(:disabled):active {
background: var(--surface-reading); background: var(--surface-reading);
} }
/* Desktop Main Frame uses solid, work-focused panes instead of map-overlay acrylic. */ .workbench-agent-dock .agent-panel-shell {
.workbench-main-frame .agent-panel-shell, border: 0;
.workbench-main-frame .scheduled-feed-panel-shell, border-radius: 0;
.workbench-main-frame .surface-dock, background: #f7f9fb;
.workbench-main-frame .surface-control, box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
.workbench-main-frame .surface-reading {
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
} }
.workbench-main-frame .agent-panel-shell {
border: 0;
border-radius: 0;
background: #f7f9fb;
box-shadow: none;
}
.workbench-main-frame .scheduled-feed-panel-shell {
width: 100%;
max-width: none;
height: 100%;
border: 0;
border-radius: 0;
background: #f7f9fb;
box-shadow: none;
}
.workbench-main-frame .workspace-window-titlebar {
-webkit-user-select: none;
user-select: none;
}
@media (forced-colors: active) {
.workbench-main-frame [aria-label="工作台导航"],
.workbench-main-frame [aria-label="工作区窗口任务栏"],
.workbench-main-frame .workspace-window-titlebar {
border-color: CanvasText;
background: Canvas;
color: CanvasText;
box-shadow: none;
}
}
.agent-ready-orbit, .agent-ready-orbit,
.agent-ready-core { .agent-ready-core {
transform-box: fill-box; transform-box: fill-box;
+3 -2
View File
@@ -184,11 +184,12 @@ test("Main Frame navigation respects forced color mode", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const navigation = page.getByRole("complementary", { name: "工作台导航" }); const navigation = page.getByRole("complementary", { name: "工作台导航" });
const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" }); const workspace = page.locator("#main-workspace");
await expect(navigation).toBeVisible(); await expect(navigation).toBeVisible();
await expect(navigation).toHaveCSS("backdrop-filter", "none"); await expect(navigation).toHaveCSS("backdrop-filter", "none");
await expect(navigation).toHaveCSS("box-shadow", "none"); await expect(navigation).toHaveCSS("box-shadow", "none");
await expect(taskbar).toHaveCSS("box-shadow", "none"); await expect(workspace).toBeVisible();
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
}); });
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) { async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
+24 -31
View File
@@ -1,52 +1,45 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
test("unified workbench navigation collapses and restores the main workspace", async ({ page }) => { test("the docked Agent collapses and restores the main workspace", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const navigation = page.getByRole("complementary", { name: "工作台导航" }); const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace"); const workspace = page.locator("#main-workspace");
await expect(navigation).toHaveCSS("width", "368px"); const expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368); expect(expandedWidth).toBeGreaterThan(400);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
await page.getByRole("button", { name: "折叠工作台导航" }).click(); await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(navigation).toHaveCSS("width", "48px"); await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48); await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(72);
await page.getByRole("button", { name: "展开工作台导航" }).click(); await page.getByLabel(/展开 Agent 助手面板/).click();
await expect(navigation).toHaveCSS("width", "368px"); await expect(agentPanel).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(368); await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
}); });
test("unified navigation switches between Agent and condition panes", async ({ page }) => { test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const mainFrame = page.getByTestId("workbench-main-frame");
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应"); const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
await prompt.fill("保留尚未发送的诊断问题"); await prompt.fill("保留尚未发送的诊断问题");
await mainFrame.getByRole("button", { name: "工况任务", exact: true }).click(); const conditionToggle = page
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeHidden();
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
await mainFrame.getByRole("button", { name: "Agent", exact: true }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
const topBarConditionToggle = page
.locator("header.acrylic-navigation") .locator("header.acrylic-navigation")
.getByRole("button", { name: "工况任务", exact: true }); .getByRole("button", { name: "工况任务", exact: true });
await page.getByRole("button", { name: "折叠工作台导航" }).click(); await conditionToggle.click();
await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "48px"); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await topBarConditionToggle.click(); await expect(conditionPanel).toBeVisible();
await expect(page.getByRole("complementary", { name: "工作台导航" })).toHaveCSS("width", "368px");
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
await topBarConditionToggle.click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
await conditionToggle.click();
await expect(conditionPanel).toBeHidden();
}); });
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => { test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => { test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
@@ -111,6 +112,7 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => { test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
@@ -168,6 +170,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
page page
}) => { }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
@@ -199,6 +202,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => { test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
+1
View File
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => { test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const reference = page const reference = page
.getByRole("region", { name: "工况任务", exact: true }) .getByRole("region", { name: "工况任务", exact: true })
@@ -21,6 +21,7 @@ test("opening Agent and condition panels does not move the map camera", async ({
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel).toHaveCSS("width", "880px"); await expect(conditionPanel).toHaveCSS("width", "880px");
-5
View File
@@ -192,7 +192,6 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
async function expectDesktopMainFrameGeometry(page: Page) { async function expectDesktopMainFrameGeometry(page: Page) {
const navigation = page.getByRole("complementary", { name: "工作台导航" }); const navigation = page.getByRole("complementary", { name: "工作台导航" });
const workspace = page.locator("#main-workspace"); const workspace = page.locator("#main-workspace");
const taskbar = page.getByRole("toolbar", { name: "工作区窗口任务栏" });
const mapWindow = page.locator("#workspace-map"); const mapWindow = page.locator("#workspace-map");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
await expect(navigation).toHaveCSS("width", "368px"); await expect(navigation).toHaveCSS("width", "368px");
@@ -201,20 +200,16 @@ async function expectDesktopMainFrameGeometry(page: Page) {
const navigationBox = await navigation.boundingBox(); const navigationBox = await navigation.boundingBox();
const workspaceBox = await workspace.boundingBox(); const workspaceBox = await workspace.boundingBox();
const taskbarBox = await taskbar.boundingBox();
const mapBox = await mapWindow.boundingBox(); const mapBox = await mapWindow.boundingBox();
const agentBox = await agentPanel.boundingBox(); const agentBox = await agentPanel.boundingBox();
expect(navigationBox).not.toBeNull(); expect(navigationBox).not.toBeNull();
expect(workspaceBox).not.toBeNull(); expect(workspaceBox).not.toBeNull();
expect(taskbarBox).not.toBeNull();
expect(mapBox).not.toBeNull(); expect(mapBox).not.toBeNull();
expect(agentBox).not.toBeNull(); expect(agentBox).not.toBeNull();
expect(navigationBox!.x).toBe(0); expect(navigationBox!.x).toBe(0);
expect(navigationBox!.y).toBe(56); expect(navigationBox!.y).toBe(56);
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width); expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
expect(workspaceBox!.y).toBe(navigationBox!.y); expect(workspaceBox!.y).toBe(navigationBox!.y);
expect(taskbarBox!.x).toBe(workspaceBox!.x);
expect(taskbarBox!.y).toBe(workspaceBox!.y + workspaceBox!.height);
expect(mapBox).toEqual(workspaceBox); expect(mapBox).toEqual(workspaceBox);
expect(agentBox!.x).toBeGreaterThanOrEqual(48); expect(agentBox!.x).toBeGreaterThanOrEqual(48);
expect(agentBox!.x).toBeLessThanOrEqual(49); expect(agentBox!.x).toBeLessThanOrEqual(49);
+109 -227
View File
@@ -1,250 +1,132 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
test("temporary Agent analysis windows float above the persistent map workspace", async ({ page }) => { test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page); await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const frame = page.getByTestId("workbench-main-frame"); const workspace = page.locator("#main-workspace");
const mapWindow = page.locator("#workspace-map"); const map = page.locator("#workspace-map");
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
await expect(frame).toBeVisible(); await expect(workspace).toHaveAttribute("data-layout-mode", "default");
await expect(mapWindow).toBeVisible(); await expect(map).toBeVisible();
await expect(testButton).toBeVisible();
await expect(page.getByLabel("工作台导航")).toHaveCount(0);
await expect(page.getByLabel("Agent 命令面板")).toBeVisible(); await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await expect(mapWindow.getByRole("navigation", { name: "地图工具" })).toBeVisible(); await expect(map.getByRole("navigation", { name: "地图工具" })).toBeVisible();
await expect(page.locator(".workspace-window-titlebar")).toHaveCount(0); await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "任务视图" })).toHaveCount(0);
await testButton.click();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeVisible();
await expect(mapWindow).toBeVisible();
await testButton.click();
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
await expect(page.getByText("2 / 6 个临时分析窗")).toBeVisible();
await openWindowOperationMenu(page, "供水服务分区平衡分析");
await page.getByRole("menuitem", { name: "最小化" }).click();
await expect(page.getByRole("button", { name: "恢复供水服务分区平衡分析" })).toBeVisible();
await page.getByRole("button", { name: "恢复供水服务分区平衡分析" }).click();
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeVisible();
await openWindowOperationMenu(page, "供水服务分区平衡分析");
await page.getByRole("menuitem", { name: "关闭并销毁" }).click();
await openWindowOperationMenu(page, "北辰分区压力异常诊断");
await page.getByRole("menuitem", { name: "关闭并销毁" }).click();
await expect(mapWindow).toBeVisible();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toHaveCount(0);
await expect(page.getByText("0 / 6 个临时分析窗")).toBeVisible();
}); });
test("the map document floats and minimizes to its taskbar entry", async ({ page }) => { test("a generated Artifact stays in peek until explicitly opened", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page); await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
const peek = page.getByRole("complementary", { name: "Agent 成果预览" });
await expect(peek).toBeVisible();
await expect(peek).toContainText("北辰分区压力异常诊断");
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toHaveCount(0);
await peek.getByRole("button", { name: "打开成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
});
test("Agent expands over an active Artifact without resizing the main workspace", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openFirstArtifact(page);
const workspace = page.locator("#main-workspace"); const workspace = page.locator("#main-workspace");
const mapWindow = page.locator("#workspace-map"); const before = await workspace.boundingBox();
const dockedBox = await workspace.boundingBox();
expect(dockedBox).not.toBeNull();
await openWindowOperationMenu(page, "供水管网地图");
await page.getByRole("menuitem", { name: "恢复为浮动窗口" }).click();
const floatingBox = await mapWindow.boundingBox();
expect(floatingBox).not.toBeNull();
expect(floatingBox!.width).toBeLessThan(dockedBox!.width);
expect(floatingBox!.height).toBeLessThan(dockedBox!.height);
await expect(mapWindow).toHaveCSS("border-radius", "0px");
const floatingClassName = await mapWindow.getAttribute("class");
expect(floatingClassName).not.toContain("ring-1");
expect(await mapWindow.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none");
await expect(page.getByRole("button", { name: "打开供水管网地图窗口操作" })).toBeAttached();
await openWindowOperationMenu(page, "供水管网地图");
await page.getByRole("menuitem", { name: "最小化" }).click();
await expect(mapWindow).toBeHidden();
const restoreTask = page.getByRole("button", { name: "恢复供水管网地图" });
await expect(restoreTask).toBeVisible();
await restoreTask.click();
await expect(mapWindow).toBeVisible();
await expect(page.getByRole("button", { name: "打开供水管网地图窗口操作" })).toBeAttached();
});
test("the test control cycles through three trusted document profiles", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
await testButton.click();
await testButton.click();
await testButton.click();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断", exact: true })).toBeAttached();
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析", exact: true })).toBeAttached();
await expect(page.getByRole("heading", { name: "低压事件调度处置流程", exact: true })).toBeVisible();
await expect(page.getByText("3 / 6 个临时分析窗")).toBeVisible();
});
test("task view shows every window and fills the workspace with the selected one", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
await testButton.click();
await testButton.click();
await openWindowOperationMenu(page, "北辰分区压力异常诊断");
await page.getByRole("menuitem", { name: "最小化" }).click();
await page.getByRole("button", { name: "任务视图" }).click();
await expect(page.getByRole("dialog", { name: "任务视图" })).toBeVisible();
await expect(page.getByRole("button", { name: /打开供水管网地图并铺满主视图/ })).toBeVisible();
await expect(page.getByRole("button", { name: /打开北辰分区压力异常诊断并铺满主视图,当前已最小化/ })).toBeVisible();
await expect(page.getByRole("button", { name: /打开供水服务分区平衡分析并铺满主视图/ })).toBeVisible();
await page.getByRole("button", { name: /打开供水服务分区平衡分析并铺满主视图/ }).click();
await expect(page.getByRole("dialog", { name: "任务视图" })).toHaveCount(0);
const workspaceBox = await page.locator("#main-workspace").boundingBox();
const selectedBox = await page.locator("section[aria-label='供水服务分区平衡分析']").boundingBox();
expect(workspaceBox).not.toBeNull();
expect(selectedBox).not.toBeNull();
expect(Math.abs(selectedBox!.width - workspaceBox!.width)).toBeLessThanOrEqual(2);
expect(Math.abs(selectedBox!.height - workspaceBox!.height)).toBeLessThanOrEqual(2);
await page.getByRole("button", { name: "任务视图" }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "任务视图" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "任务视图" })).toBeFocused();
});
test("temporary documents survive a mobile breakpoint round trip", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
const analysisHeading = page.locator("h2", { hasText: "北辰分区压力异常诊断" });
await expect(analysisHeading).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(analysisHeading).toBeAttached();
await expect(analysisHeading).toBeHidden();
await page.setViewportSize({ width: 1440, height: 900 });
await expect(analysisHeading).toBeVisible();
await expect(page.locator("#workspace-map")).toBeVisible();
});
test("layout presets arrange transient windows and keep the map visible", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
await testButton.click();
await testButton.click();
await testButton.click();
await applyTaskViewLayout(page, "主次");
const workspace = page.locator("#main-workspace");
await expect(workspace).toHaveAttribute("data-layout-mode", "primary");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toBeVisible();
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']")).toBeHidden();
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toHaveCSS("border-radius", "0px");
const workspaceBox = await workspace.boundingBox();
const primaryBox = await page.locator("section[aria-label='低压事件调度处置流程']").boundingBox();
expect(workspaceBox).not.toBeNull();
expect(primaryBox).not.toBeNull();
expect(primaryBox!.width).toBeGreaterThan(workspaceBox!.width * 0.6);
await testButton.click();
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']").last()).toBeVisible();
const newPrimaryBox = await page.locator("section[aria-label='北辰分区压力异常诊断']").last().boundingBox();
expect(newPrimaryBox).not.toBeNull();
expect(newPrimaryBox!.width).toBeGreaterThan(workspaceBox!.width * 0.6);
});
test("dragging a small window onto the primary window swaps their complete slots", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
await applyTaskViewLayout(page, "主次");
const mapWindow = page.locator("#workspace-map");
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
const mapBefore = await mapWindow.boundingBox();
const analysisBefore = await analysisWindow.boundingBox();
expect(mapBefore).not.toBeNull();
expect(analysisBefore).not.toBeNull();
expect(mapBefore!.width).toBeLessThan(analysisBefore!.width);
await page.mouse.move(mapBefore!.x + 72, mapBefore!.y + 22);
await page.keyboard.down("Alt");
await page.mouse.down();
await page.mouse.move(
analysisBefore!.x + analysisBefore!.width / 2,
analysisBefore!.y + analysisBefore!.height / 2,
{ steps: 10 }
);
await expect(page.getByText("释放以交换")).toBeVisible();
await page.mouse.up();
await page.keyboard.up("Alt");
const mapAfter = await mapWindow.boundingBox();
const analysisAfter = await analysisWindow.boundingBox();
expect(mapAfter).not.toBeNull();
expect(analysisAfter).not.toBeNull();
expect(mapAfter!.width).toBeGreaterThan(analysisAfter!.width);
expect(Math.abs(mapAfter!.x - analysisBefore!.x)).toBeLessThanOrEqual(2);
expect(Math.abs(analysisAfter!.x - mapBefore!.x)).toBeLessThanOrEqual(2);
await expect(page.getByText("供水管网地图与北辰分区压力异常诊断已交换位置")).toBeAttached();
});
test("dropping on blank workspace exits auto layout and Escape cancels a drag", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
await applyTaskViewLayout(page, "双栏");
const workspace = page.locator("#main-workspace");
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
const before = await analysisWindow.boundingBox();
const workspaceBox = await workspace.boundingBox();
expect(before).not.toBeNull(); expect(before).not.toBeNull();
expect(workspaceBox).not.toBeNull();
await page.mouse.move(before!.x + 80, before!.y + 22); await page.getByLabel(/展开 Agent 助手面板/).click();
await page.keyboard.down("Alt"); await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await page.mouse.down(); const after = await workspace.boundingBox();
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
await page.keyboard.press("Escape");
await page.keyboard.up("Alt");
await expect(workspace).toHaveAttribute("data-layout-mode", "split");
const afterCancel = await analysisWindow.boundingBox();
expect(Math.abs(afterCancel!.x - before!.x)).toBeLessThanOrEqual(2);
await page.mouse.move(before!.x + 80, before!.y + 22); expect(after).not.toBeNull();
await page.keyboard.down("Alt"); expect(after!.x).toBe(before!.x);
await page.mouse.down(); expect(after!.width).toBe(before!.width);
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
await page.mouse.up();
await page.keyboard.up("Alt");
await expect(workspace).toHaveAttribute("data-layout-mode", "free");
await expect(page.getByText("北辰分区压力异常诊断已转为自由排列")).toBeAttached();
}); });
async function openWindowOperationMenu(page: Page, title: string) { test("spatial Artifacts switch between split, focus and peek", async ({ page }) => {
const window = page.locator(`section[aria-label='${title}']`); await page.setViewportSize({ width: 1600, height: 900 });
await page.getByRole("button", { name: `切换到${title}` }).click(); await prepareMainFrame(page);
await window.hover(); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: `打开${title}窗口操作` }).click(); await openFirstArtifact(page);
}
async function applyTaskViewLayout(page: Page, layoutName: string) { await page.getByRole("button", { name: "专注分析" }).click();
await page.getByRole("button", { name: "任务视图" }).click(); await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await page.getByRole("button", { name: new RegExp(`^${layoutName}`) }).click(); await expect(page.locator("#workspace-map")).toBeHidden();
await page.getByRole("button", { name: "显示地图" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await page.getByRole("button", { name: "将成果收起为预览" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await page.getByRole("button", { name: "销毁预览成果" }).click();
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toHaveCount(0);
await expect(page.locator("#workspace-map")).toBeVisible();
});
test("non-spatial Artifacts open directly in focus mode", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "销毁预览成果" }).click();
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "打开成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析" })).toBeVisible();
await expect(page.locator("#workspace-map")).toBeHidden();
await expect(page.getByRole("button", { name: "显示地图" })).toHaveCount(0);
});
test("narrow workspaces switch between Artifact and map as pages", async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 820 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openFirstArtifact(page);
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await page.getByRole("button", { name: "显示地图" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_only");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeHidden();
await page.getByRole("button", { name: "返回分析成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.locator("#artifact-workspace")).toBeVisible();
});
test("the condition feed remains floating and starts closed", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
});
async function openFirstArtifact(page: Page) {
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "打开成果" }).click();
} }
async function prepareMainFrame(page: Page) { async function prepareMainFrame(page: Page) {