diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 7ba3ec3..88974cb 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -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.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); }); diff --git a/src/features/workbench/components/workbench-agent-panels.tsx b/src/features/workbench/components/workbench-agent-panels.tsx index 560983e..ef6e148 100644 --- a/src/features/workbench/components/workbench-agent-panels.tsx +++ b/src/features/workbench/components/workbench-agent-panels.tsx @@ -10,6 +10,7 @@ type WorkbenchAgentPanelsProps = { panelProps: AgentPanelProps; panelOpen: boolean; panelCollapsing: boolean; + artifactActive: boolean; conditionExpanded: boolean; personaState: ComponentProps["state"]; statusLabel: string; @@ -23,6 +24,7 @@ export function WorkbenchAgentPanels({ panelProps, panelOpen, panelCollapsing, + artifactActive, conditionExpanded, personaState, statusLabel, @@ -42,14 +44,20 @@ export function WorkbenchAgentPanels({ return ( ) : ( - +
+ +
)} ); diff --git a/src/features/workbench/components/workbench-top-bar.tsx b/src/features/workbench/components/workbench-top-bar.tsx index cd66487..f738257 100644 --- a/src/features/workbench/components/workbench-top-bar.tsx +++ b/src/features/workbench/components/workbench-top-bar.tsx @@ -148,8 +148,8 @@ export function WorkbenchTopBar({
- + {artifactWorkspace.surfaceMode !== "focus" ? ( + + ) : null} +
+ +
+ + ); +} + +function AnalysisBlockBody({ block }: { block: Exclude }) { if (block.kind === "chart") { return ( - -
- -
-
+
+ +
); } if (block.kind === "process-flow") { return ( - -
    - {block.nodes.map((node, index) => ( -
  1. - {index < block.nodes.length - 1 ? ( -
  2. + ))} +
); } if (block.kind === "data-table") { return ( - -
- - - - {block.columns.map((column) => ( - - ))} +
+
{column.label}
+ + + {block.columns.map((column) => )} + + + + {block.rows.map((row, index) => ( + + {block.columns.map((column) => )} - - - {block.rows.map((row, index) => ( - - {block.columns.map((column) => ( - - ))} - - ))} - -
{column.label}
{row[column.key]}
{row[column.key]}
-
-
+ ))} + + +
); } + return
{block.paragraphs.map((paragraph) =>

{paragraph}

)}
; +} + +function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) { return ( - -
- {block.paragraphs.map((paragraph) =>

{paragraph}

)} +
+
+

+
    {artifact.sources.map((source) =>
  • {source}
  • )}
- +
+

+
    {artifact.limitations.map((item) =>
  • • {item}
  • )}
+
+
); } -function AnalysisSection({ +function ArtifactDetailDrawer({ + artifact, block, - className, - children + onClose }: { - block: Exclude; - className: string; - children: React.ReactNode; + artifact: AnalysisArtifact; + block: AnalysisBlock; + onClose: () => void; }) { return ( -
-
- -
-

{block.title}

- {"description" in block ?

{block.description}

: null} +
-
{children}
-
+ +
+
+
+

成果结论

+

{artifact.summary}

+
+
+

证据说明

+

{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}

+
+
+

版本上下文

+
+
版本
R{artifact.revision}
+
范围
{artifact.scope}
+
置信度
{artifact.confidence}
+
+
+
+ + ); +} + +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 ( +
+
+ {artifact.mapRelation !== "none" ? ( + + ) : null} + +
+
+ {artifact.actions.slice(0, 1).map((action) => )} + + +
+
+ ); +} + +function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) { + return ( + ); } function MetricTile({ metric }: { metric: AnalysisMetric }) { return ( -
+
-

{metric.value}

-

{metric.detail}

+

{metric.value}

+

{metric.detail}

); } +function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) { + const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const; + return {labels[lifecycle]}; +} + +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) { return { animationDuration: 260, diff --git a/src/features/workbench/workspace/artifact-peek-bar.tsx b/src/features/workbench/workspace/artifact-peek-bar.tsx new file mode 100644 index 0000000..6780292 --- /dev/null +++ b/src/features/workbench/workspace/artifact-peek-bar.tsx @@ -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 ( + + ); +} diff --git a/src/features/workbench/workspace/use-artifact-workspace.ts b/src/features/workbench/workspace/use-artifact-workspace.ts new file mode 100644 index 0000000..2ad2098 --- /dev/null +++ b/src/features/workbench/workspace/use-artifact-workspace.ts @@ -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 + }; +} diff --git a/src/features/workbench/workspace/workbench-main-frame.tsx b/src/features/workbench/workspace/workbench-main-frame.tsx index 2cec66b..f69dd84 100644 --- a/src/features/workbench/workspace/workbench-main-frame.tsx +++ b/src/features/workbench/workspace/workbench-main-frame.tsx @@ -1,436 +1,109 @@ -import { - Map as MapIcon, - PanelsTopLeft, - Sparkles -} from "lucide-react"; -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - type ReactNode -} from "react"; -import { showMapNotice } from "@/features/map/core"; +import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react"; +import type { ReactNode } from "react"; import { cn } from "@/shared/ui/cn"; -import { AnalysisDocumentView } from "./analysis-document-view"; -import { createAnalysisDocumentFixture } from "./analysis-document-fixtures"; -import { - MAX_ANALYSIS_WINDOWS, - applyWorkspaceLayout, - closeAnalysisWindow, - createInitialWorkspaceState, - focusWorkspaceWindow, - maximizeWorkspaceWindow, - minimizeWorkspaceWindow, - openAnalysisDocument, - resizeWorkspace, - restoreWorkspaceWindow, - swapWorkspaceWindows, - updateWorkspaceWindowRect, - type WorkspaceBounds, - type WorkspaceLayoutMode, - type WorkspaceRect, - type WorkspaceState +import { AnalysisArtifactWorkspace } from "./analysis-document-view"; +import { ArtifactPeekBar } from "./artifact-peek-bar"; +import type { + AnalysisArtifact, + ArtifactRequestedView, + WorkbenchSurfaceMode } from "./workspace-model"; -import { - getTaskViewColumnCount, - getTaskViewPreviewRects, - getTaskViewSourceRect, - WORKSPACE_LAYOUT_LABELS -} from "./workspace-task-view-layout"; -import { WorkspaceTaskView } from "./workspace-task-view"; -import { WorkspaceWindow } from "./workspace-window"; type WorkbenchMainFrameProps = { mapContent: ReactNode; mapOverlay?: ReactNode; + activeArtifact: AnalysisArtifact | null; + pendingArtifact: AnalysisArtifact | null; + surfaceMode: WorkbenchSurfaceMode; + mapSplitAvailable: boolean; + onOpenPendingArtifact: () => void; + onDestroyPendingArtifact: () => void; + onSetArtifactView: (view: ArtifactRequestedView) => void; + onCollapseArtifact: () => void; + onDestroyArtifact: () => void; }; -export type WorkbenchMainFrameHandle = { - openTestAnalysis: () => void; -}; - -const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 }; - -type WorkspaceDragState = { - sourceId: string; - sourceRect: WorkspaceRect; - targetId: string | null; -}; - -export const WorkbenchMainFrame = forwardRef(function WorkbenchMainFrame({ +export function WorkbenchMainFrame({ mapContent, - mapOverlay -}, ref) { - const workspaceRef = useRef(null); - const dragStateRef = useRef(null); - const taskViewButtonRef = useRef(null); - const [bounds, setBounds] = useState(INITIAL_BOUNDS); - const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS)); - const [desktop, setDesktop] = useState(false); - const [dragState, setDragState] = useState(null); - const [taskViewOpen, setTaskViewOpen] = useState(false); - const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map"); - const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState(""); - const fixtureIndexRef = useRef(0); - const fixtureInstanceRef = useRef(0); - const taskViewWindows = useMemo( - () => [workspace.map, ...workspace.analyses], - [workspace.analyses, workspace.map] - ); - const taskViewPreviewRects = useMemo( - () => getTaskViewPreviewRects(taskViewWindows.length, bounds), - [bounds, taskViewWindows.length] - ); - - useEffect(() => { - const element = workspaceRef.current; - if (!element) return; - const updateBounds = () => { - const rect = element.getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0) { - const nextBounds = { width: rect.width, height: rect.height }; - setBounds(nextBounds); - setWorkspace((current) => resizeWorkspace(current, nextBounds)); - } - }; - updateBounds(); - const observer = new ResizeObserver(updateBounds); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (!taskViewOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - setTaskViewOpen(false); - setWorkspaceAnnouncement("已关闭任务视图"); - window.requestAnimationFrame(() => taskViewButtonRef.current?.focus()); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [taskViewOpen]); - - useEffect(() => { - const mediaQuery = window.matchMedia("(min-width: 1024px)"); - const handleChange = () => { - setDesktop(mediaQuery.matches); - }; - handleChange(); - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, []); - - const openTestDocument = useCallback(() => { - if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) { - showMapNotice({ - tone: "warning", - title: "临时窗口已达上限", - message: "请关闭一个 Agent 分析窗口后继续测试。" - }); - return; - } - const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current); - fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3; - fixtureInstanceRef.current += 1; - setWorkspace((current) => openAnalysisDocument(current, document, bounds)); - }, [bounds, workspace.analyses.length]); - - useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]); - - const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => { - const root = workspaceRef.current; - if (!root) return null; - return [...root.querySelectorAll("[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(`[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, - index: number - ) { - const columns = getTaskViewColumnCount(taskViewWindows.length); - let nextIndex = index; - if (event.key === "ArrowLeft") nextIndex = Math.max(0, index - 1); - else if (event.key === "ArrowRight") nextIndex = Math.min(taskViewWindows.length - 1, index + 1); - else if (event.key === "ArrowUp") nextIndex = Math.max(0, index - columns); - else if (event.key === "ArrowDown") nextIndex = Math.min(taskViewWindows.length - 1, index + columns); - else if (event.key === "Home") nextIndex = 0; - else if (event.key === "End") nextIndex = taskViewWindows.length - 1; - else return; - event.preventDefault(); - const nextWindow = taskViewWindows[nextIndex]; - if (nextWindow) focusTaskViewWindow(nextWindow.id); - } + mapOverlay, + activeArtifact, + pendingArtifact, + surfaceMode, + mapSplitAvailable, + onOpenPendingArtifact, + onDestroyPendingArtifact, + onSetArtifactView, + onCollapseArtifact, + onDestroyArtifact +}: WorkbenchMainFrameProps) { + const showMap = surfaceMode !== "focus"; + const showArtifact = activeArtifact && surfaceMode !== "map_only"; return (
- 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} {mapOverlay} - + {activeArtifact && surfaceMode === "map_only" ? ( + + ) : null} +
- {workspace.analyses.map((window, index) => ( - setWorkspace((current) => focusWorkspaceWindow(current, window.id))} - onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))} - onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))} - onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))} - onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))} - onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id, bounds))} - onMoveStart={(rect) => startWindowMove(window.id, rect)} - onMovePreview={(_, pointer) => previewWindowMove(window.id, pointer)} - onMoveEnd={(rect, pointer) => finishWindowMove(window.id, rect, pointer)} - onMoveCancel={cancelWindowMove} + {activeArtifact ? ( +
- - - ))} - - {taskViewOpen ? ( - + +
) : null}
-
- -
- {workspaceAnnouncement} - {!desktop ? 移动端保持地图与抽屉工作台 : null} + ) : null}
); -}); - -function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) { - return ( - - ); -} - -function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) { - if (windowId === state.map.id) return state.map.title; - return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口"; } diff --git a/src/features/workbench/workspace/workspace-model.test.ts b/src/features/workbench/workspace/workspace-model.test.ts index f0b434d..707156d 100644 --- a/src/features/workbench/workspace/workspace-model.test.ts +++ b/src/features/workbench/workspace/workspace-model.test.ts @@ -1,230 +1,105 @@ import { describe, expect, it } from "vitest"; +import { createAnalysisArtifactFixture } from "./analysis-document-fixtures"; import { - MAX_ANALYSIS_WINDOWS, - applyWorkspaceLayout, - closeAnalysisWindow, - createInitialWorkspaceState, - maximizeWorkspaceWindow, - minimizeWorkspaceWindow, - openAnalysisDocument, - resizeWorkspace, - restoreWorkspaceWindow, - setWorkspacePrimaryWindow, - swapWorkspaceWindows, - updateWorkspaceWindowRect, - type AnalysisDocument, - type WorkspaceState + ARTIFACT_MAP_SPLIT_MIN_WIDTH, + collapseActiveArtifact, + createInitialArtifactWorkspaceState, + destroyActiveArtifact, + destroyPendingArtifact, + openPendingArtifact, + previewAnalysisArtifact, + requestArtifactView, + resolveWorkbenchSurfaceMode } 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 { - return { - id: `analysis-${index}`, - 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"); + expect(previewed.pendingArtifact).toBe(artifact); + expect(previewed.activeArtifact).toBeNull(); + expect(resolveWorkbenchSurfaceMode(previewed, 1920)).toBe("default"); }); - it("supports minimize, maximize and bounded restore", () => { - const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds); - const minimized = minimizeWorkspaceWindow(opened, "analysis-1"); - expect(minimized.analyses[0]?.mode).toBe("minimized"); - - 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 + it("opens a map-related artifact in split mode only when enough width exists", () => { + const artifact = createAnalysisArtifactFixture(0, 0); + const opened = openPendingArtifact( + previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact) ); - const resized = resizeWorkspace(moved, { width: 800, height: 600 }); - const analysis = resized.analyses[0]; - expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 }); - expect(analysis?.restoreRect).toEqual(analysis?.rect); - expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788); - expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588); + expect(opened.activeArtifact).toBe(artifact); + expect(opened.pendingArtifact).toBeNull(); + expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH)).toBe("map_split"); + expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus"); }); -}); -describe("workspace layout presets", () => { - it("places the active window first, keeps the map visible and minimizes primary-layout overflow", () => { - const arranged = applyWorkspaceLayout(openDocuments(5), "primary", bounds); - - 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); + it("keeps non-spatial artifacts focused at every desktop width", () => { + const artifact = createAnalysisArtifactFixture(1, 0); + const opened = openPendingArtifact( + previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact) + ); - const grid = applyWorkspaceLayout(split, "grid", bounds); - expect(visibleIds(grid)).toHaveLength(5); + expect(artifact.mapRelation).toBe("none"); + 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", () => { - const primary = applyWorkspaceLayout(openDocuments(2), "primary", bounds); - const opened = openAnalysisDocument(primary, documentAt(3), bounds); + it("supports explicit focus and map restoration for spatial artifacts", () => { + const artifact = createAnalysisArtifactFixture(2, 0); + const opened = openPendingArtifact( + previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact) + ); + const focused = requestArtifactView(opened, "focus"); + const restored = requestArtifactView(focused, "map_split"); - expect(opened.layout.mode).toBe("primary"); - expect(opened.layout.orderedWindowIds[0]).toBe("analysis-3"); - expect(opened.activeWindowId).toBe("analysis-3"); - expect(opened.map.mode).not.toBe("minimized"); + expect(resolveWorkbenchSurfaceMode(focused, 1920)).toBe("focus"); + expect(resolveWorkbenchSurfaceMode(restored, 1920)).toBe("map_split"); }); - it("reflows after manual minimize, taskbar restore and workspace resize", () => { - const arranged = applyWorkspaceLayout(openDocuments(4), "primary", bounds); - const minimized = minimizeWorkspaceWindow(arranged, "analysis-4", bounds); - const replacement = minimized.analyses.find((window) => window.id === "analysis-3"); - - expect(minimized.analyses.find((window) => window.id === "analysis-4")?.minimizedByLayout).toBe(false); - 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"); + it("supports a map-only page while a narrow Artifact remains active", () => { + const artifact = createAnalysisArtifactFixture(0, 0); + const opened = openPendingArtifact( + previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact) + ); + const mapOnly = requestArtifactView(opened, "map_only"); - const resized = resizeWorkspace(restored, { width: 900, height: 620 }); - expect(resized.analyses.find((window) => window.id === "analysis-4")?.rect.height).toBe(604); + expect(resolveWorkbenchSurfaceMode(mapOnly, 390)).toBe("map_only"); + expect(mapOnly.activeArtifact).toBe(artifact); }); - it("supports cascade layout and an explicit primary-window command", () => { - const cascade = applyWorkspaceLayout(openDocuments(3), "cascade", bounds); - expect(visibleIds(cascade)).toHaveLength(4); - expect(cascade.analyses[0]?.rect.x).toBeGreaterThan(cascade.analyses[1]?.rect.x ?? 0); + it("collapses the active artifact back into a peek", () => { + const artifact = createAnalysisArtifactFixture(0, 0); + const opened = openPendingArtifact( + previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact) + ); + const collapsed = collapseActiveArtifact(opened); - const primary = applyWorkspaceLayout(cascade, "primary", bounds); - const promoted = setWorkspacePrimaryWindow(primary, "analysis-1", bounds); - expect(promoted.layout.orderedWindowIds[0]).toBe("analysis-1"); - expect(promoted.analyses.find((window) => window.id === "analysis-1")?.rect.width).toBeGreaterThan(promoted.map.rect.width); + expect(collapsed.activeArtifact).toBeNull(); + expect(collapsed.pendingArtifact).toBe(artifact); + expect(resolveWorkbenchSurfaceMode(collapsed, 1920)).toBe("default"); }); -}); - -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); - expect(swapped.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual(mapRect); - - const resized = resizeWorkspace(swapped, { width: 1000, height: 700 }); - expect(resized.layout.orderedWindowIds.indexOf("workspace-map")).toBeLessThan( - resized.layout.orderedWindowIds.indexOf("analysis-2") + it("replaces the active artifact only after the new preview is opened", () => { + const first = createAnalysisArtifactFixture(0, 0); + const second = createAnalysisArtifactFixture(2, 1); + const opened = openPendingArtifact( + previewAnalysisArtifact(createInitialArtifactWorkspaceState(), first) ); - 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", () => { - const initial = createInitialWorkspaceState(bounds); - const mapRect = initial.map.rect; - 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); + expect(withNewPreview.activeArtifact).toBe(first); + expect(withNewPreview.pendingArtifact).toBe(second); + expect(openPendingArtifact(withNewPreview).activeArtifact).toBe(second); }); - it("exits automatic layout when a window is placed in blank workspace", () => { - const arranged = applyWorkspaceLayout(openDocuments(2), "grid", bounds); - const moved = updateWorkspaceWindowRect( - arranged, - "analysis-2", - { x: 90, y: 70, width: 420, height: 360 }, - bounds - ); + it("destroys pending and active artifacts independently", () => { + const artifact = createAnalysisArtifactFixture(0, 0); + const previewed = previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact); + const opened = openPendingArtifact(previewed); - expect(moved.layout.mode).toBe("free"); - expect(moved.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual({ - x: 90, - y: 70, - width: 420, - height: 360 - }); + expect(destroyPendingArtifact(previewed).pendingArtifact).toBeNull(); + expect(destroyActiveArtifact(opened).activeArtifact).toBeNull(); }); }); diff --git a/src/features/workbench/workspace/workspace-model.ts b/src/features/workbench/workspace/workspace-model.ts index 388242c..4146349 100644 --- a/src/features/workbench/workspace/workspace-model.ts +++ b/src/features/workbench/workspace/workspace-model.ts @@ -1,19 +1,11 @@ -export const MAX_ANALYSIS_WINDOWS = 6; +export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = 1360; -export type WorkspaceRect = { - x: number; - y: number; - width: number; - height: number; -}; - -export type WorkspaceBounds = { - width: number; - height: number; -}; - -export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized"; -export type WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade"; +export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis"; +export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived"; +export type ArtifactViewMode = "map_split" | "focus"; +export type ArtifactRequestedView = ArtifactViewMode | "map_only"; +export type ArtifactMapRelation = "required" | "optional" | "none"; +export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView; export type AnalysisMetric = { label: string; @@ -78,548 +70,100 @@ export type AnalysisBlock = paragraphs: string[]; }; -export type AnalysisDocument = { +export type AnalysisArtifactAction = { id: string; + label: string; + description: string; + tone: "primary" | "neutral" | "warning"; +}; + +export type AnalysisArtifact = { + id: string; + type: ArtifactType; + lifecycle: ArtifactLifecycle; + revision: number; title: string; subtitle: string; + summary: string; + scope: string; + confidence: string; generatedAt: string; + preferredView: ArtifactViewMode; + mapRelation: ArtifactMapRelation; blocks: AnalysisBlock[]; + sources: string[]; + limitations: string[]; + actions: AnalysisArtifactAction[]; }; -type WorkspaceWindowState = { - mode: WorkspaceWindowMode; - rect: WorkspaceRect; - restoreRect: WorkspaceRect; - zIndex: number; - minimizedByLayout: boolean; +export type ArtifactWorkspaceState = { + pendingArtifact: AnalysisArtifact | null; + activeArtifact: AnalysisArtifact | null; + requestedView: ArtifactRequestedView; }; -export type AnalysisWorkspaceWindow = WorkspaceWindowState & { - 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); +export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState { return { - map: { - id: MAP_WINDOW_ID, - kind: "map", - 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] } + pendingArtifact: null, + activeArtifact: null, + requestedView: "map_split" }; } -export function openAnalysisDocument( - state: WorkspaceState, - document: AnalysisDocument, - bounds: WorkspaceBounds -): WorkspaceState { - if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state; +export function previewAnalysisArtifact( + state: ArtifactWorkspaceState, + artifact: AnalysisArtifact +): ArtifactWorkspaceState { + return { ...state, pendingArtifact: artifact }; +} - const rect = getDefaultAnalysisRect(bounds, state.analyses.length); - const window: AnalysisWorkspaceWindow = { - id: document.id, - kind: "agent-analysis", - title: document.title, - mode: "floating", - rect, - restoreRect: rect, - zIndex: state.nextZIndex, - minimizedByLayout: false, - document +export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState { + if (!state.pendingArtifact) return state; + return { + pendingArtifact: null, + activeArtifact: state.pendingArtifact, + requestedView: getPreferredArtifactView(state.pendingArtifact) }; - 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( - state: WorkspaceState, - 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); +export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState { + if (!state.activeArtifact) return state; return { ...state, - map: { - ...state.map, - 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") - })) + pendingArtifact: state.activeArtifact, + activeArtifact: null }; } -export function clampWorkspaceRect( - rect: WorkspaceRect, - 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 - }; +export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState { + return state.pendingArtifact ? { ...state, pendingArtifact: null } : state; } -function arrangeWorkspaceLayout( - state: WorkspaceState, - bounds: WorkspaceBounds, - restoreAll = false -): 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; +export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState { + return state.activeArtifact + ? { ...state, activeArtifact: null, requestedView: "map_split" } + : state; } -function selectVisibleWindowIds(ids: string[], mode: WorkspaceLayoutMode) { - const capacity = mode === "split" ? 2 : mode === "primary" ? 3 : ids.length; - const selected = ids.slice(0, capacity); - if ( - (mode === "split" || mode === "primary") && - ids.includes(MAP_WINDOW_ID) && - !selected.includes(MAP_WINDOW_ID) - ) { - selected[selected.length - 1] = MAP_WINDOW_ID; - } - return selected; +export function requestArtifactView( + state: ArtifactWorkspaceState, + view: ArtifactRequestedView +): ArtifactWorkspaceState { + if (!state.activeArtifact) return state; + return { ...state, requestedView: view }; } -function getLayoutRects(mode: WorkspaceLayoutMode, count: number, bounds: WorkspaceBounds): WorkspaceRect[] { - if (count === 0) return []; - const width = Math.max(1, bounds.width - LAYOUT_INSET * 2); - const height = Math.max(1, bounds.height - LAYOUT_INSET * 2); - - if (mode === "primary") { - if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }]; - const primaryWidth = Math.floor((width - LAYOUT_GAP) * 0.68); - const secondaryWidth = width - LAYOUT_GAP - primaryWidth; - 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 - }; - }); +export function resolveWorkbenchSurfaceMode( + state: ArtifactWorkspaceState, + viewportWidth: number +): WorkbenchSurfaceMode { + const artifact = state.activeArtifact; + if (!artifact) return "default"; + if (state.requestedView === "map_only") return "map_only"; + if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus"; + return viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus"; } -function normalizeWindowOrder(state: WorkspaceState) { - const ids = new Set(allWorkspaceWindows(state).map((window) => window.id)); - 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: (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: (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; -} - -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); +function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode { + return artifact.mapRelation === "none" ? "focus" : artifact.preferredView; } diff --git a/src/features/workbench/workspace/workspace-task-view-layout.ts b/src/features/workbench/workspace/workspace-task-view-layout.ts deleted file mode 100644 index 8b74070..0000000 --- a/src/features/workbench/workspace/workspace-task-view-layout.ts +++ /dev/null @@ -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 = { - 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; -} diff --git a/src/features/workbench/workspace/workspace-task-view.test.ts b/src/features/workbench/workspace/workspace-task-view.test.ts deleted file mode 100644 index 490efed..0000000 --- a/src/features/workbench/workspace/workspace-task-view.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/features/workbench/workspace/workspace-task-view.tsx b/src/features/workbench/workspace/workspace-task-view.tsx deleted file mode 100644 index c3b8eeb..0000000 --- a/src/features/workbench/workspace/workspace-task-view.tsx +++ /dev/null @@ -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, index: number) => void; - onLayoutChange: (mode: WorkspaceLayoutMode) => void; -}) { - function handleDialogKeyDown(event: KeyboardEvent) { - if (event.key !== "Tab") return; - const controls = [...event.currentTarget.querySelectorAll("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 ( - <> -