Gateway
diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx
index 647a59f..cbf6473 100644
--- a/src/features/office/screens/OfficeScreen.tsx
+++ b/src/features/office/screens/OfficeScreen.tsx
@@ -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
(
null,
);
+ const [agentEditorAgentId, setAgentEditorAgentId] = useState(null);
+ const [agentEditorInitialSection, setAgentEditorInitialSection] =
+ useState("avatar");
const [preparedPhoneCallsByAgentId, setPreparedPhoneCallsByAgentId] = useState<
Record
>({});
@@ -740,6 +758,14 @@ export function OfficeScreen() {
const [activeSidebarTab, setActiveSidebarTab] =
useState("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) {
- settingsCoordinator.schedulePatch(
- { avatars: { [key]: { [agentId]: seed } } },
- 0,
- );
- }
+ if (!key) return;
+ settingsCoordinator.schedulePatch(
+ { 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,54 +2910,55 @@ export function OfficeScreen() {
/>
) : null}
-
-
-
OpenClaw Event Console
-
-
- agents {state.agents.length} | events{" "}
- {filteredOpenClawLogEntries.length}/{openClawLogEntries.length}
-
-
{
- void handleCopyOpenClawConsoleJson();
- }}
- className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
- >
- {openClawConsoleCopyStatus === "copied"
- ? "Copied"
- : openClawConsoleCopyStatus === "error"
- ? "Copy Failed"
- : "Copy JSON"}
-
-
- Download JSON
-
-
- Clear
-
-
- setOpenClawConsoleCollapsed((previous) => !previous)
- }
- className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
- >
- {openClawConsoleCollapsed ? "Expand" : "Minimize"}
-
+ {showOpenClawConsole ? (
+
+
+
OpenClaw Event Console
+
+
+ agents {state.agents.length} | events{" "}
+ {filteredOpenClawLogEntries.length}/{openClawLogEntries.length}
+
+ {
+ void handleCopyOpenClawConsoleJson();
+ }}
+ className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
+ >
+ {openClawConsoleCopyStatus === "copied"
+ ? "Copied"
+ : openClawConsoleCopyStatus === "error"
+ ? "Copy Failed"
+ : "Copy JSON"}
+
+
+ Download JSON
+
+
+ Clear
+
+
+ setOpenClawConsoleCollapsed((previous) => !previous)
+ }
+ className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
+ >
+ {openClawConsoleCollapsed ? "Expand" : "Minimize"}
+
+
-
- {!openClawConsoleCollapsed ? (
-
+ {!openClawConsoleCollapsed ? (
+
- ) : null}
-
+
+ ) : null}
+
+ ) : null}
- handleAvatarShuffle(focusedChatAgent.agentId)
+ openAgentEditor(focusedChatAgent.agentId, "avatar")
}
onVoiceSend={handleVoiceSend}
/>
@@ -3307,6 +3355,29 @@ export function OfficeScreen() {
)}
) : null}
+ {agentEditorAgent ? (
+
{
+ 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}
);
}
diff --git a/src/features/retro-office/RetroOffice3D.tsx b/src/features/retro-office/RetroOffice3D.tsx
index 73f5b46..33e19fe 100644
--- a/src/features/retro-office/RetroOffice3D.tsx
+++ b/src/features/retro-office/RetroOffice3D.tsx
@@ -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(null);
// Idea 1 (original): hovered agent for tooltip overlay.
const [hoveredAgentId, setHoveredAgentId] = useState(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,33 +4973,57 @@ export function RetroOffice3D({
{/* New Idea 2: Camera preset buttons — top left. */}
{!immersiveOverlayActive ? (
-
- {(
- [
- {
- key: "overview",
- icon:
,
- title: "Overview",
- },
- {
- key: "frontDesk",
- icon:
,
- title: "Front desk",
- },
- { key: "lounge", icon:
, title: "Lounge" },
- ] as const
- ).map(({ key, icon, title }) => (
+
+
+ {(
+ [
+ {
+ key: "overview",
+ icon:
,
+ title: "Overview",
+ },
+ {
+ key: "frontDesk",
+ icon:
,
+ title: "Front desk",
+ },
+ { key: "lounge", icon:
, title: "Lounge" },
+ ] as const
+ ).map(({ key, icon, title }) => (
+
{
+ cameraPresetRef.current = CAMERA_PRESET_MAP[key];
+ }}
+ className="w-7 h-7 flex items-center justify-center rounded-md bg-[#1c1610]/80 text-amber-500/60 border border-amber-900/20 hover:bg-[#2a1e14] hover:text-amber-400 backdrop-blur-sm transition-colors"
+ >
+ {icon}
+
+ ))}
+
+ {standupMeeting ? (
{
- cameraPresetRef.current = CAMERA_PRESET_MAP[key];
- }}
- className="w-7 h-7 flex items-center justify-center rounded-md bg-[#1c1610]/80 text-amber-500/60 border border-amber-900/20 hover:bg-[#2a1e14] hover:text-amber-400 backdrop-blur-sm transition-colors"
+ 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"
>
- {icon}
+
+ Standup
+
+
+ {standupMeeting.phase === "gathering"
+ ? "Gathering in meeting room."
+ : standupMeeting.phase === "in_progress"
+ ? `Speaking: ${standupSpeakerCard?.agentName ?? "Team"}`
+ : "Standup complete."}
+
+
+ {standupMeeting.arrivedAgentIds.length}/
+ {standupMeeting.participantOrder.length} arrived
+
- ))}
+ ) : null}
) : null}
@@ -4978,135 +5032,189 @@ export function RetroOffice3D({
- Luke Headquarters
+ {officeTitle}
) : null}
- {!immersiveOverlayActive && standupMeeting ? (
-
-
-
- Standup
-
-
- {standupMeeting.phase === "gathering"
- ? "Gathering in meeting room."
- : standupMeeting.phase === "in_progress"
- ? `Speaking: ${standupSpeakerCard?.agentName ?? "Team"}`
- : "Standup complete."}
-
-
- {standupMeeting.arrivedAgentIds.length}/
- {standupMeeting.participantOrder.length} arrived
-
-
-
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
-
-
- ) : null}
-
- {/* Agent cards — compact single row pinned to top. */}
+ {/* Agent roster — compact top summary with overflow panel. */}
{!immersiveOverlayActive ? (
-
- {agents.map((agent) => {
- const status = agentStatusLookup[agent.id];
- const isError = status?.isError ?? agent.status === "error";
- const working = status?.working ?? agent.status === "working";
- const mood = moodByAgentId[agent.id];
- const dotClass = isError
- ? "bg-red-400"
- : working
- ? "bg-green-400"
- : "bg-yellow-400";
- return (
-
- setSpotlightAgentId((prev) =>
- prev === agent.id ? null : agent.id,
- )
- }
- >
- {/* E3 Idea 1: Mood emoji float. */}
- {mood && (
-
- {mood.emoji}
-
- )}
-
-
+
+
+ {compactRosterAgents.map((agent) => {
+ const status = agentStatusLookup[agent.id];
+ const isError = status?.isError ?? agent.status === "error";
+ const working = status?.working ?? agent.status === "working";
+ const mood = moodByAgentId[agent.id];
+ const dotClass = isError
+ ? "bg-red-400"
+ : working
+ ? "bg-green-400"
+ : "bg-yellow-400";
+ return (
+
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.emoji}
+
+ ) : null}
+ {getAgentInitials(agent.name)}
+
+
+ );
+ })}
+ {hiddenAgentCount > 0 ? (
+
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}
+
+ ) : null}
+
+
+
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"
+ >
+
+ {agents.length}
+ agents
+
+
+
+ {agentRosterOpen ? (
+
+
+
+
+ Team roster
+
+
+ Compact view for larger fleets.
+
-
- {agent.name}
-
- {/* Follow cam toggle button. */}
{
- 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"
- }`}
+ 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"
>
-
+
-
{
- e.stopPropagation();
- onMonitorSelect?.(
- monitorAgentId === agent.id ? null : agent.id,
- );
- }}
- className={`w-4 h-4 flex items-center justify-center rounded transition-colors ${
- monitorAgentId === agent.id
- ? "text-emerald-300 opacity-100"
- : "text-white/50 hover:text-emerald-200 opacity-70 hover:opacity-100"
- }`}
- >
-
-
- {/* Idea 9 (original): Run counter badge. */}
- {(runCountByAgentId[agent.id] ?? 0) > 0 && (
-
- {runCountByAgentId[agent.id]}
-
- )}
- );
- })}
+
+
+ {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 (
+
+
{
+ setSpotlightAgentId(agent.id);
+ onAgentEdit?.(agent.id);
+ setAgentRosterOpen(false);
+ }}
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
+ >
+
+ {getAgentInitials(agent.name)}
+
+
+
+
+ {agent.name}
+
+
+ {isError ? "error" : working ? "working" : "idle"}
+ {runCount > 0 ? ` · ${runCount} runs` : ""}
+
+
+
+
+ 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"
+ }`}
+ >
+
+
+
+ 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
+ ? "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"
+ }`}
+ >
+
+
+
+ );
+ })}
+
+
+ ) : null}
) : null}
@@ -5901,51 +6009,55 @@ export function RetroOffice3D({
) : null}
{settingsModalOpen ? (
-
-
+
+
- VOICE SETTINGS
+ STUDIO SETTINGS
- Control natural-sounding spoken replies for agents across the
- app.
+ Customize the office banner and spoken replies across the app.
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"
>
-
{
- onGatewayDisconnect?.();
- setSettingsModalOpen(false);
- }}
- voiceRepliesEnabled={voiceRepliesEnabled}
- voiceRepliesVoiceId={voiceRepliesVoiceId}
- voiceRepliesSpeed={voiceRepliesSpeed}
- voiceRepliesLoaded={voiceRepliesLoaded}
- onVoiceRepliesToggle={(enabled) =>
- onVoiceRepliesToggle?.(enabled)
- }
- onVoiceRepliesVoiceChange={(voiceId) =>
- onVoiceRepliesVoiceChange?.(voiceId)
- }
- onVoiceRepliesSpeedChange={(speed) =>
- onVoiceRepliesSpeedChange?.(speed)
- }
- onVoiceRepliesPreview={(voiceId, voiceName) =>
- onVoiceRepliesPreview?.(voiceId, voiceName)
- }
- />
+
+ {
+ onGatewayDisconnect?.();
+ setSettingsModalOpen(false);
+ }}
+ officeTitle={officeTitle}
+ officeTitleLoaded={officeTitleLoaded}
+ onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)}
+ voiceRepliesEnabled={voiceRepliesEnabled}
+ voiceRepliesVoiceId={voiceRepliesVoiceId}
+ voiceRepliesSpeed={voiceRepliesSpeed}
+ voiceRepliesLoaded={voiceRepliesLoaded}
+ onVoiceRepliesToggle={(enabled) =>
+ onVoiceRepliesToggle?.(enabled)
+ }
+ onVoiceRepliesVoiceChange={(voiceId) =>
+ onVoiceRepliesVoiceChange?.(voiceId)
+ }
+ onVoiceRepliesSpeedChange={(speed) =>
+ onVoiceRepliesSpeedChange?.(speed)
+ }
+ onVoiceRepliesPreview={(voiceId, voiceName) =>
+ onVoiceRepliesPreview?.(voiceId, voiceName)
+ }
+ />
+
) : null}
diff --git a/src/features/retro-office/core/types.ts b/src/features/retro-office/core/types.ts
index 5114e08..f25c837 100644
--- a/src/features/retro-office/core/types.ts
+++ b/src/features/retro-office/core/types.ts
@@ -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";
diff --git a/src/features/retro-office/objects/agents.tsx b/src/features/retro-office/objects/agents.tsx
index 34dc0c8..bbaa467 100644
--- a/src/features/retro-office/objects/agents.tsx
+++ b/src/features/retro-office/objects/agents.tsx
@@ -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
(null);
const bodyMatRef = useRef(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({
-
-
-
-
+ {bottomStyle === "shorts" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {bottomStyle === "cuffed" ? (
+
+
+
+
+ ) : null}
+ >
+ )}
-
-
-
-
+ {bottomStyle === "shorts" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {bottomStyle === "cuffed" ? (
+
+
+
+
+ ) : null}
+ >
+ )}
+ {showBackpack ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
-
+
+ {topStyle === "hoodie" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ {topStyle === "jacket" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : null}
-
+
+ {topStyle === "hoodie" ? (
+
+
+
+
+ ) : null}
@@ -681,8 +787,14 @@ export const AgentModel = memo(function AgentModel({
-
+
+ {topStyle === "hoodie" ? (
+
+
+
+
+ ) : null}
@@ -701,10 +813,94 @@ export const AgentModel = memo(function AgentModel({
-
-
-
-
+ {hairStyle === "short" ? (
+
+
+
+
+ ) : null}
+ {hairStyle === "parted" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ {hairStyle === "spiky" ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ {hairStyle === "bun" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ {hatStyle === "cap" ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ {hatStyle === "beanie" ? (
+
+
+
+
+ ) : null}
+ {showHeadset ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null}
@@ -729,6 +925,22 @@ export const AgentModel = memo(function AgentModel({
+ {showGlasses ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null}
diff --git a/src/features/retro-office/objects/types.ts b/src/features/retro-office/objects/types.ts
index 7a23576..53ee0f2 100644
--- a/src/features/retro-office/objects/types.ts
+++ b/src/features/retro-office/objects/types.ts
@@ -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;
agentLookupRef?: RefObject>;
onHover?: (id: string) => void;
diff --git a/src/hooks/useStudioOfficePreference.ts b/src/hooks/useStudioOfficePreference.ts
new file mode 100644
index 0000000..57dfe41
--- /dev/null
+++ b/src/hooks/useStudioOfficePreference.ts
@@ -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(
+ 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,
+ };
+};
diff --git a/src/lib/avatars/profile.ts b/src/lib/avatars/profile.ts
new file mode 100644
index 0000000..43337ae
--- /dev/null
+++ b/src/lib/avatars/profile.ts
@@ -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 = {
+ 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[] = [
+ { 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[] = [
+ { id: "tee", label: "Tee" },
+ { id: "hoodie", label: "Hoodie" },
+ { id: "jacket", label: "Jacket" },
+];
+
+export const AGENT_AVATAR_BOTTOM_STYLE_OPTIONS: EnumOption[] = [
+ { id: "pants", label: "Pants" },
+ { id: "shorts", label: "Shorts" },
+ { id: "cuffed", label: "Cuffed" },
+];
+
+export const AGENT_AVATAR_HAT_STYLE_OPTIONS: EnumOption[] = [
+ { 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 =>
+ 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 = (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 = (
+ value: unknown,
+ options: EnumOption[],
+ 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,
+ },
+ };
+};
diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts
index 4b24a0f..4fed0a8 100644
--- a/src/lib/studio/coordinator.ts
+++ b/src/lib/studio/coordinator.ts
@@ -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 | null>;
-type AvatarsPatch = Record | null>;
+type AvatarsPatch = Record | null>;
type DeskAssignmentsPatch = Record | null>;
type AnalyticsPatch = Record;
type VoiceRepliesPatch = Record;
+type OfficePatch = Record;
type StandupPatch = Record;
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 } : {}),
};
};
diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts
index 32568b4..2464fa5 100644
--- a/src/lib/studio/settings.ts
+++ b/src/lib/studio/settings.ts
@@ -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;
+export type StudioAgentAvatars = Record;
export type StudioStandupPreference = StandupConfig;
@@ -83,10 +94,11 @@ export type StudioSettings = {
version: 1;
gateway: StudioGatewaySettings | null;
focused: Record;
- avatars: Record>;
+ avatars: Record;
deskAssignments: Record;
analytics: Record;
voiceReplies: Record;
+ office: Record;
standup?: Record;
};
@@ -98,10 +110,11 @@ export type StudioSettingsPublic = Omit &
export type StudioSettingsPatch = {
gateway?: StudioGatewaySettingsPatch | null;
focused?: Record | null>;
- avatars?: Record | null>;
+ avatars?: Record | null>;
deskAssignments?: Record | null>;
analytics?: Record;
voiceReplies?: Record;
+ office?: Record;
standup?: Record;
};
@@ -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> => {
+const normalizeAvatars = (value: unknown): Record => {
if (!isRecord(value)) return {};
- const avatars: Record> = {};
+ const avatars: Record = {};
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (!isRecord(gatewayRaw)) continue;
- const entries: Record = {};
- 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 => {
+ if (!isRecord(value)) return {};
+ const office: Record = {};
+ 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
diff --git a/tests/e2e/agent-avatar.spec.ts b/tests/e2e/agent-avatar.spec.ts
index bf7064e..106fa4c 100644
--- a/tests/e2e/agent-avatar.spec.ts
+++ b/tests/e2e/agent-avatar.spec.ts
@@ -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();
});
diff --git a/tests/e2e/agent-ia-split.spec.ts b/tests/e2e/agent-ia-split.spec.ts
index c6d0eec..93c74e9 100644
--- a/tests/e2e/agent-ia-split.spec.ts
+++ b/tests/e2e/agent-ia-split.spec.ts
@@ -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();
});
diff --git a/tests/e2e/agent-inspect-panel.spec.ts b/tests/e2e/agent-inspect-panel.spec.ts
index 63a9a5f..1fd4657 100644
--- a/tests/e2e/agent-inspect-panel.spec.ts
+++ b/tests/e2e/agent-inspect-panel.spec.ts
@@ -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();
});
diff --git a/tests/e2e/connection-settings.spec.ts b/tests/e2e/connection-settings.spec.ts
index 248ead3..c646794 100644
--- a/tests/e2e/connection-settings.spec.ts
+++ b/tests/e2e/connection-settings.spec.ts
@@ -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;
- 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;
+ 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;
- 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;
+ expect(Object.keys(voiceReplies).length).toBeGreaterThan(0);
+ expect(Object.values(voiceReplies).some((entry) => entry.enabled === true)).toBe(true);
});
diff --git a/tests/e2e/fleet-sidebar.spec.ts b/tests/e2e/fleet-sidebar.spec.ts
index 189972a..e83888b 100644
--- a/tests/e2e/fleet-sidebar.spec.ts
+++ b/tests/e2e/fleet-sidebar.spec.ts
@@ -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();
});
diff --git a/tests/e2e/focused-smoke.spec.ts b/tests/e2e/focused-smoke.spec.ts
index 17f0e7f..9056eaf 100644
--- a/tests/e2e/focused-smoke.spec.ts
+++ b/tests/e2e/focused-smoke.spec.ts
@@ -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();
});
diff --git a/tests/e2e/helpers/studioRoute.ts b/tests/e2e/helpers/studioRoute.ts
index 4dc0a00..485f17c 100644
--- a/tests/e2e/helpers/studioRoute.ts
+++ b/tests/e2e/helpers/studioRoute.ts
@@ -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;
- avatars: Record>;
+ avatars: Record>;
};
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 | null>;
+ const avatarsPatch = patch.avatars as
+ | Record | 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;
}
diff --git a/tests/e2e/invalid-route-redirect.spec.ts b/tests/e2e/invalid-route-redirect.spec.ts
index c79e946..1eabd60 100644
--- a/tests/e2e/invalid-route-redirect.spec.ts
+++ b/tests/e2e/invalid-route-redirect.spec.ts
@@ -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();
});
diff --git a/tests/e2e/settings-route-disconnected.spec.ts b/tests/e2e/settings-route-disconnected.spec.ts
index 66cd2f3..87a88aa 100644
--- a/tests/e2e/settings-route-disconnected.spec.ts
+++ b/tests/e2e/settings-route-disconnected.spec.ts
@@ -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();
});
diff --git a/tests/unit/agentAvatarCreatorModal.test.tsx b/tests/unit/agentAvatarCreatorModal.test.tsx
new file mode 100644
index 0000000..a881cee
--- /dev/null
+++ b/tests/unit/agentAvatarCreatorModal.test.tsx
@@ -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: () => preview
,
+}));
+
+describe("AgentAvatarCreatorModal", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("saves the edited avatar profile", async () => {
+ const initialProfile = createDefaultAgentAvatarProfile("seed-a");
+ const onSave = vi.fn(async () => {});
+
+ render(
+ {}}
+ 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,
+ }),
+ })
+ );
+ });
+});
diff --git a/tests/unit/agentEditorModal.test.ts b/tests/unit/agentEditorModal.test.ts
new file mode 100644
index 0000000..f8a0634
--- /dev/null
+++ b/tests/unit/agentEditorModal.test.ts
@@ -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");
+ });
+});
diff --git a/tests/unit/agentFleetHydration.test.ts b/tests/unit/agentFleetHydration.test.ts
index a4e3bbb..bbd8b84 100644
--- a/tests/unit/agentFleetHydration.test.ts
+++ b/tests/unit/agentFleetHydration.test.ts
@@ -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",
diff --git a/tests/unit/agentFleetHydrationDerivation.test.ts b/tests/unit/agentFleetHydrationDerivation.test.ts
index 05a2f1e..92f2d7d 100644
--- a/tests/unit/agentFleetHydrationDerivation.test.ts
+++ b/tests/unit/agentFleetHydrationDerivation.test.ts
@@ -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",
diff --git a/tests/unit/studioBootstrapOperation.test.ts b/tests/unit/studioBootstrapOperation.test.ts
index d2aac84..ff4926d 100644
--- a/tests/unit/studioBootstrapOperation.test.ts
+++ b/tests/unit/studioBootstrapOperation.test.ts
@@ -166,6 +166,7 @@ describe("studioBootstrapOperation", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
+ office: {},
}),
isFocusFilterTouched: () => false,
});
@@ -203,6 +204,7 @@ describe("studioBootstrapOperation", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
+ office: {},
}),
isFocusFilterTouched: () => true,
});
diff --git a/tests/unit/studioBootstrapWorkflow.test.ts b/tests/unit/studioBootstrapWorkflow.test.ts
index ba37544..2b5fc93 100644
--- a/tests/unit/studioBootstrapWorkflow.test.ts
+++ b/tests/unit/studioBootstrapWorkflow.test.ts
@@ -157,6 +157,7 @@ describe("studioBootstrapWorkflow", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
+ office: {},
};
expect(
@@ -197,6 +198,7 @@ describe("studioBootstrapWorkflow", () => {
deskAssignments: {},
analytics: {},
voiceReplies: {},
+ office: {},
};
expect(
diff --git a/tests/unit/studioSettings.test.ts b/tests/unit/studioSettings.test.ts
index a49c5f3..ef2831c 100644
--- a/tests/unit/studioSettings.test.ts
+++ b/tests/unit/studioSettings.test.ts
@@ -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",
});
});
});
diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts
index 9c7c345..cfb62d6 100644
--- a/tests/unit/studioSettingsRoute.test.ts
+++ b/tests/unit/studioSettingsRoute.test.ts
@@ -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;
+ };
};
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;
+ };
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
+ expect(parsed.office?.["ws://example.test:1234"]).toEqual({
+ title: "Orbit Control",
+ });
});
});