feat: add project-aware agent workspace
This commit is contained in:
@@ -64,12 +64,12 @@ export function AnalysisArtifactWorkspace({
|
||||
return (
|
||||
<article
|
||||
aria-label={`${artifact.title}分析成果`}
|
||||
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
|
||||
className="surface-dock relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border 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">
|
||||
<div className="surface-reading 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>
|
||||
@@ -130,7 +130,7 @@ function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
const demoArtifact = artifact.origin === "demo";
|
||||
const OriginIcon = backendArtifact ? Database : Sparkles;
|
||||
return (
|
||||
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
|
||||
<header className="surface-dock relative z-10 border-b px-4 py-4 xl:px-5">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||
@@ -437,7 +437,7 @@ function ArtifactActionBar({
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||||
<footer className="surface-dock relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||||
<span />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useGroupRef, type Layout } from "react-resizable-panels";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/shared/ui/resizable";
|
||||
|
||||
/** Keep both hosts mounted while switching between split and single-pane views. */
|
||||
export function WorkbenchContentSplit({
|
||||
map,
|
||||
document,
|
||||
split,
|
||||
showMap,
|
||||
minPaneWidth,
|
||||
onMapSizeChange
|
||||
}: {
|
||||
map: ReactNode;
|
||||
document: ReactNode;
|
||||
split: boolean;
|
||||
showMap: boolean;
|
||||
minPaneWidth: number;
|
||||
onMapSizeChange: (percentage: number) => void;
|
||||
}) {
|
||||
const group = useGroupRef();
|
||||
const splitLayout = useRef<Layout>({ map: 50, document: 50 });
|
||||
useLayoutEffect(() => {
|
||||
group.current?.setLayout(
|
||||
split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 }
|
||||
);
|
||||
}, [group, split, showMap, minPaneWidth]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id="workspace-content-columns"
|
||||
groupRef={group}
|
||||
orientation="horizontal"
|
||||
disabled={!split}
|
||||
resizeTargetMinimumSize={{ fine: 12, coarse: 24 }}
|
||||
onLayoutChange={(layout) => onMapSizeChange(layout.map ?? 50)}
|
||||
onLayoutChanged={(layout, { isUserInteraction }) => {
|
||||
if (split && isUserInteraction) splitLayout.current = layout;
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="map"
|
||||
defaultSize={showMap ? (split ? "50%" : "100%") : "0%"}
|
||||
minSize={split ? minPaneWidth : showMap ? "100%" : 0}
|
||||
maxSize={!showMap ? 0 : "100%"}
|
||||
className="relative h-full min-h-0 min-w-0"
|
||||
>
|
||||
{map}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle
|
||||
aria-label="调整地图与报告宽度"
|
||||
disabled={!split}
|
||||
className={`z-40 h-[calc(100%-3.5rem)] self-end ${split ? "" : "hidden"}`}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="document"
|
||||
defaultSize={showMap ? (split ? "50%" : "0%") : "100%"}
|
||||
minSize={split ? minPaneWidth : showMap ? 0 : "100%"}
|
||||
maxSize={!split && showMap ? 0 : "100%"}
|
||||
className="relative h-full min-h-0 min-w-0"
|
||||
>
|
||||
{document}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
|
||||
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
|
||||
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ReadingWorkspaceContent } from "@/shared/ui/reading-workspace";
|
||||
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import { WorkbenchContentSplit } from "./workbench-content-split";
|
||||
import { ARTIFACT_PANE_MIN_WIDTH } from "./workspace-model";
|
||||
import {
|
||||
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
@@ -25,6 +30,7 @@ type WorkbenchMainFrameProps = {
|
||||
pendingArtifact: AnalysisArtifact | null;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
readingSplitAvailable?: boolean;
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
@@ -40,20 +46,28 @@ export function WorkbenchMainFrame({
|
||||
timelineBottom,
|
||||
activeArtifact,
|
||||
pendingArtifact,
|
||||
surfaceMode,
|
||||
surfaceMode: artifactSurfaceMode,
|
||||
mapSplitAvailable,
|
||||
readingSplitAvailable = false,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
onCollapseArtifact,
|
||||
onDestroyArtifact
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const reading = useReadingWorkspace();
|
||||
const surfaceMode = reading?.active
|
||||
? reading.view === "map_split" && !readingSplitAvailable
|
||||
? "focus"
|
||||
: reading.view
|
||||
: artifactSurfaceMode;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(activeArtifact);
|
||||
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active);
|
||||
const mapOnly = surfaceMode === "map_only";
|
||||
const mapSplit = surfaceMode === "map_split";
|
||||
const [mapPercentage, setMapPercentage] = useState(50);
|
||||
const viewControlLabel = mapOnly
|
||||
? "返回分析成果"
|
||||
: mapSplit
|
||||
@@ -61,7 +75,7 @@ export function WorkbenchMainFrame({
|
||||
: mapSplitAvailable
|
||||
? "半屏显示"
|
||||
: "显示地图";
|
||||
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
|
||||
const viewControlLeft = mapSplit ? `calc(${mapPercentage}% + 24px)` : "24px";
|
||||
const timelineTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
||||
@@ -75,68 +89,136 @@ export function WorkbenchMainFrame({
|
||||
// Map imagery fills the header backdrop; documents and controls reserve the header inset.
|
||||
return (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
|
||||
data-testid="workbench-main-frame"
|
||||
data-timeline-mode={timelineMode}
|
||||
>
|
||||
<div
|
||||
id="main-workspace"
|
||||
data-layout-mode={surfaceMode}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
|
||||
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
|
||||
surfaceMode !== "map_split" && "grid-cols-1"
|
||||
)}
|
||||
className="pointer-events-auto absolute inset-0 min-h-0 overflow-hidden bg-[#dbe3eb]"
|
||||
>
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</section>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
key={activeArtifact.id}
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
<WorkbenchContentSplit
|
||||
split={mapSplit}
|
||||
showMap={showMap}
|
||||
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
|
||||
onMapSizeChange={setMapPercentage}
|
||||
map={
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"z-30 mt-14 min-h-0 min-w-0 overflow-hidden",
|
||||
surfaceMode === "focus" && "lg:pl-[84px]",
|
||||
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
|
||||
"relative h-full min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={
|
||||
showArtifact
|
||||
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.994,
|
||||
transitionEnd: { visibility: "hidden" }
|
||||
}
|
||||
}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
transition={artifactTransition}
|
||||
>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</section>
|
||||
}
|
||||
document={
|
||||
<>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
key={activeArtifact.id}
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 top-14 z-30 min-h-0 min-w-0 overflow-hidden",
|
||||
surfaceMode === "focus" && "lg:pl-[84px]",
|
||||
!showArtifact && "pointer-events-none"
|
||||
)}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={
|
||||
showArtifact
|
||||
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.994,
|
||||
transitionEnd: { visibility: "hidden" }
|
||||
}
|
||||
}
|
||||
exit={
|
||||
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }
|
||||
}
|
||||
transition={artifactTransition}
|
||||
>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{pendingArtifact ? (
|
||||
{reading?.active ? (
|
||||
<section
|
||||
aria-label="展开报告工作区"
|
||||
className={cn(
|
||||
"agent-report-workspace surface-dock absolute inset-x-0 bottom-0 top-14 z-30 flex min-h-0 min-w-0 flex-col",
|
||||
reading.view === "map_only" && "hidden"
|
||||
)}
|
||||
>
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-muted-foreground">报告与分析</p>
|
||||
<h2 className="break-words text-sm font-semibold">{reading.active.title}</h2>
|
||||
</div>
|
||||
{readingSplitAvailable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
reading.setView(surfaceMode === "map_split" ? "focus" : "map_split")
|
||||
}
|
||||
>
|
||||
{surfaceMode === "map_split" ? <Focus size={14} /> : <MapIcon size={14} />}
|
||||
{surfaceMode === "map_split" ? "专注阅读" : "地图对照"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => reading.setView("map_only")}>
|
||||
<MapIcon size={14} />
|
||||
查看地图
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="收起展开报告"
|
||||
onClick={() => reading.close()}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</header>
|
||||
<div
|
||||
data-reading-scroll
|
||||
className="surface-reading min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-24 md:px-6 md:pb-6"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl pt-4 md:pt-6">
|
||||
<ReadingWorkspaceContent />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{reading?.active && reading.view === "map_only" ? (
|
||||
<Button
|
||||
className="absolute bottom-24 left-4 z-40 gap-2"
|
||||
onClick={() => reading.setView("focus")}
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
返回报告
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{pendingArtifact && !reading?.active ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
@@ -152,12 +234,9 @@ export function WorkbenchMainFrame({
|
||||
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
)}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? false
|
||||
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
|
||||
style={{ left: viewControlLeft }}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
|
||||
transition={viewControlTransition}
|
||||
>
|
||||
@@ -213,7 +292,7 @@ export function WorkbenchMainFrame({
|
||||
transition={timelineTransition}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{timelineContent ? (
|
||||
{timelineContent && !reading?.active ? (
|
||||
<motion.div
|
||||
key="operational-timeline"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
|
||||
|
||||
Reference in New Issue
Block a user