Restore full acrylic materials to stacked task cards and floating controls. Extend the map behind the navigation header while preserving document and control insets. Align timeline and scale readouts, refresh dense-marker hover content, and highlight model and approval menu options. Keep narrow timeline headers clear of their legend and actions. Update coupled geometry checks and retain city zoom-12 and menu regression coverage. Validation: scoped git diff --check and read-only UI geometry inspection. Test suites were not rerun at the user's request.
513 lines
15 KiB
TypeScript
513 lines
15 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||
import { cn } from "@/shared/ui/cn";
|
||
import type {
|
||
ScheduledConditionRecord,
|
||
WorkbenchRuntimeDataSource
|
||
} from "../types";
|
||
import {
|
||
createVisibleTickerCards,
|
||
type TickerStackCard
|
||
} from "./agent-task-ticker-utils";
|
||
|
||
type AgentTaskTickerProps = {
|
||
conditions: ScheduledConditionRecord[];
|
||
dataSource?: WorkbenchRuntimeDataSource;
|
||
className?: string;
|
||
};
|
||
|
||
type TickerTransitionDirection = -1 | 1;
|
||
|
||
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||
const tickerSwitchAnimationMs = 380;
|
||
const tickerCardVariants: Variants = {
|
||
initial: (direction: TickerTransitionDirection) => ({
|
||
opacity: 0.62,
|
||
x: 0,
|
||
y: direction > 0 ? 36 : -28,
|
||
rotate: 0
|
||
}),
|
||
exit: (direction: TickerTransitionDirection) => ({
|
||
opacity: [1, 1, 0],
|
||
x: 0,
|
||
y: direction > 0 ? 36 : -28,
|
||
rotate: 0,
|
||
transition: {
|
||
duration: 0.3,
|
||
times: [0, 0.65, 1],
|
||
ease: [0.5, 0, 0.2, 1]
|
||
}
|
||
})
|
||
};
|
||
|
||
const tickerContentVariants: Variants = {
|
||
initial: {
|
||
clipPath: "inset(0 100% 0 0)"
|
||
},
|
||
animate: {
|
||
clipPath: "inset(0 0% 0 0)",
|
||
transition: {
|
||
duration: 0.18,
|
||
ease: tickerEase
|
||
}
|
||
},
|
||
exit: {
|
||
clipPath: "inset(0 0 0 100%)",
|
||
transition: {
|
||
duration: 0.14,
|
||
ease: [0.5, 0, 0.2, 1]
|
||
}
|
||
}
|
||
};
|
||
|
||
const tickerIndicatorVariants: Variants = {
|
||
initial: {
|
||
opacity: 0,
|
||
x: 4,
|
||
scale: 0.96
|
||
},
|
||
animate: {
|
||
opacity: 1,
|
||
x: 0,
|
||
scale: 1,
|
||
transition: {
|
||
duration: 0.16,
|
||
ease: tickerEase
|
||
}
|
||
},
|
||
exit: {
|
||
opacity: 0,
|
||
x: 4,
|
||
scale: 0.96,
|
||
transition: {
|
||
duration: 0.12,
|
||
ease: [0.5, 0, 0.2, 1]
|
||
}
|
||
}
|
||
};
|
||
|
||
export function AgentTaskTicker({
|
||
conditions,
|
||
dataSource = "mock",
|
||
className
|
||
}: AgentTaskTickerProps) {
|
||
const sectionRef = useRef<HTMLElement | null>(null);
|
||
const isTaskSwitchLockedRef = useRef(false);
|
||
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
|
||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||
const [rotationIndex, setRotationIndex] = useState(0);
|
||
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
|
||
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
|
||
id: 0,
|
||
active: false
|
||
}));
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
const [isDocumentVisible, setIsDocumentVisible] = useState(() => (
|
||
typeof document === "undefined" || document.visibilityState === "visible"
|
||
));
|
||
const [visibilityRevision, setVisibilityRevision] = useState(0);
|
||
const runningConditions = conditions;
|
||
const visibleTickerCards = useMemo(
|
||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs, dataSource),
|
||
[dataSource, runningConditions, rotationIndex, nowMs]
|
||
);
|
||
const hasMultipleRunningTasks = runningConditions.length > 1;
|
||
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
|
||
const tickerLabel = hasMultipleRunningTasks
|
||
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
|
||
: "工况任务步骤";
|
||
|
||
const releaseTaskSwitchLock = useCallback(() => {
|
||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||
taskSwitchUnlockTimeoutRef.current = null;
|
||
}
|
||
|
||
isTaskSwitchLockedRef.current = false;
|
||
}, []);
|
||
|
||
const finishTaskSwitchAnimation = useCallback(() => {
|
||
setTaskSwitchAnimation((current) => (
|
||
current.active ? { ...current, active: false } : current
|
||
));
|
||
}, []);
|
||
|
||
const lockTaskSwitch = useCallback(() => {
|
||
isTaskSwitchLockedRef.current = true;
|
||
|
||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||
}
|
||
|
||
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
|
||
isTaskSwitchLockedRef.current = false;
|
||
taskSwitchUnlockTimeoutRef.current = null;
|
||
finishTaskSwitchAnimation();
|
||
}, tickerSwitchAnimationMs);
|
||
}, [finishTaskSwitchAnimation]);
|
||
|
||
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
|
||
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
|
||
return;
|
||
}
|
||
|
||
lockTaskSwitch();
|
||
setTransitionDirection(direction);
|
||
setTaskSwitchAnimation((current) => ({
|
||
id: current.id + 1,
|
||
active: true
|
||
}));
|
||
setRotationIndex((current) => (
|
||
(current + direction + runningConditions.length) % runningConditions.length
|
||
));
|
||
}, [lockTaskSwitch, runningConditions.length]);
|
||
|
||
const showPreviousTask = useCallback(() => {
|
||
shiftTask(-1);
|
||
}, [shiftTask]);
|
||
|
||
const showNextTask = useCallback(() => {
|
||
shiftTask(1);
|
||
}, [shiftTask]);
|
||
|
||
const handleWheel = useCallback((event: WheelEvent) => {
|
||
if (!hasMultipleRunningTasks || event.deltaY === 0) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
|
||
if (event.deltaY > 0) {
|
||
showNextTask();
|
||
return;
|
||
}
|
||
|
||
showPreviousTask();
|
||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||
|
||
useEffect(() => {
|
||
if (!isDocumentVisible) {
|
||
return;
|
||
}
|
||
|
||
const refreshNow = () => setNowMs(Date.now());
|
||
|
||
refreshNow();
|
||
const intervalId = window.setInterval(() => {
|
||
refreshNow();
|
||
}, 1_000);
|
||
|
||
return () => window.clearInterval(intervalId);
|
||
}, [isDocumentVisible]);
|
||
|
||
useEffect(() => {
|
||
setRotationIndex(0);
|
||
releaseTaskSwitchLock();
|
||
finishTaskSwitchAnimation();
|
||
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
|
||
|
||
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
||
|
||
useEffect(() => {
|
||
const handleVisibilityChange = () => {
|
||
const isVisible = document.visibilityState === "visible";
|
||
|
||
releaseTaskSwitchLock();
|
||
finishTaskSwitchAnimation();
|
||
setIsHovered(false);
|
||
setIsDocumentVisible(isVisible);
|
||
setVisibilityRevision((current) => current + 1);
|
||
|
||
if (isVisible) {
|
||
setNowMs(Date.now());
|
||
}
|
||
};
|
||
|
||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||
|
||
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock]);
|
||
|
||
useEffect(() => {
|
||
const section = sectionRef.current;
|
||
if (!section) {
|
||
return;
|
||
}
|
||
|
||
section.addEventListener("wheel", handleWheel, { passive: false });
|
||
|
||
return () => section.removeEventListener("wheel", handleWheel);
|
||
}, [handleWheel]);
|
||
|
||
useEffect(() => {
|
||
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
|
||
return;
|
||
}
|
||
|
||
const intervalId = window.setInterval(() => {
|
||
if (document.visibilityState !== "visible") {
|
||
return;
|
||
}
|
||
|
||
shiftTask(1);
|
||
}, 6_000);
|
||
|
||
return () => window.clearInterval(intervalId);
|
||
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
|
||
|
||
if (visibleTickerCards.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<section
|
||
ref={sectionRef}
|
||
className={cn(
|
||
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
|
||
className
|
||
)}
|
||
aria-label={tickerLabel}
|
||
title={tickerLabel}
|
||
onMouseEnter={() => setIsHovered(true)}
|
||
onMouseLeave={() => setIsHovered(false)}
|
||
>
|
||
<div className="absolute inset-0">
|
||
<AnimatePresence
|
||
key={`ticker-presence-${visibilityRevision}`}
|
||
custom={transitionDirection}
|
||
mode="popLayout"
|
||
initial={false}
|
||
>
|
||
{visibleTickerCards
|
||
.slice()
|
||
.reverse()
|
||
.map((card) => (
|
||
<TickerTaskCard
|
||
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
|
||
card={card}
|
||
isActive={card.stackIndex === 0}
|
||
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
|
||
transitionDirection={transitionDirection}
|
||
/>
|
||
))}
|
||
</AnimatePresence>
|
||
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
|
||
<AnimatePresence initial={false}>
|
||
{isHovered && hasMultipleRunningTasks ? (
|
||
<motion.div
|
||
key="task-indicator"
|
||
className="agent-task-ticker-indicator acrylic-control flex flex-col gap-1 rounded-full border px-1 py-1"
|
||
variants={tickerIndicatorVariants}
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
>
|
||
{runningConditions.map((runningCondition, index) => (
|
||
<span
|
||
key={runningCondition.id}
|
||
className={cn(
|
||
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
|
||
index === activeIndicatorIndex ? "bg-[var(--action-blue)]" : "bg-slate-300"
|
||
)}
|
||
/>
|
||
))}
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function TickerTaskCard({
|
||
card,
|
||
isActive,
|
||
animateContentChanges,
|
||
transitionDirection
|
||
}: {
|
||
card: TickerStackCard;
|
||
isActive: boolean;
|
||
animateContentChanges: boolean;
|
||
transitionDirection: TickerTransitionDirection;
|
||
}) {
|
||
const { condition, view, stackIndex } = card;
|
||
const stackStyle = getTickerStackStyle(stackIndex);
|
||
const contentKey = view.currentStep
|
||
? `${condition.id}-${view.currentStep.id}`
|
||
: `${condition.id}-pending`;
|
||
|
||
return (
|
||
<motion.article
|
||
layout="position"
|
||
className={cn(
|
||
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
|
||
isActive ? "glass-transient z-30" : "acrylic-control pointer-events-none z-10 border"
|
||
)}
|
||
style={{ transformOrigin: "center top" }}
|
||
custom={transitionDirection}
|
||
variants={tickerCardVariants}
|
||
initial="initial"
|
||
animate={{
|
||
opacity: stackStyle.opacity,
|
||
x: stackStyle.x,
|
||
y: stackStyle.y,
|
||
rotate: stackStyle.rotate,
|
||
transition: {
|
||
duration: isActive ? 0.34 : 0.3,
|
||
ease: tickerEase
|
||
}
|
||
}}
|
||
exit="exit"
|
||
aria-hidden={!isActive}
|
||
data-condition-id={condition.id}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"h-full",
|
||
isActive ? "opacity-100" : "opacity-0"
|
||
)}
|
||
>
|
||
<AnimatePresence key={condition.id} initial={false} mode="wait">
|
||
<motion.div
|
||
key={contentKey}
|
||
className="h-full overflow-hidden"
|
||
variants={tickerContentVariants}
|
||
initial={animateContentChanges ? "initial" : false}
|
||
animate={animateContentChanges ? "animate" : undefined}
|
||
exit={animateContentChanges ? "exit" : undefined}
|
||
>
|
||
<TickerTaskCardContent view={view} />
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
</motion.article>
|
||
);
|
||
}
|
||
|
||
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
|
||
if (!view.currentStep) {
|
||
return (
|
||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
|
||
<TickerStatusDot progressValue={view.progressValue} />
|
||
<div className="min-w-0">
|
||
<span className="block truncate text-[13px] font-semibold leading-5 text-[var(--ink-muted)]">
|
||
{view.title}
|
||
</span>
|
||
{view.progressValue !== undefined ? (
|
||
<TickerProgressBar progressValue={view.progressValue} muted />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
|
||
<TickerStatusDot progressValue={view.progressValue} />
|
||
<div className="flex min-w-0 flex-col justify-center">
|
||
<div className="min-w-0 pr-1">
|
||
<span className="block truncate text-[13px] font-semibold leading-4 text-[var(--ink-muted)]">
|
||
{view.title}
|
||
</span>
|
||
</div>
|
||
<div className="min-w-0" title={`${view.currentStep.title}:${view.currentStep.detail}`}>
|
||
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
|
||
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
|
||
{view.currentStep.title}
|
||
</span>
|
||
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-[var(--ink-muted)]">
|
||
{view.currentStep.detail}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{view.progressValue !== undefined ? (
|
||
<TickerProgressBar progressValue={view.progressValue} />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TickerProgressBar({
|
||
progressValue,
|
||
muted = false
|
||
}: {
|
||
progressValue: number;
|
||
muted?: boolean;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"mt-0.5 h-0.5 overflow-hidden rounded-full",
|
||
muted ? "bg-[var(--surface-divider)]/60" : "bg-[var(--surface-divider)]/80"
|
||
)}
|
||
aria-hidden="true"
|
||
>
|
||
<motion.span
|
||
className={cn(
|
||
"status-tone-info block h-full rounded-full",
|
||
muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]"
|
||
)}
|
||
animate={{ width: `${Math.min(100, Math.max(0, progressValue))}%` }}
|
||
initial={false}
|
||
transition={{ duration: 0.34, ease: tickerEase }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||
const dashOffset = 44 - (44 * progress) / 100;
|
||
return (
|
||
<span className="status-tone-info grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-[var(--status-border)]" />
|
||
<motion.circle
|
||
cx="8"
|
||
cy="8"
|
||
r="7"
|
||
fill="none"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
className="stroke-[var(--status-foreground)]"
|
||
strokeDasharray="44"
|
||
animate={{ strokeDashoffset: dashOffset }}
|
||
initial={false}
|
||
transition={{ duration: 0.34, ease: tickerEase }}
|
||
/>
|
||
<circle cx="8" cy="8" r="3.5" className="fill-[var(--status-mark)]" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function getTickerStackStyle(stackIndex: number) {
|
||
if (stackIndex === 1) {
|
||
return {
|
||
opacity: 1,
|
||
x: 0,
|
||
y: 7,
|
||
rotate: 0
|
||
};
|
||
}
|
||
|
||
if (stackIndex === 2) {
|
||
return {
|
||
opacity: 1,
|
||
x: 0,
|
||
y: 14,
|
||
rotate: 0
|
||
};
|
||
}
|
||
|
||
return {
|
||
opacity: 1,
|
||
x: 0,
|
||
y: 0,
|
||
rotate: 0
|
||
};
|
||
}
|