moved floor nav

This commit is contained in:
gsknnft
2026-04-03 23:49:01 -04:00
parent 7db17e39fa
commit 7fa2119661
4 changed files with 201 additions and 57 deletions
@@ -0,0 +1,151 @@
import {
OFFICE_FLOORS,
getAdjacentEnabledOfficeFloorId,
listOfficeFloorsForZone,
type FloorDefinition,
type FloorId,
} from "@/lib/office/floors";
import type { FloorRosterState } from "@/lib/office/floorRoster";
type OfficeFloorNavProps = {
activeFloorId: FloorId;
floorRosterCache: Record<FloorId, FloorRosterState>;
onSelectFloor: (floorId: FloorId) => void;
};
const PROVIDER_LABEL: Record<FloorDefinition["provider"], string> = {
demo: "Demo",
openclaw: "OpenClaw",
hermes: "Hermes",
custom: "Custom",
};
const renderFloorButton = (params: {
floor: FloorDefinition;
activeFloorId: FloorId;
floorRosterCache: Record<FloorId, FloorRosterState>;
onSelectFloor: (floorId: FloorId) => void;
}) => {
const { floor, activeFloorId, floorRosterCache, onSelectFloor } = params;
const active = floor.id === activeFloorId;
const rosterState = floorRosterCache[floor.id];
const rosterCount = rosterState?.entries.length ?? 0;
const rosterStatus = rosterState?.status ?? "idle";
return (
<button
key={floor.id}
type="button"
onClick={() => onSelectFloor(floor.id)}
disabled={!floor.enabled}
className={[
"w-full rounded-xl border px-3 py-2 text-left transition-colors",
active
? "border-cyan-400/45 bg-cyan-950/40 shadow-[0_0_0_1px_rgba(34,211,238,0.16)]"
: "border-white/10 bg-black/45 hover:border-white/25 hover:bg-black/55",
floor.enabled ? "cursor-pointer" : "cursor-not-allowed opacity-45",
].join(" ")}
aria-pressed={active}
aria-label={`Select ${floor.label}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/45">
{floor.zone === "outside" ? "Destination" : "Floor"}
</div>
<div className="truncate text-sm font-semibold text-white">{floor.label}</div>
</div>
<span
className={[
"shrink-0 rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em]",
active
? "border-cyan-400/30 bg-cyan-950/35 text-cyan-100/85"
: "border-white/10 bg-white/5 text-white/55",
].join(" ")}
>
{PROVIDER_LABEL[floor.provider]}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2 font-mono text-[10px] text-white/45">
<span>{floor.shortLabel}</span>
{floor.enabled ? (
<span>
roster {rosterCount} | {rosterStatus}
</span>
) : (
<span>Locked</span>
)}
</div>
</button>
);
};
export function OfficeFloorNav({
activeFloorId,
floorRosterCache,
onSelectFloor,
}: OfficeFloorNavProps) {
const buildingFloors = listOfficeFloorsForZone("building");
const outsideFloors = listOfficeFloorsForZone("outside");
const activeFloor = OFFICE_FLOORS.find((floor) => floor.id === activeFloorId) ?? OFFICE_FLOORS[0];
const activeRoster = floorRosterCache[activeFloor.id];
return (
<aside className="pointer-events-none fixed left-4 top-24 z-40 flex w-[240px] max-w-[calc(100vw-2rem)] flex-col gap-3">
<section className="pointer-events-auto rounded-2xl border border-amber-400/20 bg-black/78 p-3 shadow-2xl backdrop-blur">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-amber-200/70">
Building Directory
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
className="rounded border border-amber-500/20 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-100/80 transition-colors hover:border-amber-400/45 hover:text-amber-50"
onClick={() => onSelectFloor(getAdjacentEnabledOfficeFloorId(activeFloor.id, -1))}
aria-label="Switch to previous enabled floor"
>
Prev
</button>
<button
type="button"
className="rounded border border-amber-500/20 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-100/80 transition-colors hover:border-amber-400/45 hover:text-amber-50"
onClick={() => onSelectFloor(getAdjacentEnabledOfficeFloorId(activeFloor.id, 1))}
aria-label="Switch to next enabled floor"
>
Next
</button>
</div>
<div className="mt-3 flex flex-col gap-2">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Building
</div>
{buildingFloors.map((floor) =>
renderFloorButton({ floor, activeFloorId, floorRosterCache, onSelectFloor }),
)}
</div>
{outsideFloors.length > 0 ? (
<div className="mt-4 flex flex-col gap-2">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Outside
</div>
{outsideFloors.map((floor) =>
renderFloorButton({ floor, activeFloorId, floorRosterCache, onSelectFloor }),
)}
</div>
) : null}
</section>
<section className="pointer-events-auto rounded-2xl border border-white/10 bg-black/68 px-3 py-2 shadow-xl backdrop-blur">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/45">
Current Floor
</div>
<div className="mt-1 text-sm font-semibold text-white">{activeFloor.label}</div>
<div className="mt-1 flex items-center justify-between gap-2 font-mono text-[10px] uppercase tracking-[0.16em] text-white/45">
<span>{PROVIDER_LABEL[activeFloor.provider]}</span>
<span>
roster {activeRoster?.entries.length ?? 0} | {activeRoster?.status ?? "idle"}
</span>
</div>
</section>
</aside>
);
}
+6 -57
View File
@@ -143,6 +143,7 @@ import { KanbanDisabledPanel } from "@/features/office/components/panels/KanbanD
import { PlaybooksPanel } from "@/features/office/components/panels/PlaybooksPanel";
import { SkillsMarketplaceModal } from "@/features/office/components/panels/SkillsMarketplaceModal";
import { TaskBoardPanel } from "@/features/office/components/panels/TaskBoardPanel";
import { OfficeFloorNav } from "@/features/office/components/OfficeFloorNav";
import { JukeboxPanel } from "@/features/spotify-jukebox/components/JukeboxPanel";
import { JukeboxDisabledPanel } from "@/features/spotify-jukebox/components/JukeboxDisabledPanel";
import { executeBrowserJukeboxCommand } from "@/features/spotify-jukebox/agentBridge";
@@ -183,12 +184,9 @@ import {
buildFloorRosterErrorState,
buildFloorRosterState,
createFloorRosterCache,
defaultFloorRosterState,
} from "@/lib/office/floorRoster";
import {
getAdjacentEnabledOfficeFloorId,
getOfficeFloor,
listEnabledOfficeFloors,
resolveActiveOfficeFloorId,
type FloorId,
} from "@/lib/office/floors";
@@ -1036,13 +1034,10 @@ export function OfficeScreen({
const { showOnboarding, completeOnboarding, resetOnboarding } =
useOnboardingState();
const [forceShowOnboarding, setForceShowOnboarding] = useState(false);
const enabledFloors = useMemo(() => listEnabledOfficeFloors(), []);
const activeFloor = useMemo(
() => getOfficeFloor(resolveActiveOfficeFloorId(activeFloorId)),
[activeFloorId],
);
const activeFloorRoster =
floorRosterCache[activeFloor.id] ?? defaultFloorRosterState(activeFloor.id);
useEffect(() => {
initJukeboxStore();
}, [initJukeboxStore]);
@@ -4619,57 +4614,11 @@ export function OfficeScreen({
</div>
) : null}
<div className="pointer-events-none fixed left-1/2 top-4 z-40 -translate-x-1/2 px-4">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-400/25 bg-black/82 px-3 py-2 shadow-2xl backdrop-blur">
<button
type="button"
className="rounded border border-amber-500/20 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-100/80 transition-colors hover:border-amber-400/45 hover:text-amber-50"
onClick={() =>
handleSelectFloor(getAdjacentEnabledOfficeFloorId(activeFloor.id, -1))
}
aria-label="Switch to previous floor"
>
Prev
</button>
<div className="min-w-[220px]">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-amber-200/70">
Active floor
</div>
<div className="mt-1 flex items-center gap-2">
<select
value={activeFloor.id}
onChange={(event) =>
handleSelectFloor(event.target.value as FloorId)
}
className="rounded border border-amber-500/20 bg-[#120d06]/95 px-2 py-1 font-mono text-[11px] text-amber-50 focus:border-amber-400/45 focus:outline-none"
aria-label="Select office floor"
>
{enabledFloors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.label}
</option>
))}
</select>
<span className="rounded border border-cyan-500/20 bg-cyan-950/30 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-100/75">
{activeFloor.provider}
</span>
</div>
<div className="mt-1 font-mono text-[10px] text-white/45">
roster {activeFloorRoster.entries.length} {activeFloorRoster.status}
</div>
</div>
<button
type="button"
className="rounded border border-amber-500/20 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-100/80 transition-colors hover:border-amber-400/45 hover:text-amber-50"
onClick={() =>
handleSelectFloor(getAdjacentEnabledOfficeFloorId(activeFloor.id, 1))
}
aria-label="Switch to next floor"
>
Next
</button>
</div>
</div>
<OfficeFloorNav
activeFloorId={activeFloor.id}
floorRosterCache={floorRosterCache}
onSelectFloor={handleSelectFloor}
/>
{deleteAgentStatusLine ? (
<div className="pointer-events-none fixed left-1/2 top-5 z-40 -translate-x-1/2 px-4">
+28
View File
@@ -1,4 +1,5 @@
export type FloorProvider = "openclaw" | "hermes" | "custom" | "demo";
export type FloorZone = "building" | "outside";
export type FloorId =
| "lobby"
@@ -19,9 +20,12 @@ export type FloorKind =
export type FloorDefinition = {
id: FloorId;
label: string;
shortLabel: string;
provider: FloorProvider;
kind: FloorKind;
zone: FloorZone;
enabled: boolean;
sortOrder: number;
runtimeProfileId: string | null;
};
@@ -29,57 +33,78 @@ export const OFFICE_FLOORS: readonly FloorDefinition[] = [
{
id: "lobby",
label: "Lobby",
shortLabel: "Lobby",
provider: "demo",
kind: "lobby",
zone: "building",
enabled: true,
sortOrder: 0,
runtimeProfileId: null,
},
{
id: "openclaw-ground",
label: "OpenClaw Floor",
shortLabel: "OpenClaw",
provider: "openclaw",
kind: "runtime",
zone: "building",
enabled: true,
sortOrder: 10,
runtimeProfileId: "openclaw-default",
},
{
id: "hermes-first",
label: "Hermes Floor",
shortLabel: "Hermes",
provider: "hermes",
kind: "runtime",
zone: "building",
enabled: true,
sortOrder: 20,
runtimeProfileId: "hermes-default",
},
{
id: "custom-second",
label: "Custom Floor",
shortLabel: "Custom",
provider: "custom",
kind: "runtime",
zone: "building",
enabled: true,
sortOrder: 30,
runtimeProfileId: "custom-default",
},
{
id: "training",
label: "Training Floor",
shortLabel: "Training",
provider: "demo",
kind: "training",
zone: "building",
enabled: false,
sortOrder: 40,
runtimeProfileId: null,
},
{
id: "traders-floor",
label: "Trader's Floor",
shortLabel: "Traders",
provider: "demo",
kind: "market",
zone: "building",
enabled: false,
sortOrder: 50,
runtimeProfileId: null,
},
{
id: "campus",
label: "Outside / Campus",
shortLabel: "Campus",
provider: "demo",
kind: "campus",
zone: "outside",
enabled: false,
sortOrder: 100,
runtimeProfileId: null,
},
] as const;
@@ -102,6 +127,9 @@ export const listEnabledOfficeFloors = (): FloorDefinition[] =>
export const listOfficeFloorsForProvider = (provider: FloorProvider): FloorDefinition[] =>
OFFICE_FLOORS.filter((floor) => floor.provider === provider);
export const listOfficeFloorsForZone = (zone: FloorZone): FloorDefinition[] =>
OFFICE_FLOORS.filter((floor) => floor.zone === zone);
export const resolveActiveOfficeFloorId = (floorId: FloorId | null | undefined): FloorId => {
if (floorId && FLOOR_BY_ID[floorId]?.enabled) {
return floorId;
+16
View File
@@ -6,6 +6,7 @@ import {
getAdjacentEnabledOfficeFloorId,
listEnabledOfficeFloors,
listOfficeFloorsForProvider,
listOfficeFloorsForZone,
OFFICE_FLOORS,
resolveActiveOfficeFloorId,
} from "@/lib/office/floors";
@@ -26,9 +27,12 @@ describe("office floor registry", () => {
it("looks up floors by id", () => {
expect(getOfficeFloor("hermes-first")).toMatchObject({
label: "Hermes Floor",
shortLabel: "Hermes",
provider: "hermes",
kind: "runtime",
zone: "building",
enabled: true,
sortOrder: 20,
runtimeProfileId: "hermes-default",
});
});
@@ -51,6 +55,18 @@ describe("office floor registry", () => {
]);
});
it("groups floors by zone for building navigation", () => {
expect(listOfficeFloorsForZone("building").map((floor) => floor.id)).toEqual([
"lobby",
"openclaw-ground",
"hermes-first",
"custom-second",
"training",
"traders-floor",
]);
expect(listOfficeFloorsForZone("outside").map((floor) => floor.id)).toEqual(["campus"]);
});
it("resolves active floor ids against enabled floors", () => {
expect(DEFAULT_ACTIVE_FLOOR_ID).toBe("lobby");
expect(resolveActiveOfficeFloorId("hermes-first")).toBe("hermes-first");