fix(floor-nav): show only available floors per active adapter; block hang on unconfigured runtime

- floors.ts: add paperclip to FloorProvider; add listAvailableFloorsForAdapter() —
  lobby always visible, runtime floors only shown when their provider matches the
  active adapter, so demo-only users only see Lobby
- OfficeFloorNav: accept activeAdapterType prop, filter floor list via
  listAvailableFloorsForAdapter(); fall back displayActiveFloorId to lobby if current
  floor is no longer in the available set
- OfficeScreen: pass selectedAdapterType to OfficeFloorNav; add guard in
  handleSelectFloor — bail back to lobby immediately when a runtime floor has no
  gateway URL configured, preventing the connect-hang limbo state

Authored-By: GSKNNFT
This commit is contained in:
gsknnft
2026-04-16 19:02:01 -04:00
parent 13137c01db
commit 65993dda8e
3 changed files with 57 additions and 7 deletions
@@ -1,9 +1,10 @@
import {
OFFICE_FLOORS,
getAdjacentEnabledOfficeFloorId,
listOfficeFloorsForZone,
listAvailableFloorsForAdapter,
type FloorDefinition,
type FloorId,
type FloorProvider,
} from "@/lib/office/floors";
import type { FloorRosterState } from "@/lib/office/floorRoster";
@@ -11,12 +12,15 @@ type OfficeFloorNavProps = {
activeFloorId: FloorId;
floorRosterCache: Record<FloorId, FloorRosterState>;
onSelectFloor: (floorId: FloorId) => void;
/** The currently selected adapter — controls which runtime floors are shown */
activeAdapterType?: FloorProvider | null;
};
const PROVIDER_LABEL: Record<FloorDefinition["provider"], string> = {
const PROVIDER_LABEL: Record<FloorProvider, string> = {
demo: "Demo",
openclaw: "OpenClaw",
hermes: "Hermes",
paperclip: "Paperclip",
custom: "Custom",
local: "Local",
claw3d: "Claw3D",
@@ -86,10 +90,18 @@ export function OfficeFloorNav({
activeFloorId,
floorRosterCache,
onSelectFloor,
activeAdapterType,
}: OfficeFloorNavProps) {
const buildingFloors = listOfficeFloorsForZone("building");
const outsideFloors = listOfficeFloorsForZone("outside");
const activeFloor = OFFICE_FLOORS.find((floor) => floor.id === activeFloorId) ?? OFFICE_FLOORS[0];
const availableFloors = listAvailableFloorsForAdapter(activeAdapterType ?? null);
const buildingFloors = availableFloors.filter((f) => f.zone === "building");
const outsideFloors = availableFloors.filter((f) => f.zone === "outside");
// Active floor — fall back to lobby if current floor is no longer available
const activeIsAvailable = availableFloors.some((f) => f.id === activeFloorId);
const displayActiveFloorId = activeIsAvailable ? activeFloorId : "lobby";
const activeFloor =
OFFICE_FLOORS.find((floor) => floor.id === displayActiveFloorId) ?? OFFICE_FLOORS[0];
const activeRoster = floorRosterCache[activeFloor.id];
return (
@@ -121,7 +133,7 @@ export function OfficeFloorNav({
Building
</div>
{buildingFloors.map((floor) =>
renderFloorButton({ floor, activeFloorId, floorRosterCache, onSelectFloor }),
renderFloorButton({ floor, activeFloorId: displayActiveFloorId, floorRosterCache, onSelectFloor }),
)}
</div>
{outsideFloors.length > 0 ? (
@@ -130,7 +142,7 @@ export function OfficeFloorNav({
Outside
</div>
{outsideFloors.map((floor) =>
renderFloorButton({ floor, activeFloorId, floorRosterCache, onSelectFloor }),
renderFloorButton({ floor, activeFloorId: displayActiveFloorId, floorRosterCache, onSelectFloor }),
)}
</div>
) : null}
@@ -80,6 +80,7 @@ import {
listOfficeFloorsForProvider,
resolveActiveOfficeFloorId,
type FloorId,
type FloorProvider,
} from "@/lib/office/floors";
import {
AgentEditorModal,
@@ -1481,6 +1482,15 @@ export function OfficeScreen({
console.error("Failed to resolve floor runtime profile.", error);
}
// Guard: if this is a runtime floor and there's no gateway URL to connect to,
// bail back to lobby rather than entering a connect-hang limbo state.
if (floor.kind === "runtime" && !nextGatewayUrl.trim()) {
setActiveFloorId("lobby");
settingsCoordinator.schedulePatch({ activeFloorId: "lobby" }, 0);
setAgentsLoaded(true);
return;
}
setSelectedAdapterType(adapterType);
setGatewayUrl(nextGatewayUrl);
setToken(nextToken);
@@ -4741,6 +4751,7 @@ export function OfficeScreen({
onSelectFloor={(floorId) => {
void handleSelectFloor(floorId);
}}
activeAdapterType={(selectedAdapterType as FloorProvider) ?? null}
/>
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
<RetroOffice3D
+27
View File
@@ -1,6 +1,7 @@
export type FloorProvider =
| "openclaw"
| "hermes"
| "paperclip"
| "custom"
| "demo"
| "local"
@@ -162,6 +163,32 @@ export const resolveActiveOfficeFloorId = (floorId: FloorId | null | undefined):
return listEnabledOfficeFloors()[0]?.id ?? DEFAULT_ACTIVE_FLOOR_ID;
};
/**
* Floors visible in the nav for a given active adapter.
* - Lobby (kind="lobby") always shown.
* - Runtime floors shown only when their provider matches the active adapter.
* - Non-runtime enabled floors (training, market, campus) always shown.
*
* When activeAdapterType is null/undefined/"demo", only lobby + non-runtime floors appear.
*/
export const listAvailableFloorsForAdapter = (
activeAdapterType: FloorProvider | "demo" | null | undefined,
): FloorDefinition[] => {
return OFFICE_FLOORS.filter((floor) => {
if (!floor.enabled) return false;
if (floor.kind === "lobby") return true;
if (floor.kind === "runtime") {
return (
Boolean(activeAdapterType) &&
activeAdapterType !== "demo" &&
floor.provider === activeAdapterType
);
}
// training / market / campus
return true;
});
};
export const getAdjacentEnabledOfficeFloorId = (
floorId: FloorId,
direction: 1 | -1,