feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user