Add state animation mapping settings UI

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-26 03:30:35 +00:00
co-authored by Luke The Dev
parent 8c1df5e18c
commit d91aa35d22
6 changed files with 432 additions and 1 deletions
@@ -3,6 +3,8 @@
import { useState } from "react";
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
import type { OfficeStateAnimationMapping } from "@/lib/office/stateMappingConfig";
import { StateAnimationMappingsEditor } from "@/features/office/components/panels/StateAnimationMappingsEditor";
type SettingsPanelProps = {
gatewayStatus?: string;
@@ -19,6 +21,8 @@ type SettingsPanelProps = {
officeTitle: string;
officeTitleLoaded: boolean;
onOfficeTitleChange: (title: string) => void;
stateAnimationMappings: OfficeStateAnimationMapping[];
onStateAnimationMappingsChange: (mappings: OfficeStateAnimationMapping[]) => void;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
@@ -56,6 +60,8 @@ export function SettingsPanel({
officeTitle,
officeTitleLoaded,
onOfficeTitleChange,
stateAnimationMappings,
onStateAnimationMappingsChange,
remoteOfficeEnabled,
remoteOfficeSourceKind,
remoteOfficeLabel,
@@ -226,6 +232,10 @@ export function SettingsPanel({
</div>
</div>
</div>
<StateAnimationMappingsEditor
mappings={stateAnimationMappings}
onChange={onStateAnimationMappingsChange}
/>
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
@@ -0,0 +1,330 @@
"use client";
import {
OFFICE_STATE_ANIMATION_TARGETS,
OFFICE_STATE_EFFECT_IDS,
normalizeOfficeStateToken,
type OfficeStateAnimationMapping,
type OfficeStateAnimationTarget,
type OfficeStateEffectId,
} from "@/lib/office/stateMappingConfig";
type StateAnimationMappingsEditorProps = {
mappings: OfficeStateAnimationMapping[];
onChange: (mappings: OfficeStateAnimationMapping[]) => void;
};
const TARGET_LABELS: Record<OfficeStateAnimationTarget, string> = {
none: "No movement",
desk: "Desk",
server_room: "Server room",
gym: "Gym",
jukebox: "Jukebox",
qa_lab: "QA lab",
sms_booth: "SMS booth",
phone_booth: "Phone booth",
};
const EFFECT_LABELS: Record<OfficeStateEffectId, string> = {
none: "No effect",
confetti: "Confetti",
alarm: "Alarm",
doorbell: "Doorbell",
};
const createMappingId = (sourceState: string, index: number) =>
`state-${sourceState}-${Date.now()}-${index}`;
const createMapping = (
sourceState: string,
animationTarget: OfficeStateAnimationTarget,
params: Partial<OfficeStateAnimationMapping> = {},
): OfficeStateAnimationMapping => ({
id: createMappingId(sourceState, params.priority ?? 50),
sourceState,
label: params.label ?? sourceState.replace(/_/g, " "),
animationTarget,
effect: params.effect ?? "none",
soundCueId: params.soundCueId ?? null,
priority: params.priority ?? 50,
enabled: params.enabled ?? true,
});
const STARTER_MAPPINGS: OfficeStateAnimationMapping[] = [
createMapping("writing", "desk", { label: "Writing", priority: 90 }),
createMapping("executing", "server_room", { label: "Executing", priority: 80 }),
createMapping("waiting", "phone_booth", {
label: "Waiting",
effect: "doorbell",
soundCueId: "approval",
priority: 70,
}),
createMapping("error", "server_room", {
label: "Error",
effect: "alarm",
soundCueId: "alarm",
priority: 100,
}),
];
export function StateAnimationMappingsEditor({
mappings,
onChange,
}: StateAnimationMappingsEditorProps) {
const updateMapping = (
id: string,
patch: Partial<OfficeStateAnimationMapping>,
) => {
onChange(
mappings.map((mapping) =>
mapping.id === id ? { ...mapping, ...patch } : mapping,
),
);
};
const removeMapping = (id: string) => {
onChange(mappings.filter((mapping) => mapping.id !== id));
};
const normalizeMappingsForSave = () => {
onChange(
mappings
.map((mapping) => ({
...mapping,
sourceState: normalizeOfficeStateToken(mapping.sourceState),
label: mapping.label.trim() || mapping.sourceState.trim(),
soundCueId: mapping.soundCueId?.trim() || null,
priority: Math.max(0, Math.min(100, Math.round(mapping.priority))),
}))
.filter((mapping) => mapping.sourceState.length > 0),
);
};
const addMapping = () => {
onChange([...mappings, createMapping("writing", "desk", { label: "Writing" })]);
};
const addStarterMappings = () => {
onChange([
...mappings,
...STARTER_MAPPINGS.map((mapping, index) => ({
...mapping,
id: createMappingId(mapping.sourceState, mappings.length + index),
})),
]);
};
return (
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-medium text-white">
State animation mappings
</div>
<div className="mt-1 text-[10px] text-white/75">
Map runtime states like writing, executing, waiting, or error to office movement.
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{mappings.length} rules
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={addMapping}
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-50 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15"
>
Add mapping
</button>
<button
type="button"
onClick={addStarterMappings}
className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-100 transition-colors hover:border-emerald-400/40 hover:bg-emerald-500/15"
>
Use starter set
</button>
{mappings.length > 0 ? (
<>
<button
type="button"
onClick={normalizeMappingsForSave}
className="rounded-md border border-cyan-500/20 bg-black/20 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/10"
>
Normalize
</button>
<button
type="button"
onClick={() => onChange([])}
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15"
>
Clear
</button>
</>
) : null}
</div>
{mappings.length === 0 ? (
<div className="mt-3 rounded-md border border-dashed border-cyan-500/15 bg-black/15 px-3 py-3 text-[10px] text-white/60">
No custom mappings yet. Add one rule or load the starter set for common fleet states.
</div>
) : (
<div className="mt-3 grid gap-3">
{mappings.map((mapping, index) => (
<div
key={mapping.id}
className="rounded-lg border border-cyan-500/10 bg-black/15 px-3 py-3"
>
<div className="flex items-center justify-between gap-3">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Rule {index + 1}
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-label={`Enable mapping ${mapping.label || mapping.sourceState}`}
aria-checked={mapping.enabled}
className={`ui-switch self-center ${mapping.enabled ? "ui-switch--on" : ""}`}
onClick={() =>
updateMapping(mapping.id, { enabled: !mapping.enabled })
}
>
<span className="ui-switch-thumb" />
</button>
<button
type="button"
onClick={() => removeMapping(mapping.id)}
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15"
>
Remove
</button>
</div>
</div>
<div className="mt-3 grid gap-3">
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Label
</span>
<input
type="text"
value={mapping.label}
onChange={(event) =>
updateMapping(mapping.id, {
label: event.target.value,
})
}
placeholder="Writing"
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
</label>
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Source state
</span>
<input
type="text"
value={mapping.sourceState}
onChange={(event) =>
updateMapping(mapping.id, {
sourceState: event.target.value,
})
}
placeholder="writing"
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Target
</span>
<select
value={mapping.animationTarget}
onChange={(event) =>
updateMapping(mapping.id, {
animationTarget: event.target
.value as OfficeStateAnimationTarget,
})
}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
>
{OFFICE_STATE_ANIMATION_TARGETS.map((target) => (
<option key={target} value={target}>
{TARGET_LABELS[target]}
</option>
))}
</select>
</label>
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Effect
</span>
<select
value={mapping.effect}
onChange={(event) =>
updateMapping(mapping.id, {
effect: event.target.value as OfficeStateEffectId,
})
}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
>
{OFFICE_STATE_EFFECT_IDS.map((effect) => (
<option key={effect} value={effect}>
{EFFECT_LABELS[effect]}
</option>
))}
</select>
</label>
</div>
<div className="grid grid-cols-[1fr_88px] gap-2">
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Sound cue
</span>
<input
type="text"
value={mapping.soundCueId ?? ""}
onChange={(event) =>
updateMapping(mapping.id, {
soundCueId: event.target.value || null,
})
}
placeholder="alarm"
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
</label>
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Priority
</span>
<input
type="number"
min={0}
max={100}
value={mapping.priority}
onChange={(event) =>
updateMapping(mapping.id, {
priority: Number.parseInt(event.target.value, 10) || 0,
})
}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
/>
</label>
</div>
</div>
</div>
))}
</div>
)}
<div className="mt-3 text-[10px] text-white/50">
Source states are normalized on save, so "Writing" becomes "writing" and "approval pending" becomes "approval_pending".
</div>
</div>
);
}
@@ -1329,6 +1329,7 @@ export function OfficeScreen({
remoteOfficeGatewayUrl,
remoteOfficeTokenConfigured,
setTitle: setOfficeTitle,
setStateAnimationMappings,
setRemoteOfficeEnabled,
setRemoteOfficeSourceKind,
setRemoteOfficeLabel,
@@ -4835,11 +4836,13 @@ export function OfficeScreen({
remoteOfficeStatusText={remoteOfficeStatusText}
remoteLayoutSnapshot={remoteOfficeLayoutSnapshot}
remoteOfficeTokenConfigured={remoteOfficeTokenConfigured}
stateAnimationMappings={stateAnimationMappings}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
voiceRepliesLoaded={voiceRepliesLoaded}
onOfficeTitleChange={setOfficeTitle}
onStateAnimationMappingsChange={setStateAnimationMappings}
onRemoteOfficeEnabledChange={setRemoteOfficeEnabled}
onRemoteOfficeSourceKindChange={setRemoteOfficeSourceKind}
onRemoteOfficeLabelChange={setRemoteOfficeLabel}
@@ -28,6 +28,7 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Environment, OrbitControls } from "@react-three/drei";
import * as THREE from "three";
import { SettingsPanel } from "@/features/office/components/panels/SettingsPanel";
import type { OfficeStateAnimationMapping } from "@/lib/office/stateMappingConfig";
import { AtmImmersiveScreen } from "@/features/office/screens/AtmImmersiveScreen";
import { GithubImmersiveScreen } from "@/features/office/screens/GithubImmersiveScreen";
import { KanbanImmersiveScreen } from "@/features/office/screens/KanbanImmersiveScreen";
@@ -2276,6 +2277,7 @@ export function RetroOffice3D({
remoteOfficeStatusText = "Remote office disabled.",
remoteLayoutSnapshot = null,
remoteOfficeTokenConfigured = false,
stateAnimationMappings = [],
voiceRepliesEnabled = false,
voiceRepliesVoiceId = null,
voiceRepliesSpeed = 1,
@@ -2287,6 +2289,7 @@ export function RetroOffice3D({
onRemoteOfficePresenceUrlChange,
onRemoteOfficeGatewayUrlChange,
onRemoteOfficeTokenChange,
onStateAnimationMappingsChange,
onVoiceRepliesToggle,
onVoiceRepliesVoiceChange,
onVoiceRepliesSpeedChange,
@@ -2390,6 +2393,7 @@ export function RetroOffice3D({
remoteOfficeStatusText?: string;
remoteLayoutSnapshot?: OfficeLayoutSnapshot | null;
remoteOfficeTokenConfigured?: boolean;
stateAnimationMappings?: OfficeStateAnimationMapping[];
voiceRepliesEnabled?: boolean;
voiceRepliesVoiceId?: string | null;
voiceRepliesSpeed?: number;
@@ -2403,6 +2407,7 @@ export function RetroOffice3D({
onRemoteOfficePresenceUrlChange?: (url: string) => void;
onRemoteOfficeGatewayUrlChange?: (url: string) => void;
onRemoteOfficeTokenChange?: (token: string) => void;
onStateAnimationMappingsChange?: (mappings: OfficeStateAnimationMapping[]) => void;
onVoiceRepliesToggle?: (enabled: boolean) => void;
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
onVoiceRepliesSpeedChange?: (speed: number) => void;
@@ -7132,6 +7137,10 @@ export function RetroOffice3D({
onRemoteOfficeTokenChange={(token) =>
onRemoteOfficeTokenChange?.(token)
}
stateAnimationMappings={stateAnimationMappings}
onStateAnimationMappingsChange={(mappings) =>
onStateAnimationMappingsChange?.(mappings)
}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
@@ -0,0 +1,79 @@
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { StateAnimationMappingsEditor } from "@/features/office/components/panels/StateAnimationMappingsEditor";
import type { OfficeStateAnimationMapping } from "@/lib/office/stateMappingConfig";
const baseMapping: OfficeStateAnimationMapping = {
id: "writing-desk",
sourceState: "writing",
label: "Writing",
animationTarget: "desk",
effect: "none",
soundCueId: null,
priority: 90,
enabled: true,
};
describe("StateAnimationMappingsEditor", () => {
afterEach(() => {
cleanup();
});
it("adds starter mappings", () => {
const onChange = vi.fn();
render(
createElement(StateAnimationMappingsEditor, {
mappings: [],
onChange,
}),
);
fireEvent.click(screen.getByRole("button", { name: "Use starter set" }));
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
sourceState: "writing",
animationTarget: "desk",
}),
expect.objectContaining({
sourceState: "error",
animationTarget: "server_room",
effect: "alarm",
}),
]),
);
});
it("edits and removes a mapping", () => {
const onChange = vi.fn();
render(
createElement(StateAnimationMappingsEditor, {
mappings: [baseMapping],
onChange,
}),
);
fireEvent.change(screen.getByLabelText("Source state"), {
target: { value: "syncing" },
});
expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ sourceState: "syncing" }),
]);
fireEvent.change(screen.getByLabelText("Target"), {
target: { value: "qa_lab" },
});
expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ animationTarget: "qa_lab" }),
]);
const rule = screen.getByText("Rule 1").closest("div")?.parentElement?.parentElement;
expect(rule).toBeTruthy();
fireEvent.click(within(rule as HTMLElement).getByRole("button", { name: "Remove" }));
expect(onChange).toHaveBeenLastCalledWith([]);
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
include: ["tests/unit/**/*.test.ts"],
include: ["tests/unit/**/*.test.{ts,tsx}"],
exclude: ["tests/e2e/**"],
},
});