interinstance ui improvements

This commit is contained in:
legop3
2026-07-18 00:09:51 -04:00
parent b86eec88f8
commit cd8f8816c9
10 changed files with 342 additions and 330 deletions
+143 -87
View File
@@ -1,7 +1,7 @@
// 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 { useMemo, useState } from 'react';
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';
@@ -25,6 +25,74 @@ function batteryClass(rover) {
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 (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={viewportRef}
className="min-h-0 flex-1 overflow-y-auto"
onScroll={measureScrollRemainder}
>
<div ref={contentRef}>{children}</div>
</div>
{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.
*/
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-neutral-950 px-1 py-0.5 text-center text-xs font-semibold text-slate-200">
Scroll for more
</div>
) : null}
</div>
);
}
export default function RoverQueuesPanel({
title = 'Rovers',
roster: rosterOverride = null,
@@ -39,9 +107,6 @@ export default function RoverQueuesPanel({
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const hasRemoteInstances = useSessionSelector(
(state) => (state.session?.interInstances?.instances?.length ?? 0) > 0,
);
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());
@@ -164,20 +229,7 @@ export default function RoverQueuesPanel({
</button>
) : null;
const headerActions = !externalMode ? (
<>
{interInstanceEnabled && hasRemoteInstances ? (
<button
type="button"
className="button-dark"
onClick={() => setInterInstancePopupOpen(true)}
>
Browse servers
</button>
) : null}
{rebootAction}
</>
) : null;
const headerActions = !externalMode ? rebootAction : null;
return (
<>
@@ -187,75 +239,79 @@ export default function RoverQueuesPanel({
fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-sm"
>
<div className={fillHeight ? 'relative flex min-h-0 flex-1 flex-col gap-0.5' : 'relative space-y-0.5'}>
{rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
<ul className="space-y-0.5 text-sm">
{rosterItems.map((rover) => {
const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null;
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remainingSeconds =
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
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 (
<QueueTargetRow
key={rover.id}
target={{ ...rover, rover, roverId, id: roverId }}
queue={queue}
currentId={currentId}
nextId={nextId}
selfId={selfId}
lookupUser={lookupUser}
canClick={canClickRow}
pending={Boolean(pending[roverId])}
locked={locked}
lockedBlocked={lockedBlocked}
privateOpen={isPrivateOpen}
buttonLabel={buttonLabel}
batteryLabel={formatBattery(rover)}
batteryClassName={batteryClass(rover)}
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
onRequest={handleRequest}
showAction={Boolean(canRequest)}
/>
);
})}
</ul>
)}
{externalBlocked ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
{disabledOverlay}
</div>
) : null}
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null}
</div>
<ScrollableQueueContent enabled={fillHeight}>
<div className="relative space-y-0.5">
{rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
<ul className="space-y-0.5 text-sm">
{rosterItems.map((rover) => {
const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null;
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remainingSeconds =
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
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 (
<QueueTargetRow
key={rover.id}
target={{ ...rover, rover, roverId, id: roverId }}
queue={queue}
currentId={currentId}
nextId={nextId}
selfId={selfId}
lookupUser={lookupUser}
canClick={canClickRow}
pending={Boolean(pending[roverId])}
locked={locked}
lockedBlocked={lockedBlocked}
privateOpen={isPrivateOpen}
buttonLabel={buttonLabel}
batteryLabel={formatBattery(rover)}
batteryClassName={batteryClass(rover)}
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
onRequest={handleRequest}
showAction={Boolean(canRequest)}
/>
);
})}
</ul>
)}
{externalBlocked ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
{disabledOverlay}
</div>
) : null}
{!externalMode && interInstanceEnabled ? (
<ExternalInstancesCompact onBrowse={() => setInterInstancePopupOpen(true)} />
) : null}
</div>
</ScrollableQueueContent>
</CardFrame>
{interInstancePopupOpen ? (
/*