Avatar Customization + Update Agent Brain (#23)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user