// New Generation Docking HUD
// Purpose: Provides a single low-friction transition between docked, driving, and manual docking.
// Scope: Owns presentation and the existing manual-assist lifecycle for the current driver HUD;
// the archived desktop layout retains its previous DriveDockAction behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaChargingStation } from 'react-icons/fa';
import { useControlActions } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
import usePodVisibility from '../CornerPods/usePodVisibility.js';
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
const [hidden, setHidden] = useState(false);
const waitingForTurn = controlsDisabled && !pending;
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts
this component when the rover leaves the base, and its roverId key remounts
it for a different assignment, so no persistence or reset effect is needed. */
if (hidden && !pending) return null;
const mainToneClass = waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75';
const hideToneClass = waitingForTurn
? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200';
return (
<>
{/* The docked shield is owned by the dismissible action so hiding the
prompt also reveals the video and ordinary HUD instead of leaving an
unexplained dark, input-blocking layer behind. */}
);
}
function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLabel, onDock, onCancel, cornerOffsetClass, open, onOpenChange, batterySeverity }) {
const batteryUrgent = batterySeverity === 'urgent';
const batteryLow = batterySeverity === 'low';
// When the battery pod is collapsed its triangular reopen control owns the
// outermost forty pixels. Docking uses the next top-edge slot in both open
// and collapsed states so the two independent controls never overlap.
const dockPositionClass = cornerOffsetClass === 'right-0' ? 'right-10' : cornerOffsetClass;
if (active) {
return (
{/* Once assist is active, the camera image is the user's task context. Centering this
one-line instruction connects it to that view instead of leaving guidance beside the
corner button that already completed its action. */}
Dock assist is active, drive forward onto the dock.
{error ? {error} : null}
);
}
return (
{error ?
{error}
: null}
);
}
function UndockTransitionGhost({ onFinish }) {
const ghostRef = useRef(null);
useEffect(() => {
const element = ghostRef.current;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!element || reducedMotion || typeof element.animate !== 'function') {
onFinish();
return undefined;
}
/*
This intentionally animates a disposable visual copy instead of trying to morph the
centered action into the differently structured corner control. Moving left/top while
scaling and fading is inexpensive, communicates where Dock moved, and needs no viewport
measurements or persistent layout state.
*/
const animation = element.animate(
[
{
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%) scale(1)',
opacity: 1,
},
{
left: 'calc(100% - 10rem)',
top: '0',
transform: 'translate(0, 0) scale(0.2)',
opacity: 0,
},
],
{
duration: 850,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
},
);
animation.onfinish = onFinish;
return () => {
animation.onfinish = null;
animation.cancel();
};
}, [onFinish]);
return (
Rover dockedDock control moved here
);
}
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const actions = useControlActions();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
const dockAssist = useManualDockAssist({ manageLifecycle: true });
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
// DockingHud already owns the canonical desktop docking action, so battery urgency only
// changes that action's emphasis and wording instead of introducing a competing button.
const batterySeverity = batteryState?.urgentActive ? 'urgent' : batteryState?.warnActive ? 'low' : null;
const { value: podSettings } = useSettingsNamespace('newdrivePods', {});
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
// The action name is presentation state as well as busy state. Keeping the
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
const [pendingAction, setPendingAction] = useState(null);
const [error, setError] = useState('');
const [showUndockTransition, setShowUndockTransition] = useState(false);
const docked = Boolean(dockTelemetry?.homeBase);
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
// The established UI contract treats exactly passive + undocked as the Roomba's
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
const pending = pendingAction !== null;
/* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a
keyboard shortcut that is irrelevant on that layout. */
const driveKeyLabel = layout === 'desktop' ? : '';
const dockKeyLabel = layout === 'desktop' ? : '';
const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
const cornerOffsetClass = batteryPodOpen ? 'right-[8.5rem]' : 'right-0';
const previousDockedRef = useRef(docked);
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
useEffect(() => {
const wasDocked = previousDockedRef.current;
// Only a real live transition plays the cue. An already-undocked rover must not animate
// merely because the user opened or refreshed the page.
if (wasDocked && !docked) {
setShowUndockTransition(true);
} else if (docked) {
setShowUndockTransition(false);
}
previousDockedRef.current = docked;
}, [docked]);
const startDriving = async (action) => {
if (!roverId || pending || !canControl) return;
setPendingAction(action);
setError('');
try {
// The established drive sequence is the canonical undock path: it restores the camera,
// enters full Open Interface mode, and performs the short physical back-away from the dock.
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start driving. Please try again.');
} finally {
setPendingAction(null);
}
};
const startUndocking = () => {
startDriving('undocking');
};
const resumeDriving = () => {
startDriving('resuming');
};
const startDocking = () => {
if (!roverId || pending || !canControl) return;
setError('');
try {
// Enter on the first click. The explanation appears as the resulting active state instead
// of forcing the user through a modal and a second confirmation action.
dockAssist.enterAssist();
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start dock assist. Please try again.');
}
};
return (
<>
{/* Automatic docking keeps its own lighter blocking shield. The ordinary
docked shield lives inside DockedAction because the new Hide control
must dismiss the prompt and its dimming as one coherent surface. */}
{docked || pendingAction === 'undocking' ? (
) : autoDocking || pendingAction === 'resuming' ? (
) : (
<>
{dockAssist.active ? (
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
temporary mode without adding another instruction surface over the video. */
) : null}
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
its dedicated control column, but once active it still mounts this component
so the centered camera instruction and cancel action remain available. */}
{layout === 'desktop' || dockAssist.active ? (
) : null}
{showUndockTransition ? : null}
>
)}
>
);
}