Files
next-tjwater-frontend/src/features/workbench/components/agent-task-ticker.tsx
T
jiang 85a9a3ab45 fix(ui): refine acrylic controls and timeline interactions
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.
2026-10-09 18:11:33 +08:00

513 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}