Avatar Customization + Update Agent Brain (#23)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-20 23:05:14 -05:00
committed by GitHub
parent a5b0895dd8
commit 65c2b9cf85
39 changed files with 2803 additions and 551 deletions
+3
View File
@@ -1,6 +1,9 @@
# Browser/client gateway URL
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
# Debug UI
DEBUG=true
# App server
# PORT=3000
# HOST=127.0.0.1
+29 -3
View File
@@ -1,10 +1,36 @@
# Changelog
All notable changes to Claw3D will be documented in this file.
## [0.1.2] - 2026-03-20
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows Semantic Versioning where practical.
### Added
## [0.1.0] - 2026-03-19
- An in-app avatar creator for agents with live 3D preview, appearance presets, and accessory controls for customizing office avatars.
- A unified agent editor modal in the office that lets you edit avatars alongside agent brain files such as `IDENTITY.md`, `SOUL.md`, `AGENTS.md`, `USER.md`, `TOOLS.md`, `MEMORY.md`, and `HEARTBEAT.md`.
- Structured avatar profile persistence and normalization so studio settings can store full avatar appearance data per gateway and agent instead of only avatar seeds.
- A `DEBUG` environment toggle for controlling the OpenClaw event console in the office UI.
### Changed
- Reworked office avatar rendering so 3D agents reflect saved appearance profiles, including hair, clothing, hats, glasses, headsets, backpacks, and other visual variations.
- Replaced avatar shuffle entry points in the chat and office surfaces with avatar customization flows that open the editor directly.
- Updated the office HUD with a compact agent roster, overflow handling, and direct shortcuts into per-agent editing from the 3D office view.
- Expanded the brain editor so `IDENTITY.md` fields are edited in structured form and agent renames can be applied to the live gateway agent after saving.
- Defaulted the OpenClaw event console to a collapsed state and made it optional from environment configuration.
- Updated hydration and store state to carry full avatar profiles through agent loading, persistence, and rendering.
### Fixed
- Fixed WebSocket gateway authentication during the upgrade handshake by wiring access control through the `ws` `verifyClient` flow.
- Fixed the gym release directive TypeScript error by adding explicit `"release"` support to office gym directives and aligning release-hold logic.
- Corrected studio settings merging and normalization for avatar data so saved office appearances survive reloads and patch updates.
- Kept skill gym hold state active for release directives during office animation trigger reconciliation.
### Tests
- Added unit coverage for avatar profile persistence, studio settings normalization, and fleet hydration with structured avatar data.
- Expanded end-to-end coverage for avatar settings fixtures, office header and sidebar flows, voice reply settings persistence, disconnected office settings surfaces, and office route expectations.
## [0.1.1] - 2026-03-19
### Added
+11 -1
View File
@@ -2,11 +2,21 @@ import { Suspense } from "react";
import { AgentStoreProvider } from "@/features/agents/state/store";
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
const ENABLED_RE = /^(1|true|yes|on)$/i;
const readDebugFlag = (value: string | undefined): boolean => {
const normalized = (value ?? "").trim();
if (!normalized) return true;
return ENABLED_RE.test(normalized);
};
export default function OfficePage() {
const showOpenClawConsole = readDebugFlag(process.env.DEBUG);
return (
<AgentStoreProvider>
<Suspense fallback={null}>
<OfficeScreen />
<OfficeScreen showOpenClawConsole={showOpenClawConsole} />
</Suspense>
</AgentStoreProvider>
);
@@ -0,0 +1,48 @@
"use client";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
type AgentAvatarCreatorModalProps = {
open: boolean;
agentId: string;
agentName: string;
initialProfile: AgentAvatarProfile | null | undefined;
onClose: () => void;
onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
};
export const AgentAvatarCreatorModal = ({
open,
agentId,
agentName,
initialProfile,
onClose,
onSave,
}: AgentAvatarCreatorModalProps) => {
if (!open) return null;
return (
<div
className="fixed inset-0 z-[140] flex items-center justify-center bg-background/85 p-4"
role="dialog"
aria-modal="true"
aria-label={`Customize avatar for ${agentName}`}
onClick={onClose}
>
<div
className="ui-panel grid w-full max-w-6xl gap-0 overflow-hidden shadow-xs xl:grid-cols-[360px_minmax(0,1fr)]"
onClick={(event) => event.stopPropagation()}
>
<AgentAvatarEditorPanel
agentId={agentId}
agentName={agentName}
initialProfile={initialProfile}
onCancel={onClose}
onSave={onSave}
onSaved={onClose}
/>
</div>
</div>
);
};
@@ -0,0 +1,423 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { RefreshCcw, Shuffle } from "lucide-react";
import {
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
AGENT_AVATAR_HAIR_COLOR_OPTIONS,
AGENT_AVATAR_HAIR_STYLE_OPTIONS,
AGENT_AVATAR_HAT_STYLE_OPTIONS,
AGENT_AVATAR_SHOE_COLOR_OPTIONS,
AGENT_AVATAR_SKIN_TONE_OPTIONS,
AGENT_AVATAR_TOP_STYLE_OPTIONS,
type AgentAvatarProfile,
createDefaultAgentAvatarProfile,
} from "@/lib/avatars/profile";
import { AgentAvatarPreview3D } from "@/features/agents/components/AgentAvatarPreview3D";
import { randomUUID } from "@/lib/uuid";
export type AgentAvatarEditorPanelProps = {
agentId: string;
agentName: string;
initialProfile: AgentAvatarProfile | null | undefined;
onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
onCancel?: () => void;
onSaved?: () => void;
};
const pillClassName =
"rounded-full border px-3 py-1.5 text-[11px] transition-colors";
const colorSwatchClassName =
"h-7 w-7 rounded-full border-2 transition-transform hover:scale-105";
export const AgentAvatarEditorPanel = ({
agentId,
agentName,
initialProfile,
onSave,
onCancel,
onSaved,
}: AgentAvatarEditorPanelProps) => {
const fallbackProfile = useMemo(
() => createDefaultAgentAvatarProfile(agentId),
[agentId]
);
const resolvedInitialProfile = initialProfile ?? fallbackProfile;
const [draft, setDraft] = useState<AgentAvatarProfile>(resolvedInitialProfile);
const [saving, setSaving] = useState(false);
useEffect(() => {
setDraft(resolvedInitialProfile);
}, [resolvedInitialProfile]);
const save = async () => {
if (saving) return;
setSaving(true);
try {
await onSave(draft);
onSaved?.();
} finally {
setSaving(false);
}
};
return (
<div className="grid h-full min-h-0 gap-0 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="border-b border-border/45 p-5 xl:border-b-0 xl:border-r">
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Avatar creator
</div>
<div className="mt-1 text-lg font-semibold text-foreground">{agentName}</div>
<div className="mt-1 text-xs text-muted-foreground">
Personalize this office avatar locally on this machine.
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-border/45 bg-[#070b16]">
<AgentAvatarPreview3D profile={draft} className="h-[360px] w-full" />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs"
onClick={() => setDraft(createDefaultAgentAvatarProfile(agentId))}
disabled={saving}
>
<RefreshCcw className="h-3.5 w-3.5" />
Reset
</button>
<button
type="button"
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs"
onClick={() => setDraft(createDefaultAgentAvatarProfile(randomUUID()))}
disabled={saving}
>
<Shuffle className="h-3.5 w-3.5" />
Randomize
</button>
</div>
</div>
<div className="min-h-0 overflow-y-auto p-5">
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
<button
type="button"
className="ui-btn-ghost px-3 py-2 text-xs"
onClick={onCancel}
disabled={saving}
>
Cancel
</button>
<button
type="button"
className="ui-btn-primary px-3 py-2 text-xs"
onClick={() => {
void save();
}}
disabled={saving}
>
{saving ? "Saving..." : "Save avatar"}
</button>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Skin tone
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_SKIN_TONE_OPTIONS.map((option) => {
const selected = draft.body.skinTone === option.color;
return (
<button
key={option.id}
type="button"
aria-label={option.label}
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
style={{ backgroundColor: option.color }}
onClick={() =>
setDraft((current) => ({
...current,
body: { ...current.body, skinTone: option.color },
}))
}
/>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Hair style
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_HAIR_STYLE_OPTIONS.map((option) => {
const selected = draft.hair.style === option.id;
return (
<button
key={option.id}
type="button"
className={`${pillClassName} ${
selected
? "border-primary bg-primary/15 text-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground"
}`}
onClick={() =>
setDraft((current) => ({
...current,
hair: { ...current.hair, style: option.id },
}))
}
>
{option.label}
</button>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Hair color
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_HAIR_COLOR_OPTIONS.map((option) => {
const selected = draft.hair.color === option.color;
return (
<button
key={option.id}
type="button"
aria-label={option.label}
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
style={{ backgroundColor: option.color }}
onClick={() =>
setDraft((current) => ({
...current,
hair: { ...current.hair, color: option.color },
}))
}
/>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Top style
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_TOP_STYLE_OPTIONS.map((option) => {
const selected = draft.clothing.topStyle === option.id;
return (
<button
key={option.id}
type="button"
className={`${pillClassName} ${
selected
? "border-primary bg-primary/15 text-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground"
}`}
onClick={() =>
setDraft((current) => ({
...current,
clothing: { ...current.clothing, topStyle: option.id },
}))
}
>
{option.label}
</button>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Top color
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => {
const selected = draft.clothing.topColor === option.color;
return (
<button
key={`top-${option.id}`}
type="button"
aria-label={option.label}
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
style={{ backgroundColor: option.color }}
onClick={() =>
setDraft((current) => ({
...current,
clothing: { ...current.clothing, topColor: option.color },
}))
}
/>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Bottom style
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_BOTTOM_STYLE_OPTIONS.map((option) => {
const selected = draft.clothing.bottomStyle === option.id;
return (
<button
key={option.id}
type="button"
className={`${pillClassName} ${
selected
? "border-primary bg-primary/15 text-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground"
}`}
onClick={() =>
setDraft((current) => ({
...current,
clothing: { ...current.clothing, bottomStyle: option.id },
}))
}
>
{option.label}
</button>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Bottom color
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => {
const selected = draft.clothing.bottomColor === option.color;
return (
<button
key={`bottom-${option.id}`}
type="button"
aria-label={option.label}
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
style={{ backgroundColor: option.color }}
onClick={() =>
setDraft((current) => ({
...current,
clothing: { ...current.clothing, bottomColor: option.color },
}))
}
/>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Shoe color
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_SHOE_COLOR_OPTIONS.map((option) => {
const selected = draft.clothing.shoesColor === option.color;
return (
<button
key={`shoes-${option.id}`}
type="button"
aria-label={option.label}
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
style={{ backgroundColor: option.color }}
onClick={() =>
setDraft((current) => ({
...current,
clothing: { ...current.clothing, shoesColor: option.color },
}))
}
/>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Hat
</h3>
<div className="flex flex-wrap gap-2">
{AGENT_AVATAR_HAT_STYLE_OPTIONS.map((option) => {
const selected = draft.accessories.hatStyle === option.id;
return (
<button
key={option.id}
type="button"
className={`${pillClassName} ${
selected
? "border-primary bg-primary/15 text-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground"
}`}
onClick={() =>
setDraft((current) => ({
...current,
accessories: { ...current.accessories, hatStyle: option.id },
}))
}
>
{option.label}
</button>
);
})}
</div>
</section>
<section className="space-y-3 xl:col-span-2">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
Accessories
</h3>
<div className="flex flex-wrap gap-2">
{[
{
key: "glasses" as const,
label: "Glasses",
enabled: draft.accessories.glasses,
},
{
key: "headset" as const,
label: "Headset",
enabled: draft.accessories.headset,
},
{
key: "backpack" as const,
label: "Backpack",
enabled: draft.accessories.backpack,
},
].map((option) => (
<button
key={option.key}
type="button"
className={`${pillClassName} ${
option.enabled
? "border-primary bg-primary/15 text-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground"
}`}
onClick={() =>
setDraft((current) => ({
...current,
accessories: {
...current.accessories,
[option.key]: !current.accessories[option.key],
},
}))
}
>
{option.label}
</button>
))}
</div>
</section>
</div>
</div>
</div>
);
};
@@ -0,0 +1,336 @@
"use client";
import { Environment, OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import {
type AgentAvatarProfile,
createDefaultAgentAvatarProfile,
} from "@/lib/avatars/profile";
const PreviewFigure = ({
profile,
onFirstFrame,
}: {
profile: AgentAvatarProfile;
onFirstFrame: () => void;
}) => {
const groupRef = useRef<THREE.Group>(null);
const reportedReadyRef = useRef(false);
useFrame((state) => {
if (!reportedReadyRef.current) {
reportedReadyRef.current = true;
onFirstFrame();
}
if (!groupRef.current) return;
groupRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.45) * 0.35 + 0.25;
});
const skin = profile.body.skinTone;
const topColor = profile.clothing.topColor;
const bottomColor = profile.clothing.bottomColor;
const shoeColor = profile.clothing.shoesColor;
const hairColor = profile.hair.color;
const accessoryColor = topColor;
const sleeveColor = profile.clothing.topStyle === "jacket" ? "#dbe4ff" : topColor;
const cuffColor = profile.clothing.topStyle === "hoodie" ? "#d1d5db" : sleeveColor;
return (
<group ref={groupRef} position={[0, -0.72, 0]} scale={[1.45, 1.45, 1.45]}>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
<circleGeometry args={[0.22, 24]} />
<meshBasicMaterial color="#000000" transparent opacity={0.16} />
</mesh>
{profile.accessories.backpack ? (
<group position={[0, 0.31, -0.08]}>
<mesh>
<boxGeometry args={[0.16, 0.2, 0.06]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
</group>
) : null}
<group position={[-0.05, 0.12, 0]}>
{profile.clothing.bottomStyle === "shorts" ? (
<>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.08, 0.08]} />
<meshLambertMaterial color={bottomColor} />
</mesh>
<mesh position={[0, -0.045, 0]}>
<boxGeometry args={[0.05, 0.06, 0.05]} />
<meshLambertMaterial color={skin} />
</mesh>
</>
) : (
<mesh>
<boxGeometry args={[0.07, 0.14, 0.08]} />
<meshLambertMaterial color={bottomColor} />
</mesh>
)}
<mesh position={[0, -0.09, 0]}>
<boxGeometry args={[0.07, 0.05, 0.12]} />
<meshLambertMaterial color={shoeColor} />
</mesh>
</group>
<group position={[0.05, 0.12, 0]}>
{profile.clothing.bottomStyle === "shorts" ? (
<>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.08, 0.08]} />
<meshLambertMaterial color={bottomColor} />
</mesh>
<mesh position={[0, -0.045, 0]}>
<boxGeometry args={[0.05, 0.06, 0.05]} />
<meshLambertMaterial color={skin} />
</mesh>
</>
) : (
<mesh>
<boxGeometry args={[0.07, 0.14, 0.08]} />
<meshLambertMaterial color={bottomColor} />
</mesh>
)}
<mesh position={[0, -0.09, 0]}>
<boxGeometry args={[0.07, 0.05, 0.12]} />
<meshLambertMaterial color={shoeColor} />
</mesh>
</group>
<mesh position={[0, 0.3, 0]}>
<boxGeometry args={[0.2, 0.22, 0.1]} />
<meshLambertMaterial color={topColor} />
</mesh>
{profile.clothing.topStyle === "hoodie" ? (
<>
<mesh position={[0, 0.37, -0.045]}>
<boxGeometry args={[0.18, 0.1, 0.03]} />
<meshLambertMaterial color={topColor} />
</mesh>
<mesh position={[0, 0.23, 0.056]}>
<boxGeometry args={[0.11, 0.03, 0.012]} />
<meshLambertMaterial color={cuffColor} />
</mesh>
</>
) : null}
{profile.clothing.topStyle === "jacket" ? (
<>
<mesh position={[0, 0.3, 0.056]}>
<boxGeometry args={[0.202, 0.23, 0.012]} />
<meshLambertMaterial color="#1f2937" />
</mesh>
<mesh position={[0, 0.3, 0.063]}>
<boxGeometry args={[0.038, 0.21, 0.01]} />
<meshLambertMaterial color="#f8fafc" />
</mesh>
</>
) : null}
<group position={[-0.13, 0.3, 0]}>
<mesh position={[0, -0.08, 0]}>
<boxGeometry args={[0.06, 0.16, 0.06]} />
<meshLambertMaterial color={sleeveColor} />
</mesh>
{profile.clothing.topStyle === "hoodie" ? (
<mesh position={[0, -0.145, 0]}>
<boxGeometry args={[0.064, 0.03, 0.064]} />
<meshLambertMaterial color={cuffColor} />
</mesh>
) : null}
<mesh position={[0, -0.17, 0]}>
<boxGeometry args={[0.05, 0.05, 0.05]} />
<meshLambertMaterial color={skin} />
</mesh>
</group>
<group position={[0.13, 0.3, 0]}>
<mesh position={[0, -0.08, 0]}>
<boxGeometry args={[0.06, 0.16, 0.06]} />
<meshLambertMaterial color={sleeveColor} />
</mesh>
{profile.clothing.topStyle === "hoodie" ? (
<mesh position={[0, -0.145, 0]}>
<boxGeometry args={[0.064, 0.03, 0.064]} />
<meshLambertMaterial color={cuffColor} />
</mesh>
) : null}
<mesh position={[0, -0.17, 0]}>
<boxGeometry args={[0.05, 0.05, 0.05]} />
<meshLambertMaterial color={skin} />
</mesh>
</group>
<mesh position={[0, 0.42, 0]}>
<boxGeometry args={[0.07, 0.05, 0.07]} />
<meshLambertMaterial color={skin} />
</mesh>
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[0.17, 0.17, 0.15]} />
<meshLambertMaterial color={skin} />
</mesh>
{profile.hair.style === "short" ? (
<mesh position={[0, 0.59, 0]}>
<boxGeometry args={[0.18, 0.05, 0.15]} />
<meshLambertMaterial color={hairColor} />
</mesh>
) : null}
{profile.hair.style === "parted" ? (
<>
<mesh position={[0, 0.585, 0]}>
<boxGeometry args={[0.18, 0.045, 0.15]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[-0.03, 0.62, 0.01]} rotation={[0.1, 0, -0.2]}>
<boxGeometry args={[0.12, 0.03, 0.08]} />
<meshLambertMaterial color={hairColor} />
</mesh>
</>
) : null}
{profile.hair.style === "spiky" ? (
<>
<mesh position={[0, 0.58, 0]}>
<boxGeometry args={[0.17, 0.035, 0.14]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[-0.05, 0.62, 0]} rotation={[0, 0, -0.2]}>
<boxGeometry args={[0.04, 0.06, 0.04]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[0, 0.635, 0]}>
<boxGeometry args={[0.04, 0.08, 0.04]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[0.05, 0.62, 0]} rotation={[0, 0, 0.2]}>
<boxGeometry args={[0.04, 0.06, 0.04]} />
<meshLambertMaterial color={hairColor} />
</mesh>
</>
) : null}
{profile.hair.style === "bun" ? (
<>
<mesh position={[0, 0.58, 0]}>
<boxGeometry args={[0.18, 0.04, 0.15]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[0, 0.63, -0.03]}>
<sphereGeometry args={[0.045, 16, 16]} />
<meshLambertMaterial color={hairColor} />
</mesh>
</>
) : null}
{profile.accessories.hatStyle === "cap" ? (
<>
<mesh position={[0, 0.63, 0]}>
<boxGeometry args={[0.18, 0.03, 0.16]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
<mesh position={[0, 0.615, 0.07]}>
<boxGeometry args={[0.09, 0.012, 0.05]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
</>
) : null}
{profile.accessories.hatStyle === "beanie" ? (
<mesh position={[0, 0.63, 0]}>
<boxGeometry args={[0.19, 0.06, 0.17]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
) : null}
{profile.accessories.headset ? (
<>
<mesh position={[0, 0.6, 0]} rotation={[0, 0, Math.PI / 2]}>
<torusGeometry args={[0.095, 0.008, 8, 24, Math.PI]} />
<meshLambertMaterial color="#94a3b8" />
</mesh>
<mesh position={[-0.105, 0.51, 0]}>
<boxGeometry args={[0.018, 0.05, 0.028]} />
<meshLambertMaterial color="#475569" />
</mesh>
<mesh position={[0.105, 0.51, 0]}>
<boxGeometry args={[0.018, 0.05, 0.028]} />
<meshLambertMaterial color="#475569" />
</mesh>
</>
) : null}
<mesh position={[-0.04, 0.505, 0.078]}>
<boxGeometry args={[0.03, 0.03, 0.01]} />
<meshBasicMaterial color="#111827" />
</mesh>
<mesh position={[0.04, 0.505, 0.078]}>
<boxGeometry args={[0.03, 0.03, 0.01]} />
<meshBasicMaterial color="#111827" />
</mesh>
{profile.accessories.glasses ? (
<>
<mesh position={[-0.04, 0.505, 0.084]}>
<boxGeometry args={[0.05, 0.05, 0.01]} />
<meshBasicMaterial color="#111827" wireframe />
</mesh>
<mesh position={[0.04, 0.505, 0.084]}>
<boxGeometry args={[0.05, 0.05, 0.01]} />
<meshBasicMaterial color="#111827" wireframe />
</mesh>
<mesh position={[0, 0.505, 0.084]}>
<boxGeometry args={[0.02, 0.008, 0.01]} />
<meshBasicMaterial color="#111827" />
</mesh>
</>
) : null}
<mesh position={[0, 0.46, 0.079]}>
<boxGeometry args={[0.05, 0.014, 0.01]} />
<meshBasicMaterial color="#9c4a4a" />
</mesh>
</group>
);
};
export const AgentAvatarPreview3D = ({
profile,
className = "",
}: {
profile: AgentAvatarProfile | null | undefined;
className?: string;
}) => {
const resolvedProfile = useMemo(
() => profile ?? createDefaultAgentAvatarProfile("preview"),
[profile]
);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
setIsReady(false);
}, [resolvedProfile]);
return (
<div className={`relative ${className}`}>
{!isReady ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#070b16] text-white/70">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-cyan-300" />
<div className="font-mono text-[11px] tracking-[0.08em] text-white/55">
Loading avatar...
</div>
</div>
) : null}
<Canvas camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
<color attach="background" args={["#070b16"]} />
<ambientLight intensity={1.4} />
<directionalLight position={[3, 4, 5]} intensity={2.4} />
<directionalLight position={[-4, 2, 3]} intensity={0.9} color="#89a6ff" />
<PreviewFigure
profile={resolvedProfile}
onFirstFrame={() => {
setIsReady(true);
}}
/>
<Environment preset="city" />
<OrbitControls enablePan={false} enableZoom={false} maxPolarAngle={1.8} minPolarAngle={1.1} />
</Canvas>
</div>
);
};
@@ -13,7 +13,7 @@ import {
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Shuffle, Square, Trash2, X } from "lucide-react";
import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Square, Trash2, X } from "lucide-react";
import type { GatewayModelChoice } from "@/lib/gateway/models";
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
@@ -1481,15 +1481,15 @@ export const AgentChatPanel = ({
className="nodrag ui-btn-icon ui-btn-icon-xs agent-avatar-shuffle-btn absolute bottom-0 right-0"
style={{ "--ui-btn-icon-size": "1.1rem" } as React.CSSProperties}
type="button"
aria-label="Shuffle avatar"
data-testid="agent-avatar-shuffle"
aria-label="Customize avatar"
data-testid="agent-avatar-customize"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onAvatarShuffle();
}}
>
<Shuffle className="h-2 w-2" />
<Pencil className="h-2 w-2" />
</button>
</div>
@@ -0,0 +1,225 @@
"use client";
import { useEffect, useState } from "react";
import {
Brain,
Database,
FileText,
HeartPulse,
Palette,
Shield,
UserRound,
Wrench,
X,
} from "lucide-react";
import type { AgentState } from "@/features/agents/state/store";
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
import { AgentBrainPanel } from "@/features/agents/components/inspect/AgentBrainPanel";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import type { AgentFileName } from "@/lib/agents/agentFiles";
import { AGENT_FILE_META } from "@/lib/agents/agentFiles";
import { renameGatewayAgent } from "@/lib/gateway/agentConfig";
export type AgentEditorSection = "avatar" | AgentFileName;
type AgentEditorModalProps = {
open: boolean;
client: GatewayClient | null;
agents: AgentState[];
agent: AgentState;
initialSection?: AgentEditorSection;
onClose: () => void;
onAvatarSave: (agentId: string, profile: AgentAvatarProfile) => Promise<void> | void;
onRename?: (agentId: string, name: string) => Promise<boolean>;
};
const menuButtonClassName =
"flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors";
const editorSections: Array<{
id: AgentEditorSection;
label: string;
hint: string;
icon: typeof Palette;
}> = [
{
id: "IDENTITY.md",
label: "Identity",
hint: AGENT_FILE_META["IDENTITY.md"].hint,
icon: FileText,
},
{
id: "avatar",
label: "Avatar",
hint: "Office appearance.",
icon: Palette,
},
{
id: "SOUL.md",
label: "Soul",
hint: AGENT_FILE_META["SOUL.md"].hint,
icon: Brain,
},
{
id: "AGENTS.md",
label: "Agents",
hint: AGENT_FILE_META["AGENTS.md"].hint,
icon: Shield,
},
{
id: "USER.md",
label: "User",
hint: AGENT_FILE_META["USER.md"].hint,
icon: UserRound,
},
{
id: "TOOLS.md",
label: "Tools",
hint: AGENT_FILE_META["TOOLS.md"].hint,
icon: Wrench,
},
{
id: "MEMORY.md",
label: "Memory",
hint: AGENT_FILE_META["MEMORY.md"].hint,
icon: Database,
},
{
id: "HEARTBEAT.md",
label: "Heartbeat",
hint: AGENT_FILE_META["HEARTBEAT.md"].hint,
icon: HeartPulse,
},
];
export const AgentEditorModal = ({
open,
client,
agents,
agent,
initialSection = "avatar",
onClose,
onAvatarSave,
onRename,
}: AgentEditorModalProps) => {
const [activeSection, setActiveSection] = useState<AgentEditorSection>(initialSection);
useEffect(() => {
if (!open) return;
setActiveSection(initialSection);
}, [initialSection, open, agent.agentId]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[145] flex items-center justify-center bg-background/88 p-4"
role="dialog"
aria-modal="true"
aria-label={`Edit ${agent.name}`}
onClick={onClose}
>
<div
className="relative w-full max-w-7xl"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute -right-3 -top-3 z-20 inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/50 bg-background/92 text-muted-foreground shadow-lg transition-colors hover:text-foreground"
aria-label="Close agent editor"
>
<X className="h-4 w-4" />
</button>
<div className="ui-panel flex h-[min(90vh,920px)] w-full overflow-hidden shadow-xs">
<aside className="flex w-[240px] shrink-0 flex-col border-r border-border/50 bg-muted/20">
<div className="border-b border-border/40 px-5 py-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Agent editor
</div>
<div className="mt-1 truncate text-lg font-semibold text-foreground">
{agent.name}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Edit avatar and agent brain settings from the office.
</div>
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{editorSections.map((section) => {
const Icon = section.icon;
return (
<button
key={section.id}
type="button"
onClick={() => setActiveSection(section.id)}
className={`${menuButtonClassName} ${
activeSection === section.id
? "border-primary/40 bg-primary/10 text-foreground"
: "border-border/45 bg-background/40 text-muted-foreground hover:border-border hover:text-foreground"
}`}
>
<Icon className="h-4 w-4" />
<div>
<div className="text-sm font-medium">{section.label}</div>
<div className="text-xs opacity-75">{section.hint}</div>
</div>
</button>
);
})}
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col">
{activeSection === "avatar" ? (
<AgentAvatarEditorPanel
agentId={agent.agentId}
agentName={agent.name}
initialProfile={agent.avatarProfile}
onCancel={onClose}
onSave={(profile) => onAvatarSave(agent.agentId, profile)}
/>
) : client ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-border/40 px-6 py-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Agent file editor
</div>
<div className="mt-1 text-sm text-muted-foreground">
Edit one agent file at a time and save it through the gateway.
</div>
</div>
<div className="min-h-0 flex-1">
<AgentBrainPanel
client={client}
agents={agents}
selectedAgentId={agent.agentId}
activeSection={activeSection}
onCancel={onClose}
onRename={
onRename ??
(async (agentId, name) => {
if (!client) return false;
try {
await renameGatewayAgent({ client, agentId, name });
return true;
} catch {
return false;
}
})
}
/>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
Connect to a gateway to edit brain files.
</div>
)}
</section>
</div>
</div>
</div>
);
};
@@ -1,9 +1,14 @@
"use client";
import { useCallback, useEffect, useMemo, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import {
AGENT_FILE_META,
AGENT_FILE_PLACEHOLDERS,
type AgentFileName,
} from "@/lib/agents/agentFiles";
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
@@ -11,7 +16,10 @@ export type AgentBrainPanelProps = {
client: GatewayClient;
agents: AgentState[];
selectedAgentId: string | null;
activeSection?: AgentFileName;
onCancel?: () => void;
onUnsavedChangesChange?: (dirty: boolean) => void;
onRename?: (agentId: string, name: string) => Promise<boolean>;
};
const AgentBrainPanelSection = ({
@@ -31,7 +39,10 @@ export const AgentBrainPanel = ({
client,
agents,
selectedAgentId,
activeSection,
onCancel,
onUnsavedChangesChange,
onRename,
}: AgentBrainPanelProps) => {
const selectedAgent = useMemo(
() =>
@@ -49,9 +60,9 @@ export const AgentBrainPanel = ({
agentFilesError,
setAgentFileContent,
saveAgentFiles,
discardAgentFileChanges,
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
const [saveError, setSaveError] = useState<string | null>(null);
const setIdentityField = useCallback(
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
@@ -65,8 +76,29 @@ export const AgentBrainPanel = ({
const handleSave = useCallback(async () => {
if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return;
await saveAgentFiles();
}, [agentFilesDirty, agentFilesLoading, agentFilesSaving, saveAgentFiles]);
setSaveError(null);
const saved = await saveAgentFiles();
if (!saved || !selectedAgent || !onRename) {
return;
}
const nextName = draft.identity.name.trim();
const currentName = selectedAgent.name.trim();
if (!nextName || nextName === currentName) {
return;
}
const renamed = await onRename(selectedAgent.agentId, nextName);
if (!renamed) {
setSaveError("Saved IDENTITY.md, but could not rename the live agent.");
}
}, [
agentFilesDirty,
agentFilesLoading,
agentFilesSaving,
draft.identity.name,
onRename,
saveAgentFiles,
selectedAgent,
]);
useEffect(() => {
onUnsavedChangesChange?.(agentFilesDirty);
@@ -78,83 +110,36 @@ export const AgentBrainPanel = ({
};
}, [onUnsavedChangesChange]);
return (
<div
className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden"
data-testid="agent-personality-panel"
style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }}
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<section
className="mx-auto flex min-h-0 w-full max-w-[920px] flex-col"
data-testid="agent-personality-files"
>
{agentFilesError ? (
<div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs">
{agentFilesError}
const renderMarkdownEditor = useCallback(
(name: Exclude<AgentFileName, "IDENTITY.md">) => (
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
<textarea
aria-label={AGENT_FILE_META[name].title}
className="h-[min(56vh,480px)] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles[name].content}
placeholder={AGENT_FILE_PLACEHOLDERS[name]}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent(name, event.target.value);
}}
/>
</AgentBrainPanelSection>
),
[agentFiles, agentFilesLoading, agentFilesSaving, setAgentFileContent],
);
const renderIdentityEditor = useCallback(
() => (
<section className="space-y-3 border-t border-border/55 pt-8 first:border-t-0 first:pt-0">
<h3 className="text-sm font-medium text-foreground">{AGENT_FILE_META["IDENTITY.md"].title}</h3>
<div className="text-xs text-muted-foreground">
{AGENT_FILE_META["IDENTITY.md"].hint}
</div>
) : null}
<div className="mb-6 flex items-center justify-end gap-2">
<button
type="button"
className="ui-btn-secondary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:opacity-50"
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
onClick={discardAgentFileChanges}
>
Discard
</button>
<button
type="button"
className="ui-btn-primary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
onClick={() => {
void handleSave();
}}
>
Save
</button>
<div className="text-xs text-muted-foreground">
Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent
when you save.
</div>
<div className="space-y-8 pb-8">
<AgentBrainPanelSection title="Persona">
<textarea
aria-label="Persona"
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles["SOUL.md"].content}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent("SOUL.md", event.target.value);
}}
/>
</AgentBrainPanelSection>
<AgentBrainPanelSection title="Directives">
<textarea
aria-label="Directives"
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles["AGENTS.md"].content}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent("AGENTS.md", event.target.value);
}}
/>
</AgentBrainPanelSection>
<AgentBrainPanelSection title="Context">
<textarea
aria-label="Context"
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles["USER.md"].content}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent("USER.md", event.target.value);
}}
/>
</AgentBrainPanelSection>
<section className="space-y-3 border-t border-border/55 pt-8">
<h3 className="text-sm font-medium text-foreground">Identity</h3>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Name
@@ -201,19 +186,70 @@ export const AgentBrainPanel = ({
/>
</label>
</div>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Avatar
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.avatar}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("avatar", event.target.value);
}}
/>
</label>
</section>
),
[agentFilesLoading, agentFilesSaving, draft.identity, setIdentityField],
);
const renderedSections = useMemo(() => {
if (activeSection === "IDENTITY.md") {
return [renderIdentityEditor()];
}
if (activeSection) {
return [renderMarkdownEditor(activeSection as Exclude<AgentFileName, "IDENTITY.md">)];
}
return [
renderMarkdownEditor("SOUL.md"),
renderMarkdownEditor("AGENTS.md"),
renderMarkdownEditor("USER.md"),
renderIdentityEditor(),
];
}, [activeSection, renderIdentityEditor, renderMarkdownEditor]);
return (
<div
className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden"
data-testid="agent-personality-panel"
style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }}
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<section
className="mx-auto flex min-h-0 w-full max-w-[920px] flex-col"
data-testid="agent-personality-files"
>
{agentFilesError ? (
<div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs">
{agentFilesError}
</div>
) : null}
{saveError ? (
<div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs">
{saveError}
</div>
) : null}
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
<button
type="button"
className="ui-btn-ghost px-3 py-2 text-xs"
disabled={agentFilesLoading || agentFilesSaving}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className="ui-btn-primary px-3 py-2 text-xs disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
onClick={() => {
void handleSave();
}}
>
Save
</button>
</div>
<div className="space-y-8 pb-8">{renderedSections}</div>
</section>
</div>
</div>
@@ -1,6 +1,8 @@
import { buildAgentMainSessionKey } from "@/lib/gateway/GatewayClient";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import { resolveConfiguredModelKey, type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import {
resolveAgentAvatarProfile,
resolveAgentAvatarSeed,
type StudioSettings,
type StudioSettingsPublic,
@@ -203,9 +205,13 @@ export const deriveHydrateAgentFleetResult = (
const needsSessionSettingsSync = new Set<string>();
const seeds: AgentStoreSeed[] = input.agentsResult.agents.map((agent) => {
const defaultAvatarProfile = createDefaultAgentAvatarProfile(agent.id);
const persistedProfile =
input.settings && gatewayKey ? resolveAgentAvatarProfile(input.settings, gatewayKey, agent.id) : null;
const persistedSeed =
input.settings && gatewayKey ? resolveAgentAvatarSeed(input.settings, gatewayKey, agent.id) : null;
const avatarSeed = persistedSeed ?? agent.id;
const avatarProfile = persistedProfile ?? defaultAvatarProfile;
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
const avatarUrl = resolveAgentAvatarUrl(agent);
const name = resolveAgentName(agent);
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
@@ -247,6 +253,7 @@ export const deriveHydrateAgentFleetResult = (
name,
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
avatarSeed,
avatarProfile,
avatarUrl,
model,
thinkingLevel,
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
import { AgentAvatarCreatorModal } from "@/features/agents/components/AgentAvatarCreatorModal";
import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal";
import {
AgentBrainPanel,
@@ -45,6 +46,10 @@ import {
} from "@/lib/gateway/agentConfig";
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator";
import {
type AgentAvatarProfile,
createDefaultAgentAvatarProfile,
} from "@/lib/avatars/profile";
import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations";
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
import {
@@ -267,6 +272,7 @@ const AgentsPageScreen = () => {
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
const [avatarCreatorAgentId, setAvatarCreatorAgentId] = useState<string | null>(null);
const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null);
const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false);
const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality");
@@ -308,6 +314,13 @@ const AgentsPageScreen = () => {
: null;
return selectedInFilter ?? filteredAgents[0] ?? null;
}, [filteredAgents, selectedAgent]);
const avatarCreatorAgent = useMemo(
() =>
avatarCreatorAgentId
? state.agents.find((entry) => entry.agentId === avatarCreatorAgentId) ?? null
: null,
[avatarCreatorAgentId, state.agents]
);
const focusedAgentId = focusedAgent?.agentId ?? null;
const focusedAgentRunning = focusedAgent?.status === "running";
const focusedAgentStopDisabledReason = useMemo(() => {
@@ -892,17 +905,16 @@ const AgentsPageScreen = () => {
setCreateAgentModalOpen(true);
}, [createAgentBlock, createAgentBusy, restartingMutationBlock]);
const persistAvatarSeed = useCallback(
(agentId: string, avatarSeed: string) => {
const persistAvatarProfile = useCallback(
(agentId: string, profile: AgentAvatarProfile) => {
const resolvedAgentId = agentId.trim();
const resolvedAvatarSeed = avatarSeed.trim();
const key = gatewayUrl.trim();
if (!resolvedAgentId || !resolvedAvatarSeed || !key) return;
if (!resolvedAgentId || !key) return;
settingsCoordinator.schedulePatch(
{
avatars: {
[key]: {
[resolvedAgentId]: resolvedAvatarSeed,
[resolvedAgentId]: profile,
},
},
},
@@ -928,7 +940,10 @@ const AgentsPageScreen = () => {
createAgent: async (name, avatarSeed) => {
const created = await createGatewayAgent({ client, name });
if (avatarSeed) {
persistAvatarSeed(created.id, avatarSeed);
persistAvatarProfile(
created.id,
createDefaultAgentAvatarProfile(avatarSeed)
);
}
flushPendingDraft(focusedAgent?.agentId ?? null);
focusFilterTouchedRef.current = true;
@@ -1013,7 +1028,7 @@ const AgentsPageScreen = () => {
hasDeleteMutationBlock,
hasRenameMutationBlock,
loadAgents,
persistAvatarSeed,
persistAvatarProfile,
refreshGatewayConfigSnapshot,
setError,
status,
@@ -1254,16 +1269,15 @@ const AgentsPageScreen = () => {
]);
const handleAvatarShuffle = useCallback(
async (agentId: string) => {
const avatarSeed = randomUUID();
(agentId: string, profile: AgentAvatarProfile) => {
dispatch({
type: "updateAgent",
agentId,
patch: { avatarSeed },
patch: { avatarProfile: profile, avatarSeed: profile.seed },
});
persistAvatarSeed(agentId, avatarSeed);
persistAvatarProfile(agentId, profile);
},
[dispatch, persistAvatarSeed]
[dispatch, persistAvatarProfile]
);
const connectionPanelVisible = showConnectionPanel;
@@ -1547,6 +1561,7 @@ const AgentsPageScreen = () => {
agents={agents}
selectedAgentId={inspectSidebarAgent.agentId}
onUnsavedChangesChange={setPersonalityHasUnsavedChanges}
onRename={settingsMutationController.handleRenameAgent}
/>
) : (
<div className="h-full overflow-y-auto px-6 py-6">
@@ -1748,7 +1763,7 @@ const AgentsPageScreen = () => {
removeQueuedMessage(focusedAgent.agentId, index)
}
onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)}
onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)}
onAvatarShuffle={() => setAvatarCreatorAgentId(focusedAgent.agentId)}
pendingExecApprovals={focusedPendingExecApprovals}
onResolveExecApproval={(id, decision) => {
void handleResolveExecApproval(id, decision);
@@ -1791,6 +1806,20 @@ const AgentsPageScreen = () => {
}}
/>
) : null}
{avatarCreatorAgent ? (
<AgentAvatarCreatorModal
open
agentId={avatarCreatorAgent.agentId}
agentName={avatarCreatorAgent.name}
initialProfile={avatarCreatorAgent.avatarProfile}
onClose={() => {
setAvatarCreatorAgentId(null);
}}
onSave={(profile) => {
handleAvatarShuffle(avatarCreatorAgent.agentId, profile);
}}
/>
) : null}
{createAgentBlock && createAgentBlock.phase !== "queued" ? (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80"
+3
View File
@@ -8,6 +8,7 @@ import {
useReducer,
type ReactNode,
} from "react";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import {
areTranscriptEntriesEqual,
buildOutputLinesFromTranscriptEntries,
@@ -27,6 +28,7 @@ export type AgentStoreSeed = {
name: string;
sessionKey: string;
avatarSeed?: string | null;
avatarProfile?: AgentAvatarProfile | null;
avatarUrl?: string | null;
model?: string | null;
thinkingLevel?: string | null;
@@ -184,6 +186,7 @@ const createRuntimeAgentState = (
return {
...seed,
avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId,
avatarProfile: seed.avatarProfile ?? existing?.avatarProfile ?? null,
avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null,
model: seed.model ?? existing?.model ?? null,
thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "high",
@@ -6,6 +6,9 @@ type SettingsPanelProps = {
gatewayStatus?: string;
gatewayUrl?: string;
onGatewayDisconnect?: () => void;
officeTitle: string;
officeTitleLoaded: boolean;
onOfficeTitleChange: (title: string) => void;
voiceRepliesEnabled: boolean;
voiceRepliesVoiceId: string | null;
voiceRepliesSpeed: number;
@@ -20,6 +23,9 @@ export function SettingsPanel({
gatewayStatus,
gatewayUrl,
onGatewayDisconnect,
officeTitle,
officeTitleLoaded,
onOfficeTitleChange,
voiceRepliesEnabled,
voiceRepliesVoiceId,
voiceRepliesSpeed,
@@ -38,6 +44,31 @@ export function SettingsPanel({
return (
<div className="px-4 py-4">
<div className="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">Studio title</div>
<div className="mt-1 text-[10px] text-white/75">
Customize the banner shown at the top of the office.
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{officeTitleLoaded ? "Ready" : "Loading"}
</span>
</div>
<input
type="text"
value={officeTitle}
maxLength={48}
disabled={!officeTitleLoaded}
onChange={(event) => onOfficeTitleChange(event.target.value)}
placeholder="Luke Headquarters"
className="mt-3 w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] uppercase tracking-[0.18em] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30 disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="mt-2 text-[10px] text-white/50">
Used in the office scene header.
</div>
</div>
<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">Gateway</div>
+81 -10
View File
@@ -25,6 +25,7 @@ import {
type StudioSettingsLoadOptions,
} from "@/lib/studio/coordinator";
import { resolveDeskAssignments } from "@/lib/studio/settings";
import { renameGatewayAgent } from "@/lib/gateway/agentConfig";
import {
runStudioBootstrapLoadOperation,
executeStudioBootstrapLoadCommands,
@@ -48,6 +49,10 @@ import {
} from "@/lib/text/message-extract";
import { resolveOfficeIntentSnapshot } from "@/lib/office/deskDirectives";
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
import {
AgentEditorModal,
type AgentEditorSection,
} from "@/features/agents/components/AgentEditorModal";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import {
executeHistorySyncCommands,
@@ -66,6 +71,7 @@ import {
type GatewayModelChoice,
} from "@/lib/gateway/models";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import { randomUUID } from "@/lib/uuid";
import {
HQSidebar,
@@ -80,6 +86,7 @@ import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSki
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
import { useRunLog } from "@/features/office/hooks/useRunLog";
import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistantReplyListener";
import { useStudioOfficePreference } from "@/hooks/useStudioOfficePreference";
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
import {
useVoiceRecorder,
@@ -370,6 +377,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
status: "error",
color: stringToColor(agent.agentId),
item: getDeterministicItem(agent.agentId),
avatarProfile: agent.avatarProfile ?? null,
};
}
const isWorking = agent.status === "running" || Boolean(agent.runId);
@@ -379,6 +387,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
status: isWorking ? "working" : "idle",
color: stringToColor(agent.agentId),
item: getDeterministicItem(agent.agentId),
avatarProfile: agent.avatarProfile ?? null,
};
};
@@ -628,7 +637,13 @@ const inferRunningFromAgentSessions = async (params: {
};
};
export function OfficeScreen() {
type OfficeScreenProps = {
showOpenClawConsole?: boolean;
};
export function OfficeScreen({
showOpenClawConsole = true,
}: OfficeScreenProps) {
const searchParams = useSearchParams();
const debugEnabled = searchParams.get("officeDebug") === "1";
const [settingsCoordinator] = useState(() =>
@@ -679,7 +694,7 @@ export function OfficeScreen() {
OpenClawLogEntry[]
>([]);
const [openClawConsoleCollapsed, setOpenClawConsoleCollapsed] =
useState(false);
useState(true);
const [openClawConsoleSearch, setOpenClawConsoleSearch] = useState("");
const [openClawConsoleCopyStatus, setOpenClawConsoleCopyStatus] = useState<
"idle" | "copied" | "error"
@@ -721,6 +736,9 @@ export function OfficeScreen() {
const [selectedChatAgentId, setSelectedChatAgentId] = useState<string | null>(
null,
);
const [agentEditorAgentId, setAgentEditorAgentId] = useState<string | null>(null);
const [agentEditorInitialSection, setAgentEditorInitialSection] =
useState<AgentEditorSection>("avatar");
const [preparedPhoneCallsByAgentId, setPreparedPhoneCallsByAgentId] = useState<
Record<string, PreparedPhoneCallEntry>
>({});
@@ -740,6 +758,14 @@ export function OfficeScreen() {
const [activeSidebarTab, setActiveSidebarTab] =
useState<HQSidebarTab>("inbox");
const router = useRouter();
const {
loaded: officeTitleLoaded,
title: officeTitle,
setTitle: setOfficeTitle,
} = useStudioOfficePreference({
gatewayUrl,
settingsCoordinator,
});
const {
loaded: voiceRepliesLoaded,
preference: voiceRepliesPreference,
@@ -764,20 +790,31 @@ export function OfficeScreen() {
speed: voiceRepliesPreference.speed,
});
const handleAvatarShuffle = useCallback(
(agentId: string) => {
const seed = randomUUID();
dispatch({ type: "updateAgent", agentId, patch: { avatarSeed: seed } });
const handleAvatarProfileSave = useCallback(
(agentId: string, profile: AgentAvatarProfile) => {
dispatch({
type: "updateAgent",
agentId,
patch: { avatarProfile: profile, avatarSeed: profile.seed },
});
const key = gatewayUrl.trim();
if (key) {
if (!key) return;
settingsCoordinator.schedulePatch(
{ avatars: { [key]: { [agentId]: seed } } },
{ avatars: { [key]: { [agentId]: profile } } },
0,
);
}
},
[dispatch, gatewayUrl, settingsCoordinator],
);
const openAgentEditor = useCallback(
(agentId: string, initialSection: AgentEditorSection = "avatar") => {
setAgentEditorAgentId(agentId);
setAgentEditorInitialSection(initialSection);
setSelectedChatAgentId(agentId);
dispatch({ type: "selectAgent", agentId });
},
[dispatch],
);
const handleDeskAssignmentChange = useCallback(
(deskUid: string, agentId: string | null) => {
@@ -1604,6 +1641,9 @@ export function OfficeScreen() {
? (state.agents.find((agent) => agent.agentId === selectedChatAgentId) ??
null)
: null;
const agentEditorAgent = agentEditorAgentId
? (state.agents.find((agent) => agent.agentId === agentEditorAgentId) ?? null)
: null;
const mainAgent =
state.agents.find((agent) => agent.agentId === MAIN_AGENT_ID) ?? null;
const runLog = useRunLog({ client, status, agents: state.agents });
@@ -2715,10 +2755,13 @@ export function OfficeScreen() {
monitorAgentId={monitorAgentId}
monitorByAgentId={monitorByAgentId}
githubSkill={githubSkill}
officeTitle={officeTitle}
officeTitleLoaded={officeTitleLoaded}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
voiceRepliesLoaded={voiceRepliesLoaded}
onOfficeTitleChange={setOfficeTitle}
onVoiceRepliesToggle={setVoiceRepliesEnabled}
onVoiceRepliesVoiceChange={setVoiceRepliesVoiceId}
onVoiceRepliesSpeedChange={setVoiceRepliesSpeed}
@@ -2762,6 +2805,9 @@ export function OfficeScreen() {
dispatch({ type: "selectAgent", agentId });
}
}}
onAgentEdit={(agentId) => {
openAgentEditor(agentId, "avatar");
}}
onDeskAssignmentChange={handleDeskAssignmentChange}
onDeskAssignmentsReset={handleDeskAssignmentsReset}
onGithubReviewDismiss={() => {
@@ -2864,6 +2910,7 @@ export function OfficeScreen() {
/>
) : null}
{showOpenClawConsole ? (
<section className="pointer-events-auto fixed bottom-3 left-3 z-30 flex w-[520px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded border border-cyan-500/25 bg-black/78 shadow-2xl backdrop-blur">
<div className="flex items-center justify-between border-b border-cyan-500/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-cyan-200/80">
<span>OpenClaw Event Console</span>
@@ -3073,6 +3120,7 @@ export function OfficeScreen() {
</div>
) : null}
</section>
) : null}
<div
className={`fixed bottom-3 z-30 flex flex-col items-end gap-2 ${sidebarOpen ? "right-84" : "right-3"} ${
@@ -3183,7 +3231,7 @@ export function OfficeScreen() {
);
}}
onAvatarShuffle={() =>
handleAvatarShuffle(focusedChatAgent.agentId)
openAgentEditor(focusedChatAgent.agentId, "avatar")
}
onVoiceSend={handleVoiceSend}
/>
@@ -3307,6 +3355,29 @@ export function OfficeScreen() {
)}
</section>
) : null}
{agentEditorAgent ? (
<AgentEditorModal
open
client={client}
agents={state.agents}
agent={agentEditorAgent}
initialSection={agentEditorInitialSection}
onClose={() => {
setAgentEditorAgentId(null);
}}
onAvatarSave={handleAvatarProfileSave}
onRename={async (agentId, name) => {
if (!client) return false;
try {
await renameGatewayAgent({ client, agentId, name });
dispatch({ type: "updateAgent", agentId, patch: { name } });
return true;
} catch {
return false;
}
}}
/>
) : null}
</main>
);
}
+197 -85
View File
@@ -9,6 +9,7 @@ import {
Armchair,
Settings2,
Camera,
Users,
X,
} from "lucide-react";
import {
@@ -1842,6 +1843,7 @@ function useAgentTick(
// ============================================================
const AWAY_THRESHOLD_MS = 15 * 60 * 1000;
const COMPACT_AGENT_BADGE_LIMIT = 6;
const estimatePhoneSpeechDurationMs = (text: string | null | undefined): number => {
const normalized = text?.trim() ?? "";
@@ -1850,6 +1852,15 @@ const estimatePhoneSpeechDurationMs = (text: string | null | undefined): number
return Math.max(5_000, Math.min(12_000, 1_800 + wordCount * 380));
};
const getAgentInitials = (name: string | null | undefined): string => {
const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
return parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
};
export function RetroOffice3D({
agents,
animationState = null,
@@ -1869,10 +1880,13 @@ export function RetroOffice3D({
monitorAgentId = null,
monitorByAgentId = {},
githubSkill = null,
officeTitle = "Luke Headquarters",
officeTitleLoaded = false,
voiceRepliesEnabled = false,
voiceRepliesVoiceId = null,
voiceRepliesSpeed = 1,
voiceRepliesLoaded = false,
onOfficeTitleChange,
onVoiceRepliesToggle,
onVoiceRepliesVoiceChange,
onVoiceRepliesSpeedChange,
@@ -1886,6 +1900,7 @@ export function RetroOffice3D({
onStandupArrivalsChange,
onStandupStartRequested,
onMonitorSelect,
onAgentEdit,
onDeskAssignmentChange,
onDeskAssignmentsReset,
onGithubReviewDismiss,
@@ -1922,10 +1937,13 @@ export function RetroOffice3D({
monitorAgentId?: string | null;
monitorByAgentId?: OfficeDeskMonitorMap;
githubSkill?: SkillStatusEntry | null;
officeTitle?: string;
officeTitleLoaded?: boolean;
voiceRepliesEnabled?: boolean;
voiceRepliesVoiceId?: string | null;
voiceRepliesSpeed?: number;
voiceRepliesLoaded?: boolean;
onOfficeTitleChange?: (title: string) => void;
onVoiceRepliesToggle?: (enabled: boolean) => void;
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
onVoiceRepliesSpeedChange?: (speed: number) => void;
@@ -1945,6 +1963,7 @@ export function RetroOffice3D({
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
onStandupStartRequested?: () => void;
onMonitorSelect?: (agentId: string | null) => void;
onAgentEdit?: (agentId: string) => void;
onDeskAssignmentChange?: (deskUid: string, agentId: string | null) => void;
onDeskAssignmentsReset?: (deskUids: string[]) => void;
onGithubReviewDismiss?: () => void;
@@ -2006,6 +2025,7 @@ export function RetroOffice3D({
const [spaceDown, setSpaceDown] = useState(false);
const [spaceDragging, setSpaceDragging] = useState(false);
const [standupBoardOpen, setStandupBoardOpen] = useState(false);
const [agentRosterOpen, setAgentRosterOpen] = useState(false);
const autoOpenedStandupIdRef = useRef<string | null>(null);
// Idea 1 (original): hovered agent for tooltip overlay.
const [hoveredAgentId, setHoveredAgentId] = useState<string | null>(null);
@@ -2447,6 +2467,11 @@ export function RetroOffice3D({
githubImmersive ||
qaImmersive ||
standupImmersive;
const compactRosterAgents = useMemo(
() => agents.slice(0, COMPACT_AGENT_BADGE_LIMIT),
[agents],
);
const hiddenAgentCount = Math.max(0, agents.length - compactRosterAgents.length);
const standupActive =
standupMeeting?.phase === "gathering" ||
standupMeeting?.phase === "in_progress";
@@ -2465,6 +2490,10 @@ export function RetroOffice3D({
) ?? null
);
}, [assignedDeskIndexByAgentId, deskLocations, furniture, monitorAgentId]);
useEffect(() => {
if (!immersiveOverlayActive) return;
setAgentRosterOpen(false);
}, [immersiveOverlayActive]);
const selectedItem = useMemo(
() => furniture.find((item) => item._uid === selectedUid) ?? null,
[furniture, selectedUid],
@@ -4824,6 +4853,7 @@ export function RetroOffice3D({
name={agent.name}
status={agent.status}
color={agentColorMap.get(agent.id) ?? "#888"}
appearance={"avatarProfile" in agent ? agent.avatarProfile ?? null : null}
agentsRef={renderAgentsRef}
agentLookupRef={renderAgentLookupRef}
onHover={isJanitor ? undefined : handleAgentHover}
@@ -4943,7 +4973,8 @@ export function RetroOffice3D({
{/* New Idea 2: Camera preset buttons — top left. */}
{!immersiveOverlayActive ? (
<div className="absolute top-3 left-3 flex items-center gap-1 z-10">
<div className="absolute top-3 left-3 z-20 flex flex-col items-start gap-2">
<div className="flex items-center gap-1">
{(
[
{
@@ -4971,22 +5002,12 @@ export function RetroOffice3D({
</button>
))}
</div>
) : null}
{/* Title — top center overlay. */}
{!immersiveOverlayActive ? (
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-3 pointer-events-none select-none z-10">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
Luke Headquarters
</span>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
</div>
) : null}
{!immersiveOverlayActive && standupMeeting ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<div className="rounded-xl border border-emerald-500/20 bg-[#0b1410]/90 px-3 py-2 text-right shadow-lg backdrop-blur-sm">
{standupMeeting ? (
<button
type="button"
onClick={() => setStandupBoardOpen(true)}
className="rounded-xl border border-emerald-500/20 bg-[#0b1410]/90 px-3 py-2 text-left shadow-lg backdrop-blur-sm transition-colors hover:border-emerald-400/35 hover:bg-[#102017]/95"
>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-200/80">
Standup
</div>
@@ -5001,21 +5022,28 @@ export function RetroOffice3D({
{standupMeeting.arrivedAgentIds.length}/
{standupMeeting.participantOrder.length} arrived
</div>
</div>
<button
type="button"
onClick={() => setStandupBoardOpen(true)}
className="rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-100 transition-colors hover:border-emerald-400/50 hover:text-white"
>
Standup board
</button>
) : null}
</div>
) : null}
{/* Agent cards — compact single row pinned to top. */}
{/* Title — top center overlay. */}
{!immersiveOverlayActive ? (
<div className="absolute top-10 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
{agents.map((agent) => {
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-3 pointer-events-none select-none z-10">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
{officeTitle}
</span>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
</div>
) : null}
{/* Agent roster — compact top summary with overflow panel. */}
{!immersiveOverlayActive ? (
<div className="absolute top-10 left-1/2 z-20 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-full border border-amber-900/25 bg-[#1c1610]/92 px-2 py-2 shadow-lg backdrop-blur-sm">
<div className="flex items-center -space-x-1.5">
{compactRosterAgents.map((agent) => {
const status = agentStatusLookup[agent.id];
const isError = status?.isError ?? agent.status === "error";
const working = status?.working ?? agent.status === "working";
@@ -5026,17 +5054,25 @@ export function RetroOffice3D({
? "bg-green-400"
: "bg-yellow-400";
return (
<div
<button
key={agent.id}
className="relative flex items-center gap-2 bg-[#1c1610]/90 backdrop-blur-sm px-2.5 py-1.5 rounded-lg border border-amber-900/20 shadow cursor-pointer select-none"
onClick={() =>
setSpotlightAgentId((prev) =>
prev === agent.id ? null : agent.id,
)
}
type="button"
title={agent.name}
onMouseEnter={() => handleAgentHover(agent.id)}
onMouseLeave={handleAgentUnhover}
onClick={() => {
setSpotlightAgentId(agent.id);
onAgentEdit?.(agent.id);
}}
className={`relative flex h-8 w-8 items-center justify-center rounded-full border text-[9px] font-bold text-[#120e08] shadow transition-transform hover:-translate-y-0.5 ${
spotlightAgentId === agent.id
? "border-amber-200/80 ring-2 ring-amber-200/20"
: "border-[#120e08] hover:border-amber-200/50"
}`}
style={{ backgroundColor: agent.color }}
>
{/* E3 Idea 1: Mood emoji float. */}
{mood && (
{mood ? (
<span
key={mood.ts}
className="absolute -top-6 left-1/2 -translate-x-1/2 text-sm pointer-events-none"
@@ -5044,70 +5080,142 @@ export function RetroOffice3D({
>
{mood.emoji}
</span>
)}
<div className="relative shrink-0">
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: agent.color }}
) : null}
<span>{getAgentInitials(agent.name)}</span>
<span
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-[#1c1610] ${dotClass}`}
/>
</button>
);
})}
{hiddenAgentCount > 0 ? (
<button
type="button"
onClick={() => setAgentRosterOpen(true)}
className="flex h-8 min-w-8 items-center justify-center rounded-full border border-amber-900/30 bg-[#120e08] px-2 text-[10px] font-semibold text-amber-200 transition-colors hover:border-amber-500/40 hover:text-white"
>
+{hiddenAgentCount}
</button>
) : null}
</div>
<button
type="button"
onClick={() => setAgentRosterOpen((prev) => !prev)}
className="inline-flex items-center gap-2 rounded-full border border-amber-900/25 bg-black/20 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-100 transition-colors hover:border-amber-500/35 hover:text-white"
>
<Users className="h-3.5 w-3.5" />
<span>{agents.length}</span>
<span className="hidden sm:inline">agents</span>
</button>
</div>
{agentRosterOpen ? (
<div className="absolute left-1/2 top-full mt-2 w-[min(92vw,560px)] -translate-x-1/2 rounded-2xl border border-amber-900/25 bg-[#120e08]/96 p-3 shadow-2xl backdrop-blur-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-amber-500/70">
Team roster
</div>
<div className="mt-1 text-sm font-semibold text-amber-100">
Compact view for larger fleets.
</div>
</div>
<button
type="button"
onClick={() => setAgentRosterOpen(false)}
className="rounded-full border border-amber-900/25 p-2 text-amber-200 transition-colors hover:border-amber-500/35 hover:text-white"
aria-label="Close roster"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="grid max-h-[min(60vh,420px)] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{agents.map((agent) => {
const status = agentStatusLookup[agent.id];
const isError = status?.isError ?? agent.status === "error";
const working = status?.working ?? agent.status === "working";
const dotClass = isError
? "bg-red-400"
: working
? "bg-green-400"
: "bg-yellow-400";
const runCount = runCountByAgentId[agent.id] ?? 0;
return (
<div
className={`absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-[#1c1610] ${dotClass}`}
key={agent.id}
className="flex items-center gap-2 rounded-xl border border-amber-900/20 bg-black/20 px-3 py-2"
>
<button
type="button"
onClick={() => {
setSpotlightAgentId(agent.id);
onAgentEdit?.(agent.id);
setAgentRosterOpen(false);
}}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<div
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[10px] font-bold text-[#120e08]"
style={{ backgroundColor: agent.color }}
>
{getAgentInitials(agent.name)}
<span
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-[#120e08] ${dotClass}`}
/>
</div>
<span className="text-[10px] font-semibold text-amber-100 whitespace-nowrap">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-amber-100">
{agent.name}
</span>
{/* Follow cam toggle button. */}
<button
title={
followAgentId === agent.id
? "Exit follow cam"
: "Follow cam"
}
onClick={(e) => {
e.stopPropagation();
setFollowAgentId((prev) =>
prev === agent.id ? null : agent.id,
);
}}
className={`w-4 h-4 flex items-center justify-center rounded transition-colors ${
followAgentId === agent.id
? "text-white opacity-100"
: "text-white/50 hover:text-white opacity-70 hover:opacity-100"
}`}
>
<Camera size={9} />
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-amber-500/70">
{isError ? "error" : working ? "working" : "idle"}
{runCount > 0 ? ` · ${runCount} runs` : ""}
</div>
</div>
</button>
<button
type="button"
title={
followAgentId === agent.id ? "Exit follow cam" : "Follow cam"
}
onClick={() =>
setFollowAgentId((prev) => (prev === agent.id ? null : agent.id))
}
className={`flex h-8 w-8 items-center justify-center rounded-lg border transition-colors ${
followAgentId === agent.id
? "border-amber-200/30 bg-amber-100/10 text-white"
: "border-amber-900/20 text-white/60 hover:border-amber-500/35 hover:text-white"
}`}
>
<Camera size={12} />
</button>
<button
type="button"
title={
monitorAgentId === agent.id
? "Close desk monitor"
: "Open desk monitor"
}
onClick={(e) => {
e.stopPropagation();
onMonitorSelect?.(
monitorAgentId === agent.id ? null : agent.id,
);
}}
className={`w-4 h-4 flex items-center justify-center rounded transition-colors ${
onClick={() =>
onMonitorSelect?.(monitorAgentId === agent.id ? null : agent.id)
}
className={`flex h-8 w-8 items-center justify-center rounded-lg border transition-colors ${
monitorAgentId === agent.id
? "text-emerald-300 opacity-100"
: "text-white/50 hover:text-emerald-200 opacity-70 hover:opacity-100"
? "border-emerald-300/30 bg-emerald-300/10 text-emerald-200"
: "border-amber-900/20 text-white/60 hover:border-emerald-400/30 hover:text-emerald-200"
}`}
>
<Monitor size={9} />
<Monitor size={12} />
</button>
{/* Idea 9 (original): Run counter badge. */}
{(runCountByAgentId[agent.id] ?? 0) > 0 && (
<span className="text-[8px] font-bold bg-amber-600/30 text-amber-400 border border-amber-700/30 rounded-full px-1.5 py-0.5 leading-none">
{runCountByAgentId[agent.id]}
</span>
)}
</div>
);
})}
</div>
</div>
) : null}
</div>
) : null}
{/* Idea 1: Agent tooltip — shown when hovering an agent in the 3D scene. */}
@@ -5901,27 +6009,27 @@ export function RetroOffice3D({
</div>
) : null}
{settingsModalOpen ? (
<div className="absolute inset-0 z-30 flex items-start justify-end bg-black/35 p-4 backdrop-blur-[1px]">
<div className="w-full max-w-sm overflow-hidden rounded-xl border border-cyan-500/20 bg-[#05090d]/95 shadow-2xl">
<div className="absolute inset-0 z-30 flex items-start justify-end overflow-y-auto bg-black/35 p-4 backdrop-blur-[1px]">
<div className="flex max-h-[calc(100vh-2rem)] w-full max-w-sm flex-col overflow-hidden rounded-xl border border-cyan-500/20 bg-[#05090d]/95 shadow-2xl">
<div className="flex items-start justify-between border-b border-cyan-500/10 px-4 py-3">
<div>
<div className="font-mono text-[10px] font-semibold tracking-[0.28em] text-cyan-300/75">
VOICE SETTINGS
STUDIO SETTINGS
</div>
<div className="mt-1 text-[11px] text-white/45">
Control natural-sounding spoken replies for agents across the
app.
Customize the office banner and spoken replies across the app.
</div>
</div>
<button
type="button"
onClick={() => setSettingsModalOpen(false)}
className="flex h-7 w-7 items-center justify-center rounded-md border border-cyan-500/10 bg-black/20 text-cyan-100/70 transition-colors hover:border-cyan-400/30 hover:text-cyan-100"
aria-label="Close voice settings"
aria-label="Close studio settings"
>
<X size={12} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<SettingsPanel
gatewayStatus={gatewayStatus}
gatewayUrl={atmAnalytics?.gatewayUrl}
@@ -5929,6 +6037,9 @@ export function RetroOffice3D({
onGatewayDisconnect?.();
setSettingsModalOpen(false);
}}
officeTitle={officeTitle}
officeTitleLoaded={officeTitleLoaded}
onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
@@ -5948,6 +6059,7 @@ export function RetroOffice3D({
/>
</div>
</div>
</div>
) : null}
{/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
+3
View File
@@ -1,9 +1,12 @@
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
export type OfficeAgent = {
id: string;
name: string;
status: "working" | "idle" | "error";
color: string;
item: string;
avatarProfile?: AgentAvatarProfile | null;
};
export type JanitorTool = "broom" | "vacuum" | "floor_scrubber";
+219 -7
View File
@@ -2,6 +2,7 @@ import { Billboard, Text } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { memo, useMemo, useRef } from "react";
import * as THREE from "three";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import {
AGENT_SCALE,
WALK_ANIM_SPEED,
@@ -15,6 +16,7 @@ export const AgentModel = memo(function AgentModel({
name,
status,
color,
appearance,
agentsRef,
agentLookupRef,
onHover,
@@ -53,6 +55,10 @@ export const AgentModel = memo(function AgentModel({
const awayBubbleRef = useRef<THREE.Group>(null);
const bodyMatRef = useRef<THREE.MeshLambertMaterial>(null);
const pos = useRef(new THREE.Vector3(0, 0, 0));
const resolvedAppearance = useMemo(
() => appearance ?? createDefaultAgentAvatarProfile(agentId),
[agentId, appearance]
);
useFrame(() => {
const agent =
@@ -463,10 +469,22 @@ export const AgentModel = memo(function AgentModel({
}
});
const skin = "#f4c58a";
const trouserColor = "#2d3748";
const shoeColor = "#1a1a1a";
const hairColor = "#3e2723";
const skin = resolvedAppearance.body.skinTone;
const topColor = resolvedAppearance.clothing.topColor;
const trouserColor = resolvedAppearance.clothing.bottomColor;
const shoeColor = resolvedAppearance.clothing.shoesColor;
const hairColor = resolvedAppearance.hair.color;
const hairStyle = resolvedAppearance.hair.style;
const topStyle = resolvedAppearance.clothing.topStyle;
const bottomStyle = resolvedAppearance.clothing.bottomStyle;
const hatStyle = resolvedAppearance.accessories.hatStyle;
const showGlasses = resolvedAppearance.accessories.glasses;
const showHeadset = resolvedAppearance.accessories.headset;
const showBackpack = resolvedAppearance.accessories.backpack;
const accessoryColor = topColor;
const sleeveColor = topStyle === "jacket" ? "#dbe4ff" : topColor;
const cuffColor = topStyle === "hoodie" ? "#d1d5db" : sleeveColor;
const topAccentColor = topStyle === "jacket" ? "#1f2937" : cuffColor;
const faceTexture = useMemo(() => {
const canvas = document.createElement("canvas");
@@ -574,34 +592,122 @@ export const AgentModel = memo(function AgentModel({
<meshBasicMaterial color="#000" transparent opacity={0.2} />
</mesh>
<group ref={rightLegRef} position={[-0.045, 0.1, 0]}>
{bottomStyle === "shorts" ? (
<>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.08, 0.08]} />
<meshLambertMaterial color={trouserColor} />
</mesh>
<mesh position={[0, -0.045, 0]}>
<boxGeometry args={[0.05, 0.06, 0.05]} />
<meshLambertMaterial color={skin} />
</mesh>
</>
) : (
<>
<mesh>
<boxGeometry args={[0.07, 0.14, 0.08]} />
<meshLambertMaterial color={trouserColor} />
</mesh>
{bottomStyle === "cuffed" ? (
<mesh position={[0, -0.05, 0]}>
<boxGeometry args={[0.074, 0.022, 0.084]} />
<meshLambertMaterial color="#d1d5db" />
</mesh>
) : null}
</>
)}
<mesh position={[0, -0.09, 0]}>
<boxGeometry args={[0.07, 0.05, 0.12]} />
<meshLambertMaterial color={shoeColor} />
</mesh>
</group>
<group ref={leftLegRef} position={[0.045, 0.1, 0]}>
{bottomStyle === "shorts" ? (
<>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.08, 0.08]} />
<meshLambertMaterial color={trouserColor} />
</mesh>
<mesh position={[0, -0.045, 0]}>
<boxGeometry args={[0.05, 0.06, 0.05]} />
<meshLambertMaterial color={skin} />
</mesh>
</>
) : (
<>
<mesh>
<boxGeometry args={[0.07, 0.14, 0.08]} />
<meshLambertMaterial color={trouserColor} />
</mesh>
{bottomStyle === "cuffed" ? (
<mesh position={[0, -0.05, 0]}>
<boxGeometry args={[0.074, 0.022, 0.084]} />
<meshLambertMaterial color="#d1d5db" />
</mesh>
) : null}
</>
)}
<mesh position={[0, -0.09, 0]}>
<boxGeometry args={[0.07, 0.05, 0.12]} />
<meshLambertMaterial color={shoeColor} />
</mesh>
</group>
{showBackpack ? (
<group position={[0, 0.28, -0.08]}>
<mesh>
<boxGeometry args={[0.15, 0.18, 0.06]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
<mesh position={[-0.06, 0.02, 0.02]}>
<boxGeometry args={[0.018, 0.16, 0.018]} />
<meshLambertMaterial color="#cbd5e1" />
</mesh>
<mesh position={[0.06, 0.02, 0.02]}>
<boxGeometry args={[0.018, 0.16, 0.018]} />
<meshLambertMaterial color="#cbd5e1" />
</mesh>
</group>
) : null}
<mesh position={[0, 0.28, 0]}>
<boxGeometry args={[0.18, 0.2, 0.1]} />
<meshLambertMaterial ref={bodyMatRef} color={color} />
<meshLambertMaterial ref={bodyMatRef} color={topColor} />
</mesh>
{topStyle === "hoodie" ? (
<>
<mesh position={[0, 0.35, -0.045]}>
<boxGeometry args={[0.17, 0.1, 0.03]} />
<meshLambertMaterial color={topColor} />
</mesh>
<mesh position={[0, 0.22, 0.056]}>
<boxGeometry args={[0.11, 0.03, 0.012]} />
<meshLambertMaterial color={cuffColor} />
</mesh>
</>
) : null}
{topStyle === "jacket" ? (
<>
<mesh position={[0, 0.28, 0.056]}>
<boxGeometry args={[0.182, 0.21, 0.012]} />
<meshLambertMaterial color={topAccentColor} />
</mesh>
<mesh position={[0, 0.28, 0.063]}>
<boxGeometry args={[0.034, 0.2, 0.01]} />
<meshLambertMaterial color="#f8fafc" />
</mesh>
</>
) : null}
<group ref={rightArmRef} position={[-0.12, 0.28, 0]}>
<mesh position={[0, -0.08, 0]}>
<boxGeometry args={[0.06, 0.16, 0.06]} />
<meshLambertMaterial color={color} />
<meshLambertMaterial color={sleeveColor} />
</mesh>
{topStyle === "hoodie" ? (
<mesh position={[0, -0.145, 0]}>
<boxGeometry args={[0.064, 0.03, 0.064]} />
<meshLambertMaterial color={cuffColor} />
</mesh>
) : null}
<mesh position={[0, -0.17, 0]}>
<boxGeometry args={[0.05, 0.05, 0.05]} />
<meshLambertMaterial color={skin} />
@@ -681,8 +787,14 @@ export const AgentModel = memo(function AgentModel({
<group ref={leftArmRef} position={[0.12, 0.28, 0]}>
<mesh position={[0, -0.08, 0]}>
<boxGeometry args={[0.06, 0.16, 0.06]} />
<meshLambertMaterial color={color} />
<meshLambertMaterial color={sleeveColor} />
</mesh>
{topStyle === "hoodie" ? (
<mesh position={[0, -0.145, 0]}>
<boxGeometry args={[0.064, 0.03, 0.064]} />
<meshLambertMaterial color={cuffColor} />
</mesh>
) : null}
<mesh position={[0, -0.17, 0]}>
<boxGeometry args={[0.05, 0.05, 0.05]} />
<meshLambertMaterial color={skin} />
@@ -701,10 +813,94 @@ export const AgentModel = memo(function AgentModel({
<meshLambertMaterial attach="material-4" map={faceTexture} />
<meshLambertMaterial attach="material-5" color={skin} />
</mesh>
{hairStyle === "short" ? (
<mesh position={[0, 0.555, 0]}>
<boxGeometry args={[0.17, 0.05, 0.15]} />
<meshLambertMaterial color={hairColor} />
</mesh>
) : null}
{hairStyle === "parted" ? (
<>
<mesh position={[0, 0.555, 0]}>
<boxGeometry args={[0.17, 0.045, 0.15]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[-0.035, 0.59, 0.01]} rotation={[0.1, 0, -0.2]}>
<boxGeometry args={[0.12, 0.03, 0.08]} />
<meshLambertMaterial color={hairColor} />
</mesh>
</>
) : null}
{hairStyle === "spiky" ? (
<>
<mesh position={[0, 0.55, 0]}>
<boxGeometry args={[0.16, 0.035, 0.14]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[-0.05, 0.59, 0]} rotation={[0, 0, -0.2]}>
<boxGeometry args={[0.04, 0.06, 0.04]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[0, 0.605, 0]} rotation={[0, 0, 0]}>
<boxGeometry args={[0.04, 0.08, 0.04]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[0.05, 0.59, 0]} rotation={[0, 0, 0.2]}>
<boxGeometry args={[0.04, 0.06, 0.04]} />
<meshLambertMaterial color={hairColor} />
</mesh>
</>
) : null}
{hairStyle === "bun" ? (
<>
<mesh position={[0, 0.548, 0]}>
<boxGeometry args={[0.17, 0.04, 0.15]} />
<meshLambertMaterial color={hairColor} />
</mesh>
<mesh position={[0, 0.6, -0.035]}>
<sphereGeometry args={[0.042, 14, 14]} />
<meshLambertMaterial color={hairColor} />
</mesh>
</>
) : null}
{hatStyle === "cap" ? (
<>
<mesh position={[0, 0.59, 0]}>
<boxGeometry args={[0.172, 0.03, 0.152]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
<mesh position={[0, 0.575, 0.07]}>
<boxGeometry args={[0.09, 0.012, 0.05]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
</>
) : null}
{hatStyle === "beanie" ? (
<mesh position={[0, 0.59, 0]}>
<boxGeometry args={[0.18, 0.06, 0.16]} />
<meshLambertMaterial color={accessoryColor} />
</mesh>
) : null}
{showHeadset ? (
<>
<mesh position={[0, 0.57, 0]} rotation={[0, 0, Math.PI / 2]}>
<torusGeometry args={[0.09, 0.008, 8, 24, Math.PI]} />
<meshLambertMaterial color="#94a3b8" />
</mesh>
<mesh position={[-0.1, 0.48, 0]}>
<boxGeometry args={[0.018, 0.05, 0.028]} />
<meshLambertMaterial color="#475569" />
</mesh>
<mesh position={[0.1, 0.48, 0]}>
<boxGeometry args={[0.018, 0.05, 0.028]} />
<meshLambertMaterial color="#475569" />
</mesh>
<mesh position={[0.085, 0.43, 0.06]} rotation={[0.25, 0.25, -0.4]}>
<boxGeometry args={[0.012, 0.06, 0.012]} />
<meshLambertMaterial color="#94a3b8" />
</mesh>
</>
) : null}
<mesh ref={leftBrowRef} position={[-0.04, 0.52, 0.074]}>
<boxGeometry args={[0.04, 0.01, 0.01]} />
<meshBasicMaterial color="#342016" />
@@ -729,6 +925,22 @@ export const AgentModel = memo(function AgentModel({
<boxGeometry args={[0.008, 0.008, 0.01]} />
<meshBasicMaterial color="#fff" />
</mesh>
{showGlasses ? (
<>
<mesh position={[-0.04, 0.475, 0.078]}>
<boxGeometry args={[0.05, 0.05, 0.01]} />
<meshBasicMaterial color="#111827" wireframe />
</mesh>
<mesh position={[0.04, 0.475, 0.078]}>
<boxGeometry args={[0.05, 0.05, 0.01]} />
<meshBasicMaterial color="#111827" wireframe />
</mesh>
<mesh position={[0, 0.475, 0.078]}>
<boxGeometry args={[0.02, 0.008, 0.01]} />
<meshBasicMaterial color="#111827" />
</mesh>
</>
) : null}
<mesh ref={mouthRef} position={[0, 0.436, 0.074]}>
<boxGeometry args={[0.05, 0.014, 0.01]} />
<meshBasicMaterial color="#9c4a4a" />
@@ -1,3 +1,4 @@
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import type { RefObject } from "react";
import type {
FurnitureItem,
@@ -30,6 +31,7 @@ export type AgentModelProps = {
name: string;
status: OfficeAgent["status"];
color: string;
appearance?: AgentAvatarProfile | null;
agentsRef: RefObject<RenderAgent[]>;
agentLookupRef?: RefObject<Map<string, RenderAgent>>;
onHover?: (id: string) => void;
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
import {
defaultStudioOfficePreference,
resolveOfficePreference,
type StudioOfficePreference,
} from "@/lib/studio/settings";
type UseStudioOfficePreferenceParams = {
gatewayUrl: string;
settingsCoordinator: StudioSettingsCoordinator;
};
export const useStudioOfficePreference = ({
gatewayUrl,
settingsCoordinator,
}: UseStudioOfficePreferenceParams) => {
const [preference, setPreference] = useState<StudioOfficePreference>(
defaultStudioOfficePreference()
);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
const gatewayKey = gatewayUrl.trim();
if (!gatewayKey) {
setPreference(defaultStudioOfficePreference());
setLoaded(true);
return;
}
setLoaded(false);
const loadPreference = async () => {
try {
const settings = await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 });
if (cancelled) return;
setPreference(
settings ? resolveOfficePreference(settings, gatewayKey) : defaultStudioOfficePreference()
);
} catch (error) {
if (!cancelled) {
console.error("Failed to load office preference.", error);
setPreference(defaultStudioOfficePreference());
}
} finally {
if (!cancelled) {
setLoaded(true);
}
}
};
void loadPreference();
return () => {
cancelled = true;
};
}, [gatewayUrl, settingsCoordinator]);
const setTitle = useCallback(
(title: string) => {
const gatewayKey = gatewayUrl.trim();
setPreference((current) => ({ ...current, title }));
if (!gatewayKey) return;
settingsCoordinator.schedulePatch(
{
office: {
[gatewayKey]: {
title,
},
},
},
0
);
},
[gatewayUrl, settingsCoordinator]
);
return {
loaded,
preference,
title: preference.title,
setTitle,
};
};
+272
View File
@@ -0,0 +1,272 @@
export type AgentAvatarHairStyle = "short" | "parted" | "spiky" | "bun";
export type AgentAvatarTopStyle = "tee" | "hoodie" | "jacket";
export type AgentAvatarBottomStyle = "pants" | "shorts" | "cuffed";
export type AgentAvatarHatStyle = "none" | "cap" | "beanie";
export type AgentAvatarProfile = {
version: 1;
seed: string;
body: {
skinTone: string;
};
hair: {
style: AgentAvatarHairStyle;
color: string;
};
clothing: {
topStyle: AgentAvatarTopStyle;
topColor: string;
bottomStyle: AgentAvatarBottomStyle;
bottomColor: string;
shoesColor: string;
};
accessories: {
glasses: boolean;
headset: boolean;
hatStyle: AgentAvatarHatStyle;
backpack: boolean;
};
};
type ColorOption = {
id: string;
label: string;
color: string;
};
type EnumOption<T extends string> = {
id: T;
label: string;
};
export const AGENT_AVATAR_SKIN_TONE_OPTIONS: ColorOption[] = [
{ id: "fair", label: "Fair", color: "#f7d7c2" },
{ id: "light", label: "Light", color: "#f4c58a" },
{ id: "warm", label: "Warm", color: "#d8a06e" },
{ id: "tan", label: "Tan", color: "#b7794e" },
{ id: "deep", label: "Deep", color: "#8a5a3b" },
{ id: "rich", label: "Rich", color: "#5d3a24" },
];
export const AGENT_AVATAR_HAIR_STYLE_OPTIONS: EnumOption<AgentAvatarHairStyle>[] = [
{ id: "short", label: "Short" },
{ id: "parted", label: "Parted" },
{ id: "spiky", label: "Spiky" },
{ id: "bun", label: "Bun" },
];
export const AGENT_AVATAR_HAIR_COLOR_OPTIONS: ColorOption[] = [
{ id: "ink", label: "Ink", color: "#151515" },
{ id: "espresso", label: "Espresso", color: "#3e2723" },
{ id: "walnut", label: "Walnut", color: "#6b4f3a" },
{ id: "auburn", label: "Auburn", color: "#7b341e" },
{ id: "blonde", label: "Blonde", color: "#d6b56c" },
{ id: "violet", label: "Violet", color: "#7c3aed" },
{ id: "cyan", label: "Cyan", color: "#0891b2" },
{ id: "pink", label: "Pink", color: "#db2777" },
];
export const AGENT_AVATAR_TOP_STYLE_OPTIONS: EnumOption<AgentAvatarTopStyle>[] = [
{ id: "tee", label: "Tee" },
{ id: "hoodie", label: "Hoodie" },
{ id: "jacket", label: "Jacket" },
];
export const AGENT_AVATAR_BOTTOM_STYLE_OPTIONS: EnumOption<AgentAvatarBottomStyle>[] = [
{ id: "pants", label: "Pants" },
{ id: "shorts", label: "Shorts" },
{ id: "cuffed", label: "Cuffed" },
];
export const AGENT_AVATAR_HAT_STYLE_OPTIONS: EnumOption<AgentAvatarHatStyle>[] = [
{ id: "none", label: "None" },
{ id: "cap", label: "Cap" },
{ id: "beanie", label: "Beanie" },
];
export const AGENT_AVATAR_CLOTHING_COLOR_OPTIONS: ColorOption[] = [
{ id: "graphite", label: "Graphite", color: "#2d3748" },
{ id: "sky", label: "Sky", color: "#7090ff" },
{ id: "mint", label: "Mint", color: "#34d399" },
{ id: "amber", label: "Amber", color: "#f59e0b" },
{ id: "rose", label: "Rose", color: "#f43f5e" },
{ id: "violet", label: "Violet", color: "#8b5cf6" },
{ id: "cream", label: "Cream", color: "#f5f5f4" },
{ id: "slate", label: "Slate", color: "#64748b" },
];
export const AGENT_AVATAR_SHOE_COLOR_OPTIONS: ColorOption[] = [
{ id: "black", label: "Black", color: "#1a1a1a" },
{ id: "navy", label: "Navy", color: "#1e3a8a" },
{ id: "brown", label: "Brown", color: "#7c4a2d" },
{ id: "white", label: "White", color: "#e5e7eb" },
];
const AGENT_AVATAR_VERSION = 1 as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const hashSeed = (seed: string) => {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const pick = <T,>(values: readonly T[], index: number) => values[index % values.length];
const resolveColor = (value: unknown, options: ColorOption[], fallback: string) => {
const color = coerceString(value).toLowerCase();
if (!color) return fallback;
const option =
options.find((entry) => entry.id === color) ??
options.find((entry) => entry.color.toLowerCase() === color);
return option?.color ?? fallback;
};
const resolveEnumOption = <T extends string>(
value: unknown,
options: EnumOption<T>[],
fallback: T,
): T => {
const normalized = coerceString(value).toLowerCase();
const match = options.find((entry) => entry.id === normalized);
return match?.id ?? fallback;
};
export const createAgentAvatarProfileFromSeed = (seed: string): AgentAvatarProfile => {
const normalizedSeed = seed.trim() || "agent";
const hash = hashSeed(normalizedSeed);
const skinTone = pick(AGENT_AVATAR_SKIN_TONE_OPTIONS, hash).color;
const hairStyle = pick(AGENT_AVATAR_HAIR_STYLE_OPTIONS, hash >>> 3).id;
const hairColor = pick(AGENT_AVATAR_HAIR_COLOR_OPTIONS, hash >>> 5).color;
const topStyle = pick(AGENT_AVATAR_TOP_STYLE_OPTIONS, hash >>> 7).id;
const topColor = pick(AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, hash >>> 9).color;
const bottomStyle = pick(AGENT_AVATAR_BOTTOM_STYLE_OPTIONS, hash >>> 11).id;
const bottomColor = pick(AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, hash >>> 13).color;
const shoesColor = pick(AGENT_AVATAR_SHOE_COLOR_OPTIONS, hash >>> 15).color;
const hatStyle = pick(AGENT_AVATAR_HAT_STYLE_OPTIONS, hash >>> 17).id;
return {
version: AGENT_AVATAR_VERSION,
seed: normalizedSeed,
body: {
skinTone,
},
hair: {
style: hairStyle,
color: hairColor,
},
clothing: {
topStyle,
topColor,
bottomStyle,
bottomColor,
shoesColor,
},
accessories: {
glasses: Boolean((hash >>> 19) % 2),
headset: Boolean((hash >>> 20) % 2),
hatStyle,
backpack: Boolean((hash >>> 21) % 2),
},
};
};
export const createDefaultAgentAvatarProfile = (seed: string): AgentAvatarProfile =>
createAgentAvatarProfileFromSeed(seed);
export const normalizeAgentAvatarProfile = (
value: unknown,
fallbackSeed: string,
): AgentAvatarProfile => {
if (typeof value === "string") {
return createAgentAvatarProfileFromSeed(value);
}
const baseProfile = createAgentAvatarProfileFromSeed(fallbackSeed);
if (!isRecord(value)) {
return baseProfile;
}
const body = isRecord(value.body) ? value.body : {};
const hair = isRecord(value.hair) ? value.hair : {};
const clothing = isRecord(value.clothing) ? value.clothing : {};
const accessories = isRecord(value.accessories) ? value.accessories : {};
const normalizedSeed = coerceString(value.seed) || baseProfile.seed;
return {
version: AGENT_AVATAR_VERSION,
seed: normalizedSeed,
body: {
skinTone: resolveColor(
body.skinTone,
AGENT_AVATAR_SKIN_TONE_OPTIONS,
baseProfile.body.skinTone,
),
},
hair: {
style: resolveEnumOption(
hair.style,
AGENT_AVATAR_HAIR_STYLE_OPTIONS,
baseProfile.hair.style,
),
color: resolveColor(
hair.color,
AGENT_AVATAR_HAIR_COLOR_OPTIONS,
baseProfile.hair.color,
),
},
clothing: {
topStyle: resolveEnumOption(
clothing.topStyle,
AGENT_AVATAR_TOP_STYLE_OPTIONS,
baseProfile.clothing.topStyle,
),
topColor: resolveColor(
clothing.topColor,
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
baseProfile.clothing.topColor,
),
bottomStyle: resolveEnumOption(
clothing.bottomStyle,
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
baseProfile.clothing.bottomStyle,
),
bottomColor: resolveColor(
clothing.bottomColor,
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
baseProfile.clothing.bottomColor,
),
shoesColor: resolveColor(
clothing.shoesColor,
AGENT_AVATAR_SHOE_COLOR_OPTIONS,
baseProfile.clothing.shoesColor,
),
},
accessories: {
glasses:
typeof accessories.glasses === "boolean"
? accessories.glasses
: baseProfile.accessories.glasses,
headset:
typeof accessories.headset === "boolean"
? accessories.headset
: baseProfile.accessories.headset,
hatStyle: resolveEnumOption(
accessories.hatStyle,
AGENT_AVATAR_HAT_STYLE_OPTIONS,
baseProfile.accessories.hatStyle,
),
backpack:
typeof accessories.backpack === "boolean"
? accessories.backpack
: baseProfile.accessories.backpack,
},
};
};
+31 -1
View File
@@ -1,8 +1,10 @@
import { fetchJson } from "@/lib/http";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import type {
StudioAnalyticsPreferencePatch,
StudioFocusedPreference,
StudioGatewaySettingsPublic,
StudioOfficePreferencePatch,
StudioSettingsPublic,
StudioSettingsPatch,
StudioStandupPreferencePatch,
@@ -20,10 +22,11 @@ export type StudioSettingsLoadOptions = {
};
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
type AvatarsPatch = Record<string, Record<string, string | null> | null>;
type AvatarsPatch = Record<string, Record<string, AgentAvatarProfile | null> | null>;
type DeskAssignmentsPatch = Record<string, Record<string, string | null> | null>;
type AnalyticsPatch = Record<string, StudioAnalyticsPreferencePatch | null>;
type VoiceRepliesPatch = Record<string, StudioVoiceRepliesPreferencePatch | null>;
type OfficePatch = Record<string, StudioOfficePreferencePatch | null>;
type StandupPatch = Record<string, StudioStandupPreferencePatch | null>;
export type StudioSettingsCoordinatorTransport = {
@@ -143,6 +146,30 @@ const mergeVoiceRepliesPatch = (
return merged;
};
const mergeOfficePatch = (
current: OfficePatch | undefined,
next: OfficePatch | undefined
): OfficePatch | undefined => {
if (!current && !next) return undefined;
const merged: OfficePatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = {
...existing,
...value,
};
continue;
}
merged[gatewayKey] = { ...value };
}
return merged;
};
const mergeStandupPatch = (
current: StandupPatch | undefined,
next: StandupPatch | undefined
@@ -210,6 +237,7 @@ const mergeStudioPatch = (
...(next.deskAssignments ? { deskAssignments: { ...next.deskAssignments } } : {}),
...(next.analytics ? { analytics: { ...next.analytics } } : {}),
...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}),
...(next.office ? { office: { ...next.office } } : {}),
...(next.standup ? { standup: { ...next.standup } } : {}),
};
}
@@ -221,6 +249,7 @@ const mergeStudioPatch = (
);
const analytics = mergeAnalyticsPatch(current.analytics, next.analytics);
const voiceReplies = mergeVoiceRepliesPatch(current.voiceReplies, next.voiceReplies);
const office = mergeOfficePatch(current.office, next.office);
const standup = mergeStandupPatch(current.standup, next.standup);
return {
...(next.gateway !== undefined
@@ -233,6 +262,7 @@ const mergeStudioPatch = (
...(deskAssignments ? { deskAssignments } : {}),
...(analytics ? { analytics } : {}),
...(voiceReplies ? { voiceReplies } : {}),
...(office ? { office } : {}),
...(standup ? { standup } : {}),
};
};
+99 -17
View File
@@ -4,6 +4,8 @@ import type {
StandupManualEntry,
StandupScheduleConfig,
} from "@/lib/office/standup/types";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import { normalizeAgentAvatarProfile } from "@/lib/avatars/profile";
export type StudioGatewaySettings = {
url: string;
@@ -60,7 +62,16 @@ export type StudioVoiceRepliesPreferencePatch = {
speed?: number;
};
export type StudioOfficePreference = {
title: string;
};
export type StudioOfficePreferencePatch = {
title?: string | null;
};
export type StudioDeskAssignments = Record<string, string>;
export type StudioAgentAvatars = Record<string, AgentAvatarProfile>;
export type StudioStandupPreference = StandupConfig;
@@ -83,10 +94,11 @@ export type StudioSettings = {
version: 1;
gateway: StudioGatewaySettings | null;
focused: Record<string, StudioFocusedPreference>;
avatars: Record<string, Record<string, string>>;
avatars: Record<string, StudioAgentAvatars>;
deskAssignments: Record<string, StudioDeskAssignments>;
analytics: Record<string, StudioAnalyticsPreference>;
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
office: Record<string, StudioOfficePreference>;
standup?: Record<string, StudioStandupPreference>;
};
@@ -98,10 +110,11 @@ export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "standup"> &
export type StudioSettingsPatch = {
gateway?: StudioGatewaySettingsPatch | null;
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
avatars?: Record<string, Record<string, string | null> | null>;
avatars?: Record<string, Record<string, AgentAvatarProfile | null> | null>;
deskAssignments?: Record<string, Record<string, string | null> | null>;
analytics?: Record<string, StudioAnalyticsPreferencePatch | null>;
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
office?: Record<string, StudioOfficePreferencePatch | null>;
standup?: Record<string, StudioStandupPreferencePatch | null>;
};
@@ -257,6 +270,20 @@ const normalizeOptionalIsoString = (
return trimmed ? trimmed : null;
};
const DEFAULT_OFFICE_TITLE = "Luke Headquarters";
const normalizeOfficeTitle = (
value: unknown,
fallback: string = DEFAULT_OFFICE_TITLE
) => {
const title = coerceString(value);
return (title || fallback).slice(0, 48);
};
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
title: DEFAULT_OFFICE_TITLE,
});
const normalizeStandupScheduleConfig = (
value: unknown,
fallback: StandupScheduleConfig = defaultStudioStandupScheduleConfig()
@@ -400,20 +427,18 @@ const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreferenc
return focused;
};
const normalizeAvatars = (value: unknown): Record<string, Record<string, string>> => {
const normalizeAvatars = (value: unknown): Record<string, StudioAgentAvatars> => {
if (!isRecord(value)) return {};
const avatars: Record<string, Record<string, string>> = {};
const avatars: Record<string, StudioAgentAvatars> = {};
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (!isRecord(gatewayRaw)) continue;
const entries: Record<string, string> = {};
for (const [agentIdRaw, seedRaw] of Object.entries(gatewayRaw)) {
const entries: StudioAgentAvatars = {};
for (const [agentIdRaw, avatarRaw] of Object.entries(gatewayRaw)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
const seed = coerceString(seedRaw);
if (!seed) continue;
entries[agentId] = seed;
entries[agentId] = normalizeAgentAvatarProfile(avatarRaw, agentId);
}
avatars[gatewayKey] = entries;
}
@@ -522,6 +547,27 @@ const normalizeVoiceReplies = (
return voiceReplies;
};
const normalizeOfficePreference = (
value: unknown,
fallback: StudioOfficePreference = defaultStudioOfficePreference()
): StudioOfficePreference => {
if (!isRecord(value)) return fallback;
return {
title: normalizeOfficeTitle(value.title, fallback.title),
};
};
const normalizeOffice = (value: unknown): Record<string, StudioOfficePreference> => {
if (!isRecord(value)) return {};
const office: Record<string, StudioOfficePreference> = {};
for (const [gatewayKeyRaw, officeRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
office[gatewayKey] = normalizeOfficePreference(officeRaw);
}
return office;
};
export const defaultStudioSettings = (): StudioSettings => ({
version: SETTINGS_VERSION,
gateway: null,
@@ -530,6 +576,7 @@ export const defaultStudioSettings = (): StudioSettings => ({
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
standup: {},
});
@@ -579,6 +626,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
const deskAssignments = normalizeDeskAssignments(raw.deskAssignments);
const analytics = normalizeAnalytics(raw.analytics);
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
const office = normalizeOffice(raw.office);
const standup = normalizeStandup(raw.standup);
return {
version: SETTINGS_VERSION,
@@ -588,6 +636,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
deskAssignments,
analytics,
voiceReplies,
office,
standup,
};
};
@@ -603,6 +652,7 @@ export const mergeStudioSettings = (
const nextDeskAssignments = { ...current.deskAssignments };
const nextAnalytics = { ...current.analytics };
const nextVoiceReplies = { ...current.voiceReplies };
const nextOffice = { ...current.office };
const nextStandup = { ...(current.standup ?? {}) };
if (patch.focused) {
for (const [keyRaw, value] of Object.entries(patch.focused)) {
@@ -626,19 +676,14 @@ export const mergeStudioSettings = (
}
if (!isRecord(gatewayPatch)) continue;
const existing = nextAvatars[gatewayKey] ? { ...nextAvatars[gatewayKey] } : {};
for (const [agentIdRaw, seedPatchRaw] of Object.entries(gatewayPatch)) {
for (const [agentIdRaw, avatarPatchRaw] of Object.entries(gatewayPatch)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
if (seedPatchRaw === null) {
if (avatarPatchRaw === null) {
delete existing[agentId];
continue;
}
const seed = coerceString(seedPatchRaw);
if (!seed) {
delete existing[agentId];
continue;
}
existing[agentId] = seed;
existing[agentId] = normalizeAgentAvatarProfile(avatarPatchRaw, agentId);
}
nextAvatars[gatewayKey] = existing;
}
@@ -713,6 +758,24 @@ export const mergeStudioSettings = (
);
}
}
if (patch.office) {
for (const [gatewayKeyRaw, officePatch] of Object.entries(patch.office)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (officePatch === null) {
delete nextOffice[gatewayKey];
continue;
}
const fallback = nextOffice[gatewayKey] ?? defaultStudioOfficePreference();
nextOffice[gatewayKey] = normalizeOfficePreference(
{
...fallback,
...officePatch,
},
fallback
);
}
}
if (patch.standup) {
for (const [gatewayKeyRaw, standupPatch] of Object.entries(patch.standup)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
@@ -769,6 +832,7 @@ export const mergeStudioSettings = (
deskAssignments: nextDeskAssignments,
analytics: nextAnalytics,
voiceReplies: nextVoiceReplies,
office: nextOffice,
standup: nextStandup,
};
};
@@ -787,6 +851,15 @@ export const resolveAgentAvatarSeed = (
gatewayUrl: string,
agentId: string
): string | null => {
const profile = resolveAgentAvatarProfile(settings, gatewayUrl, agentId);
return profile?.seed ?? null;
};
export const resolveAgentAvatarProfile = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string,
agentId: string
): AgentAvatarProfile | null => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return null;
const agentKey = coerceString(agentId);
@@ -821,6 +894,15 @@ export const resolveVoiceRepliesPreference = (
return settings.voiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
};
export const resolveOfficePreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioOfficePreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioOfficePreference();
return settings.office[gatewayKey] ?? defaultStudioOfficePreference();
};
export const resolveStandupPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
+14 -25
View File
@@ -1,34 +1,23 @@
import { expect, test } from "@playwright/test";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import { stubStudioRoute } from "./helpers/studioRoute";
test.beforeEach(async ({ page }) => {
await page.route("**/api/studio", async (route, request) => {
if (request.method() === "PUT") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
}),
});
return;
}
if (request.method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
}),
});
await stubStudioRoute(page, {
version: 1,
gateway: null,
focused: {},
avatars: {
"ws://localhost:18789": {
"agent-1": createDefaultAgentAvatarProfile("seed-1"),
},
},
});
});
test("empty focused view shows zero agents when disconnected", async ({ page }) => {
test("structured avatar settings fixture does not break focused load", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
});
+8 -29
View File
@@ -1,44 +1,23 @@
import { expect, test } from "@playwright/test";
import { stubStudioRoute } from "./helpers/studioRoute";
test.beforeEach(async ({ page }) => {
await page.route("**/api/studio", async (route, request) => {
if (request.method() === "PUT") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
}),
});
return;
}
if (request.method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
}),
});
});
await stubStudioRoute(page);
});
test("shows_connection_settings_control_in_header", async ({ page }) => {
test("shows_office_header_controls", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0);
await page.getByTestId("studio-menu-toggle").click();
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
await expect(page.getByTitle("Voice reply settings")).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
});
test("mobile_header_shows_connection_control", async ({ page }) => {
test("mobile_header_shows_office_controls", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0);
await page.getByTestId("studio-menu-toggle").click();
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
await expect(page.getByTitle("Voice reply settings")).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
});
+4 -7
View File
@@ -5,13 +5,10 @@ test.beforeEach(async ({ page }) => {
await stubStudioRoute(page);
});
test("connection panel reflects disconnected state", async ({ page }) => {
test("office settings panel reflects current gateway state", async ({ page }) => {
await page.goto("/");
await page.getByTestId("studio-menu-toggle").click();
await page.getByTestId("gateway-settings-toggle").click();
await expect(page.getByLabel("Upstream URL")).toBeVisible();
await expect(
page.getByRole("button", { name: /^(Connect|Disconnect)$/ })
).toBeVisible();
await page.getByTitle("Voice reply settings").click();
await expect(page.getByRole("button", { name: "Disconnect gateway" })).toBeVisible();
await expect(page.getByText("Current studio connection and endpoint details.")).toBeVisible();
});
+15 -16
View File
@@ -1,31 +1,30 @@
import { expect, test } from "@playwright/test";
import { stubStudioRoute } from "./helpers/studioRoute";
test("connection settings persist to the studio settings API", async ({ page }) => {
test("voice reply settings persist to the studio settings API", async ({ page }) => {
await stubStudioRoute(page);
await page.goto("/");
await page.getByTestId("studio-menu-toggle").click();
await page.getByTestId("gateway-settings-toggle").click();
await expect(page.getByLabel("Upstream URL")).toBeVisible();
await page.getByTitle("Voice reply settings").click();
await expect(page.getByRole("switch", { name: "Voice replies" })).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector('[aria-label="Voice replies"]');
return element instanceof HTMLButtonElement && !element.disabled;
});
await page.getByLabel("Upstream URL").fill("ws://gateway.example:18789");
await page.getByLabel("Upstream token").fill("token-123");
const request = await page.waitForRequest((req) => {
const requestPromise = page.waitForRequest((req) => {
if (!req.url().includes("/api/studio") || req.method() !== "PUT") {
return false;
}
const payload = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>;
const gateway = (payload.gateway ?? {}) as { url?: string; token?: string };
return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123";
const voiceReplies = (payload.voiceReplies ?? {}) as Record<string, { enabled?: boolean }>;
return Object.values(voiceReplies).some((entry) => entry.enabled === true);
});
await page.getByRole("switch", { name: "Voice replies" }).click();
const request = await requestPromise;
const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>;
const gateway = (payload.gateway ?? {}) as { url?: string; token?: string };
expect(gateway.url).toBe("ws://gateway.example:18789");
expect(gateway.token).toBe("token-123");
await expect(
page.getByRole("button", { name: /^(Connect|Disconnect)$/ })
).toBeVisible();
const voiceReplies = (payload.voiceReplies ?? {}) as Record<string, { enabled?: boolean }>;
expect(Object.keys(voiceReplies).length).toBeGreaterThan(0);
expect(Object.values(voiceReplies).some((entry) => entry.enabled === true)).toBe(true);
});
+15 -9
View File
@@ -5,11 +5,14 @@ test.beforeEach(async ({ page }) => {
await stubStudioRoute(page);
});
test("shows_disconnected_connect_surface", async ({ page }) => {
test("shows_office_shell_from_root_redirect", async ({ page }) => {
await page.goto("/");
await expect(page.getByLabel("Upstream URL")).toBeVisible();
await expect(page.getByRole("button", { name: /^(Connect|Connecting…)$/ })).toBeVisible();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe("/office");
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
});
test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
@@ -35,17 +38,20 @@ test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
test("focused_preferences_persist_across_reload", async ({ page }) => {
await page.goto("/");
await page.getByTestId("studio-menu-toggle").click();
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
await page.reload();
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe("/office");
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
});
test("clears_unseen_indicator_on_focus", async ({ page }) => {
test("shows_chat_entrypoint_in_office_shell", async ({ page }) => {
await page.goto("/");
await page.getByTestId("studio-menu-toggle").click();
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
await expect(page.getByTitle("Voice reply settings")).toBeVisible();
});
+8 -27
View File
@@ -1,32 +1,13 @@
import { expect, test } from "@playwright/test";
import { stubStudioRoute } from "./helpers/studioRoute";
test("loads focused studio empty state", async ({ page }) => {
await page.route("**/api/studio", async (route, request) => {
if (request.method() === "PUT") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
}),
});
return;
}
if (request.method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
}),
});
});
test("loads office shell from root", async ({ page }) => {
await stubStudioRoute(page);
await page.goto("/");
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe("/office");
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
});
+15 -8
View File
@@ -1,10 +1,11 @@
import type { Page, Route, Request } from "@playwright/test";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
export type StudioSettingsFixture = {
version: 1;
gateway: { url: string; token: string } | null;
focused: Record<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>;
avatars: Record<string, Record<string, string>>;
avatars: Record<string, Record<string, AgentAvatarProfile>>;
};
const DEFAULT_SETTINGS: StudioSettingsFixture = {
@@ -65,25 +66,31 @@ const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) =>
}
if (patch.avatars && typeof patch.avatars === "object") {
const avatarsPatch = patch.avatars as Record<string, Record<string, string | null> | null>;
const avatarsPatch = patch.avatars as
| Record<string, Record<string, AgentAvatarProfile | null> | null>
| null;
const avatarsNext: StudioSettingsFixture["avatars"] = { ...next.avatars };
for (const [gatewayKey, gatewayPatch] of Object.entries(avatarsPatch)) {
for (const [gatewayKey, gatewayPatch] of Object.entries(avatarsPatch ?? {})) {
if (gatewayPatch === null) {
delete avatarsNext[gatewayKey];
continue;
}
const existing = avatarsNext[gatewayKey] ? { ...avatarsNext[gatewayKey] } : {};
for (const [agentId, seedPatch] of Object.entries(gatewayPatch)) {
if (seedPatch === null) {
for (const [agentId, avatarPatch] of Object.entries(gatewayPatch)) {
if (avatarPatch === null) {
delete existing[agentId];
continue;
}
const seed = typeof seedPatch === "string" ? seedPatch.trim() : "";
if (!seed) {
if (
typeof avatarPatch !== "object" ||
avatarPatch === null ||
typeof avatarPatch.seed !== "string" ||
avatarPatch.seed.trim().length === 0
) {
delete existing[agentId];
continue;
}
existing[agentId] = seed;
existing[agentId] = avatarPatch;
}
avatarsNext[gatewayKey] = existing;
}
+4 -4
View File
@@ -5,12 +5,12 @@ test.beforeEach(async ({ page }) => {
await stubStudioRoute(page);
});
test("redirects unknown app routes to root", async ({ page }) => {
test("redirects unknown app routes to office", async ({ page }) => {
await page.goto("/not-a-real-route");
await expect
.poll(() => new URL(page.url()).pathname, {
message: "Expected invalid route to redirect to root path.",
message: "Expected invalid route to redirect to office path.",
})
.toBe("/");
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
.toBe("/office");
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
});
@@ -5,16 +5,13 @@ test.beforeEach(async ({ page }) => {
await stubStudioRoute(page);
});
test("settings route shows connect UI while disconnected and can return to chat", async ({ page }) => {
test("settings route redirects to office", async ({ page }) => {
await page.goto("/agents/main/settings");
await expect(page.getByRole("button", { name: "Back to chat" })).toBeVisible();
await expect(page.getByLabel("Upstream URL")).toBeVisible();
await page.getByRole("button", { name: "Back to chat" }).click();
await expect
.poll(() => new URL(page.url()).pathname, {
message: "Expected back button to return to chat route.",
message: "Expected settings route to redirect to office.",
})
.toBe("/");
.toBe("/office");
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
});
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { AgentAvatarCreatorModal } from "@/features/agents/components/AgentAvatarCreatorModal";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
vi.mock("@/features/agents/components/AgentAvatarPreview3D", () => ({
AgentAvatarPreview3D: () => <div data-testid="avatar-preview-3d">preview</div>,
}));
describe("AgentAvatarCreatorModal", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("saves the edited avatar profile", async () => {
const initialProfile = createDefaultAgentAvatarProfile("seed-a");
const onSave = vi.fn(async () => {});
render(
<AgentAvatarCreatorModal
open
agentId="agent-1"
agentName="Agent One"
initialProfile={initialProfile}
onClose={() => {}}
onSave={onSave}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Backpack" }));
fireEvent.click(screen.getByRole("button", { name: "Save avatar" }));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
seed: "seed-a",
accessories: expect.objectContaining({
backpack: !initialProfile.accessories.backpack,
}),
})
);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { createElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { AgentEditorModal } from "@/features/agents/components/AgentEditorModal";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import type { AgentState } from "@/features/agents/state/store";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
vi.mock("@/features/agents/components/AgentAvatarPreview3D", () => ({
AgentAvatarPreview3D: () => createElement("div", { "data-testid": "avatar-preview-3d" }, "preview"),
}));
vi.mock("@/features/agents/components/inspect/AgentBrainPanel", () => ({
AgentBrainPanel: ({
selectedAgentId,
activeSection,
}: {
selectedAgentId: string | null;
activeSection?: string;
}) =>
createElement(
"div",
{ "data-testid": "brain-panel" },
`brain:${selectedAgentId}:${activeSection ?? "all"}`,
),
}));
const buildAgent = (): AgentState =>
({
agentId: "agent-1",
name: "Agent One",
avatarProfile: createDefaultAgentAvatarProfile("seed-a"),
avatarSeed: "seed-a",
avatarUrl: null,
status: "idle",
sessionCreated: false,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: null,
runStartedAt: null,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
queuedMessages: [],
sessionSettingsSynced: false,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: false,
sessionKey: "session-1",
model: undefined,
thinkingLevel: undefined,
}) as AgentState;
describe("AgentEditorModal", () => {
beforeEach(() => {
vi.restoreAllMocks();
cleanup();
});
it("saves avatar changes from the avatar section", async () => {
const agent = buildAgent();
const onAvatarSave = vi.fn(async () => {});
const initialBackpack = agent.avatarProfile?.accessories.backpack;
render(
createElement(AgentEditorModal, {
open: true,
client: {} as GatewayClient,
agents: [agent],
agent,
onClose: () => {},
onAvatarSave,
}),
);
fireEvent.click(screen.getByRole("button", { name: "Backpack" }));
fireEvent.click(screen.getByRole("button", { name: "Save avatar" }));
expect(onAvatarSave).toHaveBeenCalledTimes(1);
expect(onAvatarSave).toHaveBeenCalledWith(
"agent-1",
expect.objectContaining({
seed: "seed-a",
accessories: expect.objectContaining({ backpack: !initialBackpack }),
}),
);
});
it("switches to another file section", () => {
const agent = buildAgent();
render(
createElement(AgentEditorModal, {
open: true,
client: {} as GatewayClient,
agents: [agent],
agent,
onClose: () => {},
onAvatarSave: () => {},
}),
);
fireEvent.click(screen.getByRole("button", { name: /Tools/i }));
expect(screen.getByTestId("brain-panel")).toHaveTextContent("brain:agent-1:TOOLS.md");
});
it("honors the initial file section", () => {
const agent = buildAgent();
render(
createElement(AgentEditorModal, {
open: true,
client: {} as GatewayClient,
agents: [agent],
agent,
initialSection: "MEMORY.md",
onClose: () => {},
onAvatarSave: () => {},
}),
);
expect(screen.getByTestId("brain-panel")).toHaveTextContent("brain:agent-1:MEMORY.md");
});
});
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import type { StudioSettings } from "@/lib/studio/settings";
describe("hydrateAgentFleetFromGateway", () => {
@@ -13,12 +14,13 @@ describe("hydrateAgentFleetFromGateway", () => {
focused: {},
avatars: {
[gatewayUrl]: {
"agent-1": "persisted-seed",
"agent-1": createDefaultAgentAvatarProfile("persisted-seed"),
},
},
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
};
const call = vi.fn(async (method: string, params: unknown) => {
@@ -127,6 +129,7 @@ describe("hydrateAgentFleetFromGateway", () => {
name: "One",
sessionKey: "agent:agent-1:main",
avatarSeed: "persisted-seed",
avatarProfile: expect.objectContaining({ seed: "persisted-seed" }),
avatarUrl: "https://example.com/one.png",
model: "openai/gpt-4.1",
thinkingLevel: "medium",
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import type { StudioSettings } from "@/lib/studio/settings";
describe("deriveHydrateAgentFleetResult", () => {
@@ -13,12 +14,13 @@ describe("deriveHydrateAgentFleetResult", () => {
focused: {},
avatars: {
[gatewayUrl]: {
"agent-1": "persisted-seed",
"agent-1": createDefaultAgentAvatarProfile("persisted-seed"),
},
},
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
};
const result = deriveHydrateAgentFleetResult({
@@ -93,6 +95,7 @@ describe("deriveHydrateAgentFleetResult", () => {
name: "One",
sessionKey: "agent:agent-1:main",
avatarSeed: "persisted-seed",
avatarProfile: expect.objectContaining({ seed: "persisted-seed" }),
avatarUrl: "https://example.com/one.png",
model: "openai/gpt-4.1",
thinkingLevel: "medium",
@@ -166,6 +166,7 @@ describe("studioBootstrapOperation", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
}),
isFocusFilterTouched: () => false,
});
@@ -203,6 +204,7 @@ describe("studioBootstrapOperation", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
}),
isFocusFilterTouched: () => true,
});
@@ -157,6 +157,7 @@ describe("studioBootstrapWorkflow", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
};
expect(
@@ -197,6 +198,7 @@ describe("studioBootstrapWorkflow", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
};
expect(
+52 -9
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import {
mergeStudioSettings,
@@ -12,6 +13,7 @@ describe("studio settings normalization", () => {
expect(normalized.gateway).toBeNull();
expect(normalized.focused).toEqual({});
expect(normalized.avatars).toEqual({});
expect(normalized.office).toEqual({});
});
it("normalizes gateway entries", () => {
@@ -114,16 +116,17 @@ describe("studio settings normalization", () => {
},
});
expect(normalized.avatars["ws://localhost:18789"]).toEqual({
"agent-1": "seed-1",
});
expect(normalized.avatars["ws://localhost:18789"]?.["agent-1"]?.seed).toBe("seed-1");
});
it("merges avatar patches", () => {
const firstProfile = createDefaultAgentAvatarProfile("seed-1");
const replacementProfile = createDefaultAgentAvatarProfile("seed-2");
const secondProfile = createDefaultAgentAvatarProfile("seed-3");
const current = normalizeStudioSettings({
avatars: {
"ws://localhost:18789": {
"agent-1": "seed-1",
"agent-1": firstProfile,
},
},
});
@@ -131,15 +134,55 @@ describe("studio settings normalization", () => {
const merged = mergeStudioSettings(current, {
avatars: {
"ws://localhost:18789": {
"agent-1": "seed-2",
"agent-2": "seed-3",
"agent-1": replacementProfile,
"agent-2": secondProfile,
},
},
});
expect(merged.avatars["ws://localhost:18789"]).toEqual({
"agent-1": "seed-2",
"agent-2": "seed-3",
expect(merged.avatars["ws://localhost:18789"]?.["agent-1"]?.seed).toBe("seed-2");
expect(merged.avatars["ws://localhost:18789"]?.["agent-2"]?.seed).toBe("seed-3");
});
it("normalizes office title preferences per gateway", () => {
const normalized = normalizeStudioSettings({
office: {
" ws://localhost:18789 ": {
title: " Team Orbit ",
},
bad: {
title: "",
},
},
});
expect(normalized.office["ws://localhost:18789"]).toEqual({
title: "Team Orbit",
});
expect(normalized.office.bad).toEqual({
title: "Luke Headquarters",
});
});
it("merges office title patches", () => {
const current = normalizeStudioSettings({
office: {
"ws://localhost:18789": {
title: "Luke Headquarters",
},
},
});
const merged = mergeStudioSettings(current, {
office: {
"ws://localhost:18789": {
title: "Orbit Control",
},
},
});
expect(merged.office["ws://localhost:18789"]).toEqual({
title: "Orbit Control",
});
});
});
+27 -7
View File
@@ -47,18 +47,18 @@ describe("studio settings route", () => {
const response = await GET();
const body = (await response.json()) as {
settings?: { gateway?: { url?: string; token?: string } | null };
localGatewayDefaults?: { url?: string; token?: string } | null;
settings?: { gateway?: { url?: string; tokenConfigured?: boolean } | null };
localGatewayDefaults?: { url?: string; tokenConfigured?: boolean } | null;
};
expect(response.status).toBe(200);
expect(body.localGatewayDefaults).toEqual({
url: "ws://localhost:18791",
token: "local-token",
tokenConfigured: true,
});
expect(body.settings?.gateway).toEqual({
url: "ws://localhost:18791",
token: "local-token",
tokenConfigured: true,
});
});
@@ -82,6 +82,11 @@ describe("studio settings route", () => {
const patch = {
gateway: { url: "ws://example.test:1234", token: "t" },
office: {
"ws://example.test:1234": {
title: "Orbit Control",
},
},
};
const putResponse = await PUT({
@@ -91,16 +96,31 @@ describe("studio settings route", () => {
const getResponse = await GET();
const body = (await getResponse.json()) as {
settings?: { gateway?: { url?: string; token?: string } | null };
settings?: {
gateway?: { url?: string; tokenConfigured?: boolean } | null;
office?: Record<string, { title?: string }>;
};
};
expect(getResponse.status).toBe(200);
expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
expect(body.settings?.gateway).toEqual({
url: "ws://example.test:1234",
tokenConfigured: true,
});
expect(body.settings?.office?.["ws://example.test:1234"]).toEqual({
title: "Orbit Control",
});
const settingsPath = path.join(tempDir, "claw3d", "settings.json");
expect(fs.existsSync(settingsPath)).toBe(true);
const raw = fs.readFileSync(settingsPath, "utf8");
const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null };
const parsed = JSON.parse(raw) as {
gateway?: { url?: string; token?: string } | null;
office?: Record<string, { title?: string }>;
};
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
expect(parsed.office?.["ws://example.test:1234"]).toEqual({
title: "Orbit Control",
});
});
});