11 Commits
Author SHA1 Message Date
jiang db696eb4f1 test: align collapsed agent camera padding
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-19 18:00:40 +08:00
jiang 229cb0def2 fix: animate analysis workspace transitions 2026-08-19 17:54:37 +08:00
jiang 1fb950806a fix: unify analysis workspace controls 2026-08-19 17:43:14 +08:00
jiang 073832034a fix: restyle analysis return control 2026-08-19 17:33:19 +08:00
jiang 57c494e8bd refactor: simplify evidence workspace presentation 2026-08-19 17:31:21 +08:00
jiang 4a589d7a43 fix: refine evidence detail presentation 2026-08-19 17:28:21 +08:00
jiang d39e58196f fix: tighten timeline lane spacing 2026-08-19 17:21:41 +08:00
jiang b93999646c fix: simplify timeline legend controls 2026-08-19 17:15:48 +08:00
jiang b91be09f5c fix: distinguish timeline attention events 2026-08-19 17:12:13 +08:00
jiang 602b39d60a feat: add interactive timeline legend 2026-08-19 17:07:22 +08:00
jiang 5e7101bfee fix: default Agent panel to collapsed 2026-08-19 17:00:43 +08:00
10 changed files with 671 additions and 285 deletions
@@ -69,7 +69,7 @@ export function useWorkbenchAgent({
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true);
const [panelOpen, setPanelOpen] = useState(false);
const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
@@ -996,7 +996,8 @@ export function MapWorkbenchPage({
<span className="font-semibold"></span>
<span className="text-slate-500"> {operationalSummary.planned}</span>
<span className="text-slate-500"> {operationalSummary.active}</span>
<span className="text-amber-700"> {operationalSummary.exceptions}</span>
<span className="text-amber-700"> {operationalSummary.attention}</span>
<span className="text-rose-700"> {operationalSummary.exceptions}</span>
<span className="ml-auto text-slate-500"></span>
</button>
</> : null
+2 -2
View File
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
{
agentOpen: false,
conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 96 }
expected: { top: 72, right: 72, bottom: 32, left: 24 }
},
{
agentOpen: true,
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
{
agentOpen: false,
conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 96 }
expected: { top: 72, right: 504, bottom: 32, left: 24 }
},
{
agentOpen: true,
@@ -4,15 +4,33 @@ import {
canCenterTimelineBesideMapScale,
clusterOperationalEvents,
createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventCategory,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout
} from "./operational-timeline-model";
import type { OperationalEvent } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = {
id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整", summary: "调整阀门", status: "running", riskLevel: "attention", updatedAt: 1,
durationMinutes: 40, source: "调度方案", location: "边界阀门 BV-07", dispatcher: "调度",
assignee: "班组", priority: "urgent", replyWindowMinutes: 20, stages: [], replyRequirements: []
id: "work-1",
kind: "work_order",
code: "WO-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整",
summary: "调整阀门",
status: "running",
riskLevel: "attention",
updatedAt: 1,
durationMinutes: 40,
source: "调度方案",
location: "边界阀门 BV-07",
dispatcher: "调度",
assignee: "班组",
priority: "urgent",
replyWindowMinutes: 20,
stages: [],
replyRequirements: []
};
describe("operational timeline model", () => {
@@ -26,13 +44,57 @@ describe("operational timeline model", () => {
expect(events[0].correlationId).toBe(events[1].correlationId);
});
it("filters event lanes from the interactive legend", () => {
const events = createOperationalEvents([workOrder]);
const filtered = filterOperationalEvents(events, {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
actual: false
});
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
});
it("separates attention events from failed exceptions", () => {
const [planEvent] = createOperationalEvents([workOrder]);
const attentionEvent = {
...planEvent,
id: "attention-1",
lane: "exception",
status: "delayed"
} satisfies OperationalEvent;
const exceptionEvent = {
...attentionEvent,
id: "exception-1",
status: "failed"
} satisfies OperationalEvent;
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
attention: false
})).toEqual([exceptionEvent]);
});
it("only promotes abnormal condition runs into exception events", () => {
const normal = {
id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed",
riskLevel: "normal", updatedAt: 1
id: "condition-normal",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "诊断",
summary: "正常",
status: "completed",
riskLevel: "normal",
updatedAt: 1
} satisfies ScheduledConditionItem;
const abnormal = {
...normal,
id: "condition-error",
status: "error",
riskLevel: "critical"
} satisfies ScheduledConditionItem;
const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
@@ -1,10 +1,16 @@
import type { FeatureTarget } from "../map/workbench-map-controller";
import type { ScheduledConditionItem, ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
import type {
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledWorkOrderItem
} from "../types";
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
export type OperationalEvent = {
id: string;
@@ -42,6 +48,13 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
overlayReservedHeight: 0
} as const;
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
attention: true,
exception: true
};
export type TimelineScaleLayout = {
scaleBottom: number;
timelineBottom: number;
@@ -54,11 +67,10 @@ export function canCenterTimelineBesideMapScale(
) {
if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return businessWorkspaceWidth >=
return (
businessWorkspaceWidth >=
layout.maxWidth +
2 * (
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
);
}
@@ -83,9 +95,11 @@ export function resolveTimelineScaleLayout(
}
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items.flatMap((item) =>
return items
.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
)
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
}
export function clusterOperationalEvents(
@@ -116,17 +130,33 @@ export function getOperationalEventSummary(events: OperationalEvent[]) {
return {
planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length,
exceptions: events.filter((event) => event.lane === "exception").length,
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
critical: events.filter((event) => event.importance === "critical").length
};
}
export function getOperationalEventCategory(
event: OperationalEvent
): OperationalEventCategory {
if (event.lane === "plan" || event.lane === "actual") return event.lane;
return event.status === "failed" ? "exception" : "attention";
}
export function filterOperationalEvents(
events: OperationalEvent[],
visibility: OperationalEventVisibility
) {
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") {
return [];
}
return [{
return [
{
id: `exception-${item.id}`,
title: item.title,
description: item.report?.conclusion ?? item.summary,
@@ -141,7 +171,8 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
conditionId: item.id,
sourceLabel: "工况诊断",
mapTargets: getConditionMapTargets(item)
}];
}
];
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
@@ -9,15 +9,20 @@ import {
TriangleAlert
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import {
clusterOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent,
type OperationalEventCategory,
type OperationalEventCluster,
type OperationalEventLane,
type OperationalEventVisibility,
type OperationalTimelineMode
} from "./operational-timeline-model";
@@ -40,6 +45,13 @@ const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "exception", label: "异常" }
];
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "attention", label: "关注" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({
date,
cursorTime,
@@ -53,8 +65,12 @@ export function OperationalTimeline({
onReturnNow
}: OperationalTimelineProps) {
const prefersReducedMotion = useReducedMotion();
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
}));
const summary = getOperationalEventSummary(events);
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10);
const visibleEvents = filterOperationalEvents(events, eventVisibility);
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
@@ -86,6 +102,10 @@ export function OperationalTimeline({
) : (
<span className="min-w-0 truncate text-xs text-slate-500"></span>
)}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</motion.section>
);
@@ -114,12 +134,19 @@ export function OperationalTimeline({
<div className="flex min-w-0 items-center gap-2">
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<div className="flex items-center gap-2">
<p className={cn("text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500", mode === "compact" && !mobile && "hidden")}></p>
<p
className={cn(
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
((mode === "compact" && !mobile) || mobile) && "hidden"
)}
>
</p>
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
<TimelineSummary summary={summary} />
{!mobile ? <TimelineSummary summary={summary} /> : null}
{selectedEvent && !mobile ? (
<button
type="button"
@@ -130,6 +157,10 @@ export function OperationalTimeline({
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
@@ -137,16 +168,18 @@ export function OperationalTimeline({
{mode === "expanded" || mobile ? (
<motion.div
key="expanded-timeline"
className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]"
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
<span />
{LANES.map((lane) => (
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span>
<span key={lane.id} className="flex items-center justify-center font-semibold">
{lane.label}
</span>
))}
</div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
@@ -262,7 +295,8 @@ function TimelineMarker({
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
exception && event.status !== "failed" &&
exception &&
event.status !== "failed" &&
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
@@ -279,23 +313,58 @@ function TimelineMarker({
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl">
<div className="flex items-center gap-2">
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
event.lane === "plan" && "bg-blue-50 text-blue-700",
event.lane === "actual" && "bg-emerald-50 text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "bg-amber-50 text-amber-800",
event.status === "failed" && "bg-rose-50 text-rose-700"
)}>
<TooltipContent
side="top"
sideOffset={10}
className={cn(
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
)}
>
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
<div className="flex items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
event.lane === "exception" &&
event.status !== "failed" &&
"border-amber-200 bg-amber-50 text-amber-800",
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
)}
>
<OperationalEventGlyph event={event} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-[10px] font-bold tracking-[0.08em]",
event.lane === "plan" && "text-blue-700",
event.lane === "actual" && "text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
event.status === "failed" && "text-rose-700"
)}
>
{operationalEventNatureLabel(event)}
</span>
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span>
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
{formatClock(event.cursorTime)}
</time>
</div>
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
</div>
</div>
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
{event.title}
</p>
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
{event.description}
</p>
{cluster.events.length > 1 ? (
<p className="mt-2 text-[10px] text-slate-500"> {cluster.events.length} </p>
) : null}
</div>
<p className="mt-1.5 font-semibold">{formatClock(event.cursorTime)} · {event.title}</p>
<p className="mt-1 leading-5 text-slate-600">{event.description}</p>
{cluster.events.length > 1 ? <p className="mt-1 text-slate-500"> {cluster.events.length} </p> : null}
{event.conditionId ? <p className="mt-1 font-medium text-blue-700"></p> : null}
</TooltipContent>
</Tooltip>
);
@@ -315,8 +384,8 @@ function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
}
function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "关键异常";
if (event.lane === "exception") return "运行偏差";
if (event.status === "failed") return "异常";
if (event.lane === "exception") return "关注";
if (event.lane === "actual") return "实际执行";
return "计划任务";
}
@@ -324,9 +393,18 @@ function operationalEventNatureLabel(event: OperationalEvent) {
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span> <strong className="text-blue-700">{summary.planned}</strong></span>
<span> <strong className="text-emerald-700">{summary.active}</strong></span>
<span> <strong className="text-amber-700">{summary.exceptions}</strong></span>
<span>
<strong className="text-blue-700">{summary.planned}</strong>
</span>
<span>
<strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
<strong className="text-amber-700">{summary.attention}</strong>
</span>
<span>
<strong className="text-rose-700">{summary.exceptions}</strong>
</span>
</div>
);
}
@@ -341,7 +419,7 @@ function TimelineActions({
onReturnNow: () => void;
}) {
return (
<div className="surface-control ml-auto flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<button
type="button"
title="返回现在"
@@ -375,6 +453,60 @@ function TimelineActions({
);
}
function TimelineLegend({
visibility,
onVisibilityChange
}: {
visibility: OperationalEventVisibility;
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
}) {
return (
<div
aria-label="时间轴图例"
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
>
{LEGEND_ITEMS.map((item) => {
const visible = visibility[item.id];
return (
<button
key={item.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[item.id]: !visible
})
}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
visible ? "opacity-100" : "grayscale opacity-25"
)}
>
<LegendSwatch category={item.id} />
</button>
);
})}
</div>
);
}
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
return (
<span
className={cn(
"block h-3.5 w-3.5 shrink-0 border",
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
)}
/>
);
}
function TimeTicks() {
return (
<div className="relative text-[9px] text-slate-600">
@@ -414,7 +546,9 @@ function CompactTimeTicks() {
}
function pickClusterEvent(events: OperationalEvent[]) {
return [...events].sort((left, right) => importanceRank(right.importance) - importanceRank(left.importance))[0];
return [...events].sort(
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
)[0];
}
function importanceRank(value: OperationalEvent["importance"]) {
@@ -428,7 +562,11 @@ function getDayPosition(value: string) {
}
function formatClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function formatDate(value: string) {
@@ -7,52 +7,46 @@ import {
Clock3,
Database,
FileSearch,
Focus,
Map as MapIcon,
Minimize2,
PanelRightClose,
PanelRightOpen,
ShieldCheck,
Sparkles,
Trash2,
X
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
AnalysisMetric,
ArtifactRequestedView,
WorkbenchSurfaceMode
AnalysisMetric
} from "./workspace-model";
type AnalysisArtifactWorkspaceProps = {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void;
onDestroy: () => void;
};
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
export function AnalysisArtifactWorkspace({
artifact,
surfaceMode,
mapSplitAvailable,
onSetView,
onCollapse,
onDestroy
}: AnalysisArtifactWorkspaceProps) {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
const selectedBlock = useMemo(
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
[artifact.blocks, selectedBlockId]
const evidenceBlocks = artifact.blocks.filter(
(block): block is EvidenceBlock => block.kind !== "metric-grid"
);
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
useEffect(() => {
setSelectedBlockId(null);
@@ -116,11 +110,6 @@ export function AnalysisArtifactWorkspace({
<ArtifactActionBar
artifact={artifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
detailOpen={Boolean(selectedBlock)}
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
onSetView={onSetView}
onCollapse={onCollapse}
onDestroy={onDestroy}
/>
@@ -176,7 +165,7 @@ function AnalysisBlockView({
selected,
onSelect
}: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
block: EvidenceBlock;
selected: boolean;
onSelect: () => void;
}) {
@@ -198,11 +187,11 @@ function AnalysisBlockView({
>
<button
type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
aria-label={`查看${block.title}详情`}
onClick={onSelect}
>
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<FileSearch size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
@@ -218,7 +207,7 @@ function AnalysisBlockView({
);
}
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
if (block.kind === "chart") {
return (
<div className="h-[258px] min-h-0">
@@ -280,14 +269,28 @@ function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "m
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return (
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
<div className="surface-well rounded-xl border px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" /></h3>
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
<Database size={14} aria-hidden="true" />
</h3>
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
{artifact.sources.map((source) => (
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
))}
</ul>
</div>
<div className="material-tone-warning rounded-xl border px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" /></h3>
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}> {item}</li>)}</ul>
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
<AlertTriangle size={14} aria-hidden="true" />
</h3>
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
{artifact.limitations.map((item) => (
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
))}
</ul>
</div>
</section>
);
@@ -299,79 +302,140 @@ function ArtifactDetailDrawer({
onClose
}: {
artifact: AnalysisArtifact;
block: AnalysisBlock;
block: EvidenceBlock;
onClose: () => void;
}) {
const blockFacts = getEvidenceBlockFacts(block);
return (
<aside aria-label="证据详情" className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
<aside
aria-label="证据详情"
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
>
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="uppercase tracking-[0.12em]"></span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
</div>
<button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95">
<X size={18} aria-hidden="true" />
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
</div>
</div>
<button
type="button"
aria-label="关闭证据详情"
onClick={onClose}
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
>
<X size={15} aria-hidden="true" />
</button>
</div>
<div className="space-y-5 p-5">
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
<div className="space-y-5 px-5 py-5">
<section aria-labelledby="evidence-purpose-heading">
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-6 text-slate-700">
{evidenceBlockDescription(block)}
</p>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
<div className="flex items-center justify-between gap-3">
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500"></h4>
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
</div>
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
<dl className="mt-2 divide-y divide-slate-200">
{blockFacts.map((fact) => (
<div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
<dt className="text-slate-500">{fact.label}</dt>
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
</div>
<div className="bg-slate-100 p-4">
<p className="text-xs font-semibold text-slate-700"></p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.confidence}</dd>
))}
</dl>
</div>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500"></h4>
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
</dl>
</section>
</div>
</aside>
);
}
function evidenceBlockDescription(block: EvidenceBlock) {
return "description" in block
? block.description
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
}
function evidenceBlockKindLabel(block: EvidenceBlock) {
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
if (block.kind === "process-flow") return "流程证据";
if (block.kind === "data-table") return "明细数据";
return "分析说明";
}
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
if (block.kind === "chart") {
return [
{ label: "数据序列", value: `${block.series.length}` },
{ label: "时间 / 类别点", value: `${block.categories.length}` },
{ label: "计量单位", value: block.unit }
];
}
if (block.kind === "data-table") {
return [
{ label: "证据记录", value: `${block.rows.length}` },
{ label: "数据字段", value: `${block.columns.length}` }
];
}
if (block.kind === "process-flow") {
return [
{ label: "流程步骤", value: `${block.nodes.length}` },
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length}` },
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length}` }
];
}
return [{ label: "分析段落", value: `${block.paragraphs.length}` }];
}
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
const formatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
return `${formatter.format(new Date(range.start))}${formatter.format(new Date(range.end))}`;
}
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 (
<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">
<div className="flex min-w-0 items-center gap-1">
{artifact.mapRelation !== "none" ? (
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
</button>
) : null}
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
</button>
</div>
<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} />)}
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button>
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
</div>
</footer>
);
@@ -382,9 +446,9 @@ function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactA
<button
type="button"
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}${action.description}` })}
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex"
className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
>
<ShieldCheck size={15} aria-hidden="true" />
<ShieldCheck size={14} aria-hidden="true" />
{action.label}
</button>
);
@@ -1,5 +1,9 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
export function ArtifactPeekBar({
@@ -17,7 +21,7 @@ export function ArtifactPeekBar({
<aside
aria-label="Agent 成果预览"
className={cn(
"acrylic-panel pointer-events-auto absolute top-28 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]"
)}
@@ -36,9 +40,9 @@ export function ArtifactPeekBar({
<button
type="button"
onClick={onOpen}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-md bg-blue-500 px-3 text-xs font-semibold text-white hover:bg-blue-400 active:scale-95"
className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
>
<ChevronUp size={15} aria-hidden="true" />
<ChevronUp size={14} aria-hidden="true" />
</button>
<button
@@ -46,9 +50,9 @@ export function ArtifactPeekBar({
aria-label="销毁预览成果"
title="销毁预览成果"
onClick={onDestroy}
className="surface-control grid h-10 w-10 shrink-0 place-items-center rounded-xl border text-slate-500 hover:text-rose-700 active:scale-95"
className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
>
<Trash2 size={16} aria-hidden="true" />
<Trash2 size={15} aria-hidden="true" />
</button>
</aside>
);
@@ -0,0 +1,14 @@
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
@@ -1,10 +1,14 @@
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar";
import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
ArtifactRequestedView,
@@ -46,10 +50,28 @@ export function WorkbenchMainFrame({
}: WorkbenchMainFrameProps) {
const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus";
const showArtifact = activeArtifact && surfaceMode !== "map_only";
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
const viewControlTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
const artifactTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
return (
<div
@@ -77,39 +99,41 @@ export function WorkbenchMainFrame({
>
{mapContent}
{mapOverlay}
{activeArtifact && surfaceMode === "map_only" ? (
<button
type="button"
onClick={() => onSetArtifactView("focus")}
className="pointer-events-auto absolute left-3 top-3 z-20 inline-flex h-10 items-center gap-2 rounded-md bg-slate-950 px-3 text-xs font-semibold text-white shadow-[0_8px_24px_rgba(15,23,42,0.22)] hover:bg-slate-800 active:scale-95 lg:left-4 lg:top-4"
>
<ArrowLeft size={15} aria-hidden="true" />
<FileChartColumnIncreasing size={15} aria-hidden="true" />
</button>
) : null}
</section>
<AnimatePresence initial={false} mode="popLayout">
{activeArtifact ? (
<section
<motion.section
key={activeArtifact.id}
id="artifact-workspace"
aria-label={`${activeArtifact.title}工作区`}
aria-hidden={!showArtifact}
className={cn(
"relative z-30 min-h-0 min-w-0 overflow-hidden",
!showArtifact && "hidden"
"z-30 min-h-0 min-w-0 overflow-hidden",
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)}
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}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
onSetView={onSetArtifactView}
onCollapse={onCollapseArtifact}
onDestroy={onDestroyArtifact}
/>
</section>
</motion.section>
) : null}
</AnimatePresence>
{pendingArtifact ? (
<ArtifactPeekBar
@@ -119,6 +143,54 @@ export function WorkbenchMainFrame({
onDestroy={onDestroyPendingArtifact}
/>
) : null}
<AnimatePresence initial={false}>
{showArtifactViewControl ? (
<motion.div
className={cn(
"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 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
>
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={surfaceMode}
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
transition={viewControlTransition}
>
{mapOnly ? (
<ArrowLeft size={13} aria-hidden="true" />
) : mapSplit ? (
<Focus size={13} aria-hidden="true" />
) : (
<MapIcon size={13} aria-hidden="true" />
)}
{viewControlLabel}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<motion.div