feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -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 }}