mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 11:12:32 +00:00
moved floor nav
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user