// Rover Queues Panel
// Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js';
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
import { ExternalInstancesCompact, InterInstancePopup } from '../InterInstancePanel/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import { useSettingsNamespace } from '../../settings/index.js';
function formatBattery(rover) {
const percent = rover?.batteryState?.percentDisplay;
if (percent == null) return '--';
return `${percent}%`;
}
function batteryClass(rover) {
if (!rover?.batteryState) return 'text-slate-400';
if (rover.batteryState.urgentActive) return 'text-red-400';
if (rover.batteryState.warnActive) return 'text-amber-300';
return 'text-emerald-300';
}
function ScrollableQueueContent({ enabled = false, children }) {
const viewportRef = useRef(null);
const contentRef = useRef(null);
const [canScrollDown, setCanScrollDown] = useState(false);
const measureScrollRemainder = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
/*
Fractional layout measurements can leave a sub-pixel remainder even at
the bottom. The tolerance keeps the cue from flickering there while still
showing it for any meaningful hidden queue content.
*/
const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
setCanScrollDown(remaining > 2);
}, []);
useEffect(() => {
if (!enabled) return undefined;
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return undefined;
/*
Queue membership, user chips, and remote instances all update from live
session state and may change the content height without resizing the
window. Observing both boxes keeps the overflow cue accurate without
using JavaScript to calculate or assign the panel's actual height.
*/
const observer = new ResizeObserver(measureScrollRemainder);
observer.observe(viewport);
observer.observe(content);
const animationFrame = window.requestAnimationFrame(measureScrollRemainder);
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [enabled, measureScrollRemainder]);
if (!enabled) return children;
return (
{children}
{canScrollDown ? (
/*
This indicator is deliberately removed from layout so it consumes no
permanent panel height. It also adds no padding to the scroll content,
keeping scrollHeight stable when the indicator disappears at the
bottom. The explicit stacking level and opaque background keep queue
cards from painting through or over the message.
*/
Scroll for more ↓
) : null}
);
}
export default function RoverQueuesPanel({
title = 'Rovers',
roster: rosterOverride = null,
externalInstance = null,
disabledOverlay = '',
fillHeight = false,
}) {
const role = useSessionSelector((state) => state.session?.role || null);
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const assignedRoverName = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return '';
const rover = (state.session?.roster || []).find((entry) => String(entry?.id) === roverId);
return rover?.name || roverId;
});
const { requestControl, rebootOwnRover } = useSessionActions();
const [pending, setPending] = useState({});
const [rebootPending, setRebootPending] = useState(false);
const [interInstancePopupOpen, setInterInstancePopupOpen] = useState(false);
const externalMode = Boolean(externalInstance);
const externalBlocked = Boolean(externalMode && disabledOverlay);
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
const canRequest = useMemo(
() => canLeaveRover && (externalMode ? !externalBlocked : role && role !== 'spectator'),
[canLeaveRover, externalBlocked, externalMode, role],
);
const adminCapable = useMemo(
() => role === 'admin' || role === 'lockdown',
[role],
);
const hasDeadlines = useMemo(
() => roster.some((rover) => rover.turn?.deadline || rover.turn?.idleDeadline),
[roster],
);
/*
Queue timers are shown in whole seconds, and several queue panels can be
mounted across desktop/mobile/spectator layouts. Sharing the one-second
clock keeps those labels in sync while using a single interval globally.
*/
const now = useSharedClock(1000, hasDeadlines);
async function handleRequest(targetRoverId) {
if (!targetRoverId) return;
if (!canRequest) return;
if (externalMode) {
/*
External queue cards deliberately reuse the local row layout, but their
action cannot go through this Socket.IO server. The row opens the remote
instance and follows the saved Page setting for cookie/settings transfer
instead of interrupting each click with a confirmation popup.
*/
openExternalRover(externalInstance, targetRoverId, { includeSettings: includeInterInstanceSettings });
return;
}
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
trackAnalyticsEvent('rover_selected', {
roverId: targetRoverId,
alreadySelected: assignedRoverId === String(targetRoverId),
});
try {
await requestControl(targetRoverId);
} catch (err) {
alert(err.message);
} finally {
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
}
}
async function handleRebootOwnRover() {
if (rebootPending) return;
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
if (!ok) return;
setRebootPending(true);
try {
await rebootOwnRover();
alert('Reboot command sent.');
} catch (err) {
alert(err.message);
} finally {
setRebootPending(false);
}
}
const rebootAction =
role !== 'spectator' && assignedRoverId ? (
) : null;
const headerActions = !externalMode ? rebootAction : null;
return (
<>
{roster.length === 0 ? (
No rovers registered.
) : (
{roster.map((rover) => {
const roverId = String(rover.id);
const info = rover.turn;
/*
A turn handoff and an inactivity skip are separate server
deadlines. Keep them separate through formatting so an idle
warning cannot masquerade as the length of the whole turn or
jump from seven seconds back to the turn countdown.
*/
const turnDeadline = info?.deadline || null;
const idleDeadline = info?.idleDeadline || null;
const turnRemainingSeconds = turnDeadline
? Math.max(0, Math.ceil((turnDeadline - now) / 1000))
: null;
const idleRemainingSeconds = idleDeadline
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
: null;
const currentId = info?.currentId || null;
const nextId = info?.nextId || null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const timerLabel = isSelfCurrent
? [
turnRemainingSeconds != null ? `${turnRemainingSeconds}s left` : '',
idleRemainingSeconds != null ? `Idle skip in ${idleRemainingSeconds}s` : '',
].filter(Boolean).join(' · ')
: isSelfNext && turnRemainingSeconds != null
? `Your turn in ${turnRemainingSeconds}s`
: '';
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
const locked = Boolean(rover.locked);
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
const buttonLabel = pending[roverId]
? '...'
: lockedBlocked
? lockLabel
: externalMode
? 'Open'
: 'request';
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
return (
);
})}
{interInstancePopupOpen ? (
/*
The popup remains owned by the local Rover Queues panel because its
title-bar action opens it. External queue panels never render that
action, which prevents recursively opening browsers from remote rows.
*/
setInterInstancePopupOpen(false)} />
) : null}
>
);
}