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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CVzYrQ9e.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHMHSpML.css">
<script type="module" crossorigin src="/assets/index-knJk41EY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwjDTdpq.css">
</head>
<body>
<div id="root"></div>
@@ -1,7 +1,7 @@
// Inter Instance Panel
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
@@ -133,100 +133,52 @@ function RemoteMediaStrip({ remote }) {
);
}
function ScrollableInstanceList({ children }) {
const viewportRef = useRef(null);
const contentRef = useRef(null);
const [canScrollDown, setCanScrollDown] = useState(false);
const measureScrollRemainder = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
/*
A small tolerance prevents fractional browser measurements from leaving
the cue visible when the user is effectively at the bottom. Comparing the
live viewport and content dimensions also means the cue only appears when
there is genuinely hidden content, rather than merely because several
instances happen to exist.
*/
const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
setCanScrollDown(remaining > 2);
}, []);
useEffect(() => {
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return undefined;
/*
Remote rosters and queues can change height without a window resize. A
ResizeObserver on both the viewport and its inner content keeps the cue
accurate for those live session updates while avoiding polling timers.
*/
const observer = new ResizeObserver(measureScrollRemainder);
observer.observe(viewport);
observer.observe(content);
const animationFrame = window.requestAnimationFrame(measureScrollRemainder);
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [measureScrollRemainder]);
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} className={classNames('space-y-0.5', canScrollDown && 'pb-6')}>
{children}
</div>
</div>
{canScrollDown ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-neutral-950 via-neutral-950/90 to-transparent px-1 pb-0.5 pt-5 text-center text-xs font-semibold text-slate-200">
Scroll for more
</div>
) : null}
</div>
);
}
export function ExternalInstancesCompact() {
export function ExternalInstancesCompact({ onBrowse = null }) {
const enabled = useInterInstanceEnabled();
const instances = useRemoteInstances();
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
if (!enabled) return null;
if (!visible.length) return null;
const browseAction = onBrowse ? (
<button type="button" className="button-dark" onClick={onBrowse}>
Browse Servers
</button>
) : null;
return (
/*
External instances are intentionally always mounted. Besides removing an
unnecessary disclosure click, this preserves the live queue rows while
the local Rover Queues card uses this region as its remaining-height
scroller. The viewport cap remains a safety boundary in layouts whose
parent has natural height instead of a fixed desktop row height.
the local Rover Queues card can provide one continuous scroll surface for
both its local and external rows. Scrolling belongs to that owning panel,
so this nested section deliberately keeps its natural content height.
*/
<div className="flex min-h-0 max-h-[min(60vh,36rem)] flex-1 flex-col border-t border-neutral-600/60 pt-0.5">
<ScrollableInstanceList>
{visible.map((remote) =>
remote.online ? (
<RoverQueuesPanel
key={remote.url}
title={remote.instance?.name || remote.url}
roster={remote.roster}
turnQueues={remote.turnQueues}
users={remote.users}
externalInstance={remote}
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
/>
) : (
<InstancePanel key={remote.url} remote={remote} />
),
)}
</ScrollableInstanceList>
</div>
<CardFrame
title="External servers"
actions={browseAction}
bodyClassName="space-y-0.5 text-sm"
>
{/*
One containing card gives the remote-server collection a clear boundary
below the local rover rows. Individual remote queue cards stay intact
inside it because they still own each server's title and operational
status, while this outer title bar owns the collection-wide browser.
*/}
{visible.map((remote) =>
remote.online ? (
<RoverQueuesPanel
key={remote.url}
title={remote.instance?.name || remote.url}
roster={remote.roster}
turnQueues={remote.turnQueues}
users={remote.users}
externalInstance={remote}
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
/>
) : (
<InstancePanel key={remote.url} remote={remote} />
),
)}
</CardFrame>
);
}
+10 -6
View File
@@ -196,14 +196,18 @@ function QueueReplayLinksRow() {
removes that item instead of preserving an empty grid column.
*/
return (
<div className={`flex ${themeGapClass}`}>
<div className="min-w-0 basis-0 grow-[1]">
<div className={`flex items-stretch ${themeGapClass}`}>
<div className="relative min-w-0 basis-0 grow-[1]">
{/*
The queue card stretches to the desktop row height so its always-open
external-instance region receives the same vertical budget as the
neighboring replay card and can scroll within that space.
The absolutely positioned queue card is removed from flex cross-size
calculation. Replay and the links/PTZ stack therefore define the row
height entirely through normal CSS layout; this relative column then
stretches to that established height and gives the queue card an exact
containing block to fill without any JavaScript measurement.
*/}
<RoverQueuesPanel fillHeight />
<div className="absolute inset-0 min-h-0">
<RoverQueuesPanel fillHeight />
</div>
</div>
<div className="min-w-0 basis-0 grow-[0.9]">
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
+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 ? (
/*