feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -0,0 +1,74 @@
import { useRef, useState, type ReactNode } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ExpandableReading, ReadingWorkspaceProvider } from "@/shared/ui/reading-workspace";
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
import { WorkbenchMainFrame } from "./workbench-main-frame";
vi.mock("./analysis-document-view", () => ({ AnalysisArtifactWorkspace: () => null }));
vi.mock("./artifact-peek-bar", () => ({ ArtifactPeekBar: () => null }));
vi.mock("./workbench-content-split", () => ({
WorkbenchContentSplit: ({ map, document }: { map: ReactNode; document: ReactNode }) => <>{map}{document}</>
}));
function Fixture({ split = true }: { split?: boolean }) {
const reading = useReadingWorkspace();
const composer = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
return <>
<label>继续输入<input ref={composer} /></label>
<ExpandableReading title="压力核查报告">
<label htmlFor="site-record">现场记录</label>
<input id="site-record" value={value} onChange={(event) => setValue(event.currentTarget.value)} />
<button type="button" onClick={() => { reading?.close(); requestAnimationFrame(() => composer.current?.focus()); }}>继续提问</button>
</ExpandableReading>
<WorkbenchMainFrame mapContent={<button type="button">地图操作</button>} timelineContent={null}
timelineMode="compact" timelineBottom={0} activeArtifact={null} pendingArtifact={null} surfaceMode="map_only"
mapSplitAvailable={split} readingSplitAvailable={split} onOpenPendingArtifact={() => {}}
onDestroyPendingArtifact={() => {}} onSetArtifactView={() => {}} onCollapseArtifact={() => {}} onDestroyArtifact={() => {}} />
</>;
}
describe("modal report reading", () => {
it("traps focus above a shared overlay, preserves the live editor through map comparison, then returns focus", async () => {
const user = userEvent.setup();
const { container } = render(<ReadingWorkspaceProvider><Fixture /></ReadingWorkspaceProvider>);
const editor = screen.getByLabelText("现场记录");
await user.type(editor, "尚未发送的现场资料");
const trigger = screen.getByRole("button", { name: "展开查看" });
await user.click(trigger);
let dialog = screen.getByRole("dialog", { name: "压力核查报告" });
expect(container.querySelector(".report-reading-backdrop")).toBeNull();
expect(document.body.querySelector(".report-reading-backdrop")).toBeInTheDocument();
expect(within(dialog).getByLabelText("现场记录")).toBe(editor);
for (let index = 0; index < 7; index++) await user.tab();
expect(dialog).toContainElement(document.activeElement as HTMLElement);
await user.click(within(dialog).getByRole("button", { name: "地图对照" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(document.body.querySelector(".report-reading-backdrop")).not.toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "展开报告工作区" })).getByLabelText("现场记录")).toBe(editor);
expect(editor).toHaveValue("尚未发送的现场资料");
await user.click(screen.getByRole("button", { name: "地图操作" }));
await user.click(screen.getByRole("button", { name: "专注阅读" }));
dialog = screen.getByRole("dialog", { name: "压力核查报告" });
expect(within(dialog).getByLabelText("现场记录")).toBe(editor);
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
expect(editor).toHaveValue("尚未发送的现场资料");
expect(document.body.querySelector(".report-reading-backdrop")).not.toBeInTheDocument();
});
it("removes the overlay for map-only mode and respects a follow-up's composer focus", async () => {
const user = userEvent.setup();
render(<ReadingWorkspaceProvider><Fixture split={false} /></ReadingWorkspaceProvider>);
await user.click(screen.getByRole("button", { name: "展开查看" }));
await user.click(screen.getByRole("button", { name: "查看地图" }));
await waitFor(() => expect(document.body.querySelector(".report-reading-backdrop")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "地图操作" }));
await user.click(screen.getByRole("button", { name: "返回报告" }));
const dialog = screen.getByRole("dialog", { name: "压力核查报告" });
await user.click(within(dialog).getByRole("button", { name: "继续提问" }));
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
});
});
@@ -1,9 +1,10 @@
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
import { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context";
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
import { ReadingWorkspaceContent } from "@/shared/ui/reading-workspace";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
@@ -56,6 +57,8 @@ export function WorkbenchMainFrame({
onDestroyArtifact
}: WorkbenchMainFrameProps) {
const reading = useReadingWorkspace();
const lastReading = useRef<ReadingDocument | null>(null);
useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]);
const surfaceMode = reading?.active
? reading.view === "map_split" && !readingSplitAvailable
? "focus"
@@ -88,6 +91,8 @@ export function WorkbenchMainFrame({
// Map imagery fills the header backdrop; documents and controls reserve the header inset.
return (
<Dialog open={Boolean(reading?.active && surfaceMode === "focus")}
onOpenChange={(open) => { if (!open) reading?.close(); }}>
<div
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
data-testid="workbench-main-frame"
@@ -156,54 +161,10 @@ export function WorkbenchMainFrame({
) : null}
</AnimatePresence>
{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>
{reading?.active && surfaceMode !== "focus" ? (
<div className={cn("report-reading-split absolute z-30 min-h-0 min-w-0 overflow-hidden rounded-xl border border-border bg-card", reading.view === "map_only" && "hidden")}>
<ReportReadingPanel splitAvailable={readingSplitAvailable} surfaceMode={surfaceMode} />
</div>
) : null}
</>
}
@@ -305,6 +266,47 @@ export function WorkbenchMainFrame({
) : null}
</AnimatePresence>
</motion.div>
<DialogContent className="report-reading-dialog motion-reduce:animate-none"
overlayClassName="report-reading-backdrop motion-reduce:animate-none" data-agent-theme="chat"
aria-describedby={undefined} showCloseButton={false}
onOpenAutoFocus={(event) => { event.preventDefault(); reading?.active?.host.focus({ preventScroll: true }); }}
onCloseAutoFocus={(event) => {
event.preventDefault();
const focused = document.activeElement;
const previous = lastReading.current;
// A deliberate follow-up may already have focused the composer or another dialog.
if (!reading?.active && previous && (!focused || focused === document.body || previous.host.contains(focused))) previous.restoreFocus?.();
}}
onEscapeKeyDown={(event) => { event.preventDefault(); event.stopPropagation(); reading?.close(); }}>
<ReportReadingPanel splitAvailable={readingSplitAvailable} surfaceMode={surfaceMode} />
</DialogContent>
</div>
</Dialog>
);
}
function ReportReadingPanel({ splitAvailable, surfaceMode }: { splitAvailable: boolean; surfaceMode: WorkbenchSurfaceMode }) {
const reading = useReadingWorkspace();
const reduced = useReducedMotion();
if (!reading?.active) return null;
return <section aria-label="展开报告工作区" className="agent-report-workspace flex min-h-0 min-w-0 flex-col overflow-hidden bg-card">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<DialogTitle className="break-words text-base leading-6 tracking-normal">{reading.active.title}</DialogTitle>
</div>
{splitAvailable ? <Button variant="ghost" size="icon-sm" aria-label={surfaceMode === "map_split" ? "专注阅读" : "地图对照"} title={surfaceMode === "map_split" ? "专注阅读" : "地图对照"} onClick={() => reading.setView(surfaceMode === "map_split" ? "focus" : "map_split")}>
{surfaceMode === "map_split" ? <Focus size={14} aria-hidden /> : <MapIcon size={14} aria-hidden />}
</Button> : <Button variant="ghost" size="icon-sm" aria-label="查看地图" title="查看地图" onClick={() => reading.setView("map_only")}>
<MapIcon size={14} aria-hidden />
</Button>}
<Button variant="ghost" size="icon-sm" aria-label="收起展开报告" onClick={() => reading.close()}><X size={17} aria-hidden /></Button>
</header>
<div data-reading-scroll className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-6 md:px-6">
<motion.div className="mx-auto min-w-0 max-w-5xl pt-4 md:pt-6"
initial={reduced ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
<ReadingWorkspaceContent />
</motion.div>
</div>
</section>;
}
@@ -1,3 +1,4 @@
import { toLocalDateKey } from "@/shared/utils/date-time";
import type { AnalysisArtifact } from "./workspace-model";
export type WorkspaceTimeRange = { start: string; end: string };
@@ -93,10 +94,3 @@ export function reduceWorkspaceContext(
};
}
}
function toLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@@ -133,7 +133,7 @@ export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWork
}
export function openAnalysisArtifact(
state: ArtifactWorkspaceState,
_state: ArtifactWorkspaceState,
artifact: AnalysisArtifact
): ArtifactWorkspaceState {
return {