Add office fleet visibility and state mapping

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-09 14:36:03 +00:00
co-authored by Luke The Dev
parent e59dcbe520
commit 0def2d2e8a
9 changed files with 630 additions and 21 deletions
+30
View File
@@ -0,0 +1,30 @@
# Discussion #8 follow-up backlog.
This file captures the remaining items from the GitHub discussion so they stay visible after the first implementation pass.
## Done in this branch.
- Multi-agent visibility improvements in the office HUD.
- Config-driven office state mapping for local and remote agent presence.
## Remaining items.
### 3. Mobile-first office UX.
- Improve touch-first navigation for `/office`, especially camera movement and chat access on narrow screens.
- Verify iOS Safari behavior with a dedicated pass on touch gestures and viewport-safe overlays.
### 4. Sound and event cues.
- Add lightweight operational audio cues for important office events.
- Keep cues configurable so operators can mute or tailor them per environment.
### 5. Enterprise auth integration guidance.
- Document `oauth2-proxy` and Entra/OIDC deployment patterns around `STUDIO_ACCESS_TOKEN` and the `studio_access` cookie contract.
- Re-evaluate whether built-in OIDC is needed after documentation and operator feedback.
### 6. External event webhooks.
- Define an event-ingress API for external systems to trigger office reactions.
- Decide on payload schema, authentication, and rate-limiting before adding visual/audio reactions.
@@ -1,6 +1,11 @@
"use client";
import { useState } from "react";
import {
OFFICE_RENDERABLE_AGENT_STATUSES,
type OfficeAgentStateMapping,
type OfficeRenderableAgentStatus,
} from "@/lib/office/agentStateMapping";
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
@@ -19,6 +24,10 @@ type SettingsPanelProps = {
officeTitle: string;
officeTitleLoaded: boolean;
onOfficeTitleChange: (title: string) => void;
agentStateMapping: OfficeAgentStateMapping;
onAgentStateMappingChange: (
next: Partial<OfficeAgentStateMapping>,
) => void;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
@@ -56,6 +65,8 @@ export function SettingsPanel({
officeTitle,
officeTitleLoaded,
onOfficeTitleChange,
agentStateMapping,
onAgentStateMappingChange,
remoteOfficeEnabled,
remoteOfficeSourceKind,
remoteOfficeLabel,
@@ -90,6 +101,36 @@ export function SettingsPanel({
selectedAdapterType === "demo" ||
selectedAdapterType === "custom";
const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState("");
const renderableStateOptions = OFFICE_RENDERABLE_AGENT_STATUSES;
const renderMappingSelect = (
params: {
label: string;
value: OfficeRenderableAgentStatus;
onChange: (value: OfficeRenderableAgentStatus) => void;
description: string;
},
) => (
<label className="grid gap-1">
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
{params.label}
</span>
<select
value={params.value}
onChange={(event) =>
params.onChange(event.target.value as OfficeRenderableAgentStatus)
}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
>
{renderableStateOptions.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<span className="text-[10px] text-white/50">{params.description}</span>
</label>
);
return (
<div className="px-4 py-4">
@@ -402,6 +443,99 @@ export function SettingsPanel({
)}
</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">Office state mapping</div>
<div className="mt-1 text-[10px] text-white/75">
Decide how local runtime status and remote presence states appear in the
office.
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
Config
</span>
</div>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-cyan-500/10 bg-black/15 px-3 py-3">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-100/75">
Local agents
</div>
<div className="mt-3 grid gap-3">
{renderMappingSelect({
label: "Idle signal",
value: agentStateMapping.local.idle,
onChange: (value) =>
onAgentStateMappingChange({
local: { idle: value },
}),
description: "Used when a local agent is idle and has no active run.",
})}
{renderMappingSelect({
label: "Running signal",
value: agentStateMapping.local.running,
onChange: (value) =>
onAgentStateMappingChange({
local: { running: value },
}),
description: "Used when a local agent is running or latched as active.",
})}
{renderMappingSelect({
label: "Error signal",
value: agentStateMapping.local.error,
onChange: (value) =>
onAgentStateMappingChange({
local: { error: value },
}),
description: "Used when a local agent reports an error.",
})}
</div>
</div>
<div className="rounded-lg border border-cyan-500/10 bg-black/15 px-3 py-3">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-100/75">
Remote office
</div>
<div className="mt-3 grid gap-3">
{renderMappingSelect({
label: "Idle state",
value: agentStateMapping.remote.idle,
onChange: (value) =>
onAgentStateMappingChange({
remote: { idle: value },
}),
description: "Used when the remote office reports an idle agent.",
})}
{renderMappingSelect({
label: "Working state",
value: agentStateMapping.remote.working,
onChange: (value) =>
onAgentStateMappingChange({
remote: { working: value },
}),
description: "Used when the remote office reports active work.",
})}
{renderMappingSelect({
label: "Meeting state",
value: agentStateMapping.remote.meeting,
onChange: (value) =>
onAgentStateMappingChange({
remote: { meeting: value },
}),
description: "Used when the remote office reports meeting activity.",
})}
{renderMappingSelect({
label: "Error state",
value: agentStateMapping.remote.error,
onChange: (value) =>
onAgentStateMappingChange({
remote: { error: value },
}),
description: "Used when the remote office reports an error state.",
})}
</div>
</div>
</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>
+25 -21
View File
@@ -114,6 +114,12 @@ import {
} from "@/lib/agents/personalityBuilder";
import { writeGatewayAgentFiles } from "@/lib/gateway/agentFiles";
import { randomUUID } from "@/lib/uuid";
import {
DEFAULT_OFFICE_AGENT_STATE_MAPPING,
resolveLocalOfficeRenderableStatus,
resolveRemoteOfficeRenderableStatus,
type OfficeAgentStateMapping,
} from "@/lib/office/agentStateMapping";
import {
HQSidebar,
type HQSidebarTab,
@@ -508,41 +514,34 @@ const getDeterministicItem = (id: string) => {
return ITEMS[Math.abs(hash) % ITEMS.length];
};
const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
if (agent.status === "error") {
return {
id: agent.agentId,
name: agent.name || "Unknown",
subtitle: agent.role ?? null,
status: "error",
color: stringToColor(agent.agentId),
item: getDeterministicItem(agent.agentId),
avatarProfile: agent.avatarProfile ?? null,
};
}
const isWorking = agent.status === "running" || Boolean(agent.runId);
const mapAgentToOffice = (
agent: AgentState,
mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING,
): OfficeAgent => {
return {
id: agent.agentId,
name: agent.name || "Unknown",
subtitle: agent.role ?? null,
status: isWorking ? "working" : "idle",
status: resolveLocalOfficeRenderableStatus(agent, mapping),
color: stringToColor(agent.agentId),
item: getDeterministicItem(agent.agentId),
avatarProfile: agent.avatarProfile ?? null,
};
};
const mapRemotePresenceAgentToOffice = (agent: {
const mapRemotePresenceAgentToOffice = (
agent: {
agentId: string;
name: string;
state: "idle" | "working" | "meeting" | "error";
}): OfficeAgent => {
},
mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING,
): OfficeAgent => {
const stableId = `remote:${agent.agentId}`;
const isWorking = agent.state === "working" || agent.state === "meeting";
return {
id: stableId,
name: agent.name || "Unknown",
status: agent.state === "error" ? "error" : isWorking ? "working" : "idle",
status: resolveRemoteOfficeRenderableStatus(agent.state, mapping),
color: stringToColor(stableId),
item: getDeterministicItem(stableId),
avatarProfile: null,
@@ -1063,6 +1062,7 @@ export function OfficeScreen({
const {
loaded: officeTitleLoaded,
title: officeTitle,
agentStateMapping,
remoteOfficeEnabled,
remoteOfficeSourceKind,
remoteOfficeLabel,
@@ -1070,6 +1070,7 @@ export function OfficeScreen({
remoteOfficeGatewayUrl,
remoteOfficeTokenConfigured,
setTitle: setOfficeTitle,
setAgentStateMapping,
setRemoteOfficeEnabled,
setRemoteOfficeSourceKind,
setRemoteOfficeLabel,
@@ -3762,7 +3763,7 @@ export function OfficeScreen({
: `desk-hold-${agent.agentId}`),
}
: agent;
const officeAgent = mapAgentToOffice(effectiveAgent);
const officeAgent = mapAgentToOffice(effectiveAgent, agentStateMapping);
nextCache.set(agent.agentId, {
agent,
deskHeld,
@@ -3786,6 +3787,7 @@ export function OfficeScreen({
smsBoothHoldByAgentId,
state.agents,
workingUntilByAgentId,
agentStateMapping,
]);
const streamingTextByAgentId = useMemo(() => {
const map: Record<string, string | null> = {};
@@ -3838,9 +3840,9 @@ export function OfficeScreen({
const remoteOfficeAgents = useMemo(
() =>
(remoteOfficeSnapshot?.agents ?? []).map((agent) =>
mapRemotePresenceAgentToOffice(agent)
mapRemotePresenceAgentToOffice(agent, agentStateMapping)
),
[remoteOfficeSnapshot]
[remoteOfficeSnapshot, agentStateMapping]
);
const chatRosterEntries = useMemo<ChatRosterEntry[]>(
() => [
@@ -4255,6 +4257,7 @@ export function OfficeScreen({
soundclawEnabled={soundclawReady}
officeTitle={officeTitle}
officeTitleLoaded={officeTitleLoaded}
agentStateMapping={agentStateMapping}
remoteOfficeEnabled={remoteOfficeEnabled}
remoteOfficeSourceKind={remoteOfficeSourceKind}
remoteOfficeLabel={remoteOfficeLabel}
@@ -4268,6 +4271,7 @@ export function OfficeScreen({
voiceRepliesSpeed={voiceRepliesSpeed}
voiceRepliesLoaded={voiceRepliesLoaded}
onOfficeTitleChange={setOfficeTitle}
onAgentStateMappingChange={setAgentStateMapping}
onRemoteOfficeEnabledChange={setRemoteOfficeEnabled}
onRemoteOfficeSourceKindChange={setRemoteOfficeSourceKind}
onRemoteOfficeLabelChange={setRemoteOfficeLabel}
@@ -52,6 +52,7 @@ import type { OfficeAnimationState } from "@/lib/office/eventTriggers";
import type { StandupMeeting } from "@/lib/office/standup/types";
import type { SkillStatusEntry } from "@/lib/skills/types";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
import type { OfficeAgentStateMapping } from "@/lib/office/agentStateMapping";
import type {
TaskBoardCard,
TaskBoardStatus,
@@ -2456,6 +2457,7 @@ export function RetroOffice3D({
remoteOfficeStatusText?: string;
remoteLayoutSnapshot?: OfficeLayoutSnapshot | null;
remoteOfficeTokenConfigured?: boolean;
agentStateMapping?: OfficeAgentStateMapping;
voiceRepliesEnabled?: boolean;
voiceRepliesVoiceId?: string | null;
voiceRepliesSpeed?: number;
@@ -2469,6 +2471,7 @@ export function RetroOffice3D({
onRemoteOfficePresenceUrlChange?: (url: string) => void;
onRemoteOfficeGatewayUrlChange?: (url: string) => void;
onRemoteOfficeTokenChange?: (token: string) => void;
onAgentStateMappingChange?: (mapping: OfficeAgentStateMapping) => void;
onVoiceRepliesToggle?: (enabled: boolean) => void;
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
onVoiceRepliesSpeedChange?: (speed: number) => void;
@@ -7203,6 +7206,10 @@ export function RetroOffice3D({
onRemoteOfficeTokenChange={(token) =>
onRemoteOfficeTokenChange?.(token)
}
agentStateMapping={agentStateMapping}
onAgentStateMappingChange={(mapping) =>
onAgentStateMappingChange?.(mapping)
}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
@@ -7227,6 +7234,68 @@ export function RetroOffice3D({
{!immersiveOverlayActive ? (
<>
{(() => {
const localAgents = agents.filter((agent) => !isRemoteOfficeAgentId(agent.id));
const remoteAgents = agents.filter((agent) => isRemoteOfficeAgentId(agent.id));
const countByStatus = (
source: typeof agents,
status: "working" | "idle" | "error",
) => source.filter((agent) => agent.status === status).length;
return (
<div className="absolute left-3 top-14 z-10 w-[260px] rounded-xl border border-cyan-400/15 bg-[#081018]/78 px-3 py-3 text-[10px] text-cyan-50/90 shadow-xl backdrop-blur-sm">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-cyan-100">
Fleet visibility
</div>
<div className="mt-1 text-[10px] text-cyan-100/55">
Local and remote agent presence at a glance.
</div>
</div>
<span className="rounded-full border border-cyan-400/20 bg-cyan-500/8 px-2 py-1 font-mono uppercase tracking-[0.14em] text-cyan-100/75">
{agents.length} total
</span>
</div>
<div className="mt-3 grid gap-2">
{[
{
label: "Local fleet",
agents: localAgents,
},
{
label: remoteOfficeEnabled ? "Remote fleet" : "Remote fleet off",
agents: remoteAgents,
},
].map((section) => (
<div
key={section.label}
className="rounded-lg border border-cyan-500/10 bg-black/18 px-3 py-2"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium uppercase tracking-[0.14em] text-cyan-100/85">
{section.label}
</span>
<span className="font-mono text-cyan-100/55">
{section.agents.length}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-[10px]">
<span className="rounded-full bg-emerald-500/12 px-2 py-1 text-emerald-100/85">
{countByStatus(section.agents, "working")} working
</span>
<span className="rounded-full bg-sky-500/12 px-2 py-1 text-sky-100/85">
{countByStatus(section.agents, "idle")} idle
</span>
<span className="rounded-full bg-rose-500/12 px-2 py-1 text-rose-100/85">
{countByStatus(section.agents, "error")} error
</span>
</div>
</div>
))}
</div>
</div>
);
})()}
{/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
<div className="absolute bottom-3 left-3 flex flex-col items-start gap-1.5 z-10 pointer-events-none select-none">
{/* Idea 3: Activity feed entries — newest on bottom. */}
+34
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
import type { OfficeAgentStateMappingPatch } from "@/lib/office/agentStateMapping";
import {
defaultStudioOfficePreferencePublic,
resolveOfficePreferencePublic,
@@ -76,6 +77,37 @@ export const useStudioOfficePreference = ({
[gatewayUrl, settingsCoordinator]
);
const setAgentStateMapping = useCallback(
(agentStateMapping: OfficeAgentStateMappingPatch) => {
const gatewayKey = gatewayUrl.trim();
setPreference((current) => ({
...current,
agentStateMapping: {
local: {
...current.agentStateMapping.local,
...(agentStateMapping.local ?? {}),
},
remote: {
...current.agentStateMapping.remote,
...(agentStateMapping.remote ?? {}),
},
},
}));
if (!gatewayKey) return;
settingsCoordinator.schedulePatch(
{
office: {
[gatewayKey]: {
agentStateMapping,
},
},
},
0
);
},
[gatewayUrl, settingsCoordinator]
);
const setRemoteOfficeEnabled = useCallback(
(remoteOfficeEnabled: boolean) => {
const gatewayKey = gatewayUrl.trim();
@@ -197,6 +229,7 @@ export const useStudioOfficePreference = ({
loaded,
preference,
title: preference.title,
agentStateMapping: preference.agentStateMapping,
remoteOfficeEnabled: preference.remoteOfficeEnabled,
remoteOfficeSourceKind: preference.remoteOfficeSourceKind,
remoteOfficeLabel: preference.remoteOfficeLabel,
@@ -204,6 +237,7 @@ export const useStudioOfficePreference = ({
remoteOfficeGatewayUrl: preference.remoteOfficeGatewayUrl,
remoteOfficeTokenConfigured: preference.remoteOfficeTokenConfigured,
setTitle,
setAgentStateMapping,
setRemoteOfficeEnabled,
setRemoteOfficeSourceKind,
setRemoteOfficeLabel,
+157
View File
@@ -0,0 +1,157 @@
import type { AgentState } from "@/features/agents/state/store";
import type { OfficeAgentState } from "@/lib/office/schema";
export const OFFICE_RENDERABLE_AGENT_STATUSES = [
"idle",
"working",
"error",
] as const;
export type OfficeRenderableAgentStatus =
(typeof OFFICE_RENDERABLE_AGENT_STATUSES)[number];
export const LOCAL_OFFICE_STATE_SIGNALS = [
"idle",
"running",
"error",
] as const;
export type LocalOfficeStateSignal = (typeof LOCAL_OFFICE_STATE_SIGNALS)[number];
export const REMOTE_OFFICE_STATE_SIGNALS = [
"idle",
"working",
"meeting",
"error",
] as const;
export type RemoteOfficeStateSignal =
(typeof REMOTE_OFFICE_STATE_SIGNALS)[number];
export type OfficeAgentStateMapping = {
local: Record<LocalOfficeStateSignal, OfficeRenderableAgentStatus>;
remote: Record<RemoteOfficeStateSignal, OfficeRenderableAgentStatus>;
};
export type OfficeAgentStateMappingPatch = {
local?: Partial<Record<LocalOfficeStateSignal, OfficeRenderableAgentStatus>>;
remote?: Partial<Record<RemoteOfficeStateSignal, OfficeRenderableAgentStatus>>;
};
export const DEFAULT_OFFICE_AGENT_STATE_MAPPING: OfficeAgentStateMapping = {
local: {
idle: "idle",
running: "working",
error: "error",
},
remote: {
idle: "idle",
working: "working",
meeting: "working",
error: "error",
},
};
const isRenderableStatus = (
value: unknown,
): value is OfficeRenderableAgentStatus =>
typeof value === "string" &&
OFFICE_RENDERABLE_AGENT_STATUSES.includes(
value as OfficeRenderableAgentStatus,
);
const normalizeLocalMapping = (
value: unknown,
fallback: Record<LocalOfficeStateSignal, OfficeRenderableAgentStatus>,
): Record<LocalOfficeStateSignal, OfficeRenderableAgentStatus> => {
const source =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
idle: isRenderableStatus(source.idle) ? source.idle : fallback.idle,
running: isRenderableStatus(source.running)
? source.running
: fallback.running,
error: isRenderableStatus(source.error) ? source.error : fallback.error,
};
};
const normalizeRemoteMapping = (
value: unknown,
fallback: Record<RemoteOfficeStateSignal, OfficeRenderableAgentStatus>,
): Record<RemoteOfficeStateSignal, OfficeRenderableAgentStatus> => {
const source =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
idle: isRenderableStatus(source.idle) ? source.idle : fallback.idle,
working: isRenderableStatus(source.working)
? source.working
: fallback.working,
meeting: isRenderableStatus(source.meeting)
? source.meeting
: fallback.meeting,
error: isRenderableStatus(source.error) ? source.error : fallback.error,
};
};
export const normalizeOfficeAgentStateMapping = (
value: unknown,
fallback: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING,
): OfficeAgentStateMapping => {
const source =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
local: normalizeLocalMapping(source.local, fallback.local),
remote: normalizeRemoteMapping(source.remote, fallback.remote),
};
};
export const mergeOfficeAgentStateMapping = (
fallback: OfficeAgentStateMapping,
patch: OfficeAgentStateMappingPatch | null | undefined,
): OfficeAgentStateMapping => {
if (!patch) {
return normalizeOfficeAgentStateMapping(fallback, fallback);
}
return normalizeOfficeAgentStateMapping(
{
local: {
...fallback.local,
...(patch.local ?? {}),
},
remote: {
...fallback.remote,
...(patch.remote ?? {}),
},
},
fallback,
);
};
export const resolveLocalOfficeStateSignal = (
agent: Pick<AgentState, "status" | "runId">,
): LocalOfficeStateSignal => {
if (agent.status === "error") {
return "error";
}
if (agent.status === "running" || Boolean(agent.runId)) {
return "running";
}
return "idle";
};
export const resolveLocalOfficeRenderableStatus = (
agent: Pick<AgentState, "status" | "runId">,
mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING,
): OfficeRenderableAgentStatus =>
mapping.local[resolveLocalOfficeStateSignal(agent)];
export const resolveRemoteOfficeRenderableStatus = (
state: OfficeAgentState,
mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING,
): OfficeRenderableAgentStatus => mapping.remote[state];
+16
View File
@@ -14,6 +14,12 @@ import {
type TaskBoardPreference,
type TaskBoardPreferencePatch,
} from "@/features/office/tasks/types";
import {
DEFAULT_OFFICE_AGENT_STATE_MAPPING,
normalizeOfficeAgentStateMapping,
type OfficeAgentStateMapping,
type OfficeAgentStateMappingPatch,
} from "@/lib/office/agentStateMapping";
export type StudioGatewaySettings = {
url: string;
@@ -116,6 +122,7 @@ export type StudioVoiceRepliesPreferencePatch = {
export type StudioOfficePreference = {
title: string;
agentStateMapping: OfficeAgentStateMapping;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
@@ -133,6 +140,7 @@ export type StudioOfficePreference = {
export type StudioOfficePreferencePublic = {
title: string;
agentStateMapping: OfficeAgentStateMapping;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
@@ -150,6 +158,7 @@ export type StudioOfficePreferencePublic = {
export type StudioOfficePreferencePatch = {
title?: string | null;
agentStateMapping?: OfficeAgentStateMappingPatch | null;
remoteOfficeEnabled?: boolean;
remoteOfficeSourceKind?: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel?: string | null;
@@ -506,6 +515,7 @@ const normalizeCompanyRoleTitles = (value: unknown, fallback: string[] = []) =>
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
title: DEFAULT_OFFICE_TITLE,
agentStateMapping: DEFAULT_OFFICE_AGENT_STATE_MAPPING,
remoteOfficeEnabled: false,
remoteOfficeSourceKind: DEFAULT_REMOTE_OFFICE_SOURCE_KIND,
remoteOfficeLabel: DEFAULT_REMOTE_OFFICE_LABEL,
@@ -524,6 +534,7 @@ export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
export const defaultStudioOfficePreferencePublic =
(): StudioOfficePreferencePublic => ({
title: DEFAULT_OFFICE_TITLE,
agentStateMapping: DEFAULT_OFFICE_AGENT_STATE_MAPPING,
remoteOfficeEnabled: false,
remoteOfficeSourceKind: DEFAULT_REMOTE_OFFICE_SOURCE_KIND,
remoteOfficeLabel: DEFAULT_REMOTE_OFFICE_LABEL,
@@ -543,6 +554,7 @@ export const sanitizeStudioOfficePreference = (
value: StudioOfficePreference
): StudioOfficePreferencePublic => ({
title: value.title,
agentStateMapping: value.agentStateMapping,
remoteOfficeEnabled: value.remoteOfficeEnabled,
remoteOfficeSourceKind: value.remoteOfficeSourceKind,
remoteOfficeLabel: value.remoteOfficeLabel,
@@ -968,6 +980,10 @@ const normalizeOfficePreference = (
if (!isRecord(value)) return fallback;
return {
title: normalizeOfficeTitle(value.title, fallback.title),
agentStateMapping: normalizeOfficeAgentStateMapping(
value.agentStateMapping,
fallback.agentStateMapping,
),
remoteOfficeEnabled:
typeof value.remoteOfficeEnabled === "boolean"
? value.remoteOfficeEnabled
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_OFFICE_AGENT_STATE_MAPPING,
mergeOfficeAgentStateMapping,
normalizeOfficeAgentStateMapping,
resolveLocalOfficeRenderableStatus,
resolveLocalOfficeStateSignal,
resolveRemoteOfficeRenderableStatus,
} from "@/lib/office/agentStateMapping";
describe("office agent state mapping", () => {
it("keeps the default mapping stable", () => {
expect(DEFAULT_OFFICE_AGENT_STATE_MAPPING.local.running).toBe("working");
expect(DEFAULT_OFFICE_AGENT_STATE_MAPPING.remote.meeting).toBe("working");
});
it("normalizes partial mappings against defaults", () => {
const normalized = normalizeOfficeAgentStateMapping({
local: {
running: "idle",
},
remote: {
meeting: "error",
},
});
expect(normalized.local).toEqual({
idle: "idle",
running: "idle",
error: "error",
});
expect(normalized.remote).toEqual({
idle: "idle",
working: "working",
meeting: "error",
error: "error",
});
});
it("merges patch values without discarding untouched signals", () => {
const merged = mergeOfficeAgentStateMapping(
DEFAULT_OFFICE_AGENT_STATE_MAPPING,
{
local: { running: "idle" },
remote: { working: "error" },
},
);
expect(merged.local.idle).toBe("idle");
expect(merged.local.running).toBe("idle");
expect(merged.remote.working).toBe("error");
expect(merged.remote.meeting).toBe("working");
});
it("resolves local signals from runtime status and run ids", () => {
expect(
resolveLocalOfficeStateSignal({
status: "idle",
runId: "run-1",
}),
).toBe("running");
expect(
resolveLocalOfficeStateSignal({
status: "error",
runId: null,
}),
).toBe("error");
});
it("uses the configured renderable status for local and remote states", () => {
const mapping = mergeOfficeAgentStateMapping(
DEFAULT_OFFICE_AGENT_STATE_MAPPING,
{
local: { running: "idle" },
remote: { meeting: "error" },
},
);
expect(
resolveLocalOfficeRenderableStatus(
{
status: "running",
runId: "run-2",
},
mapping,
),
).toBe("idle");
expect(resolveRemoteOfficeRenderableStatus("meeting", mapping)).toBe("error");
});
});
+75
View File
@@ -192,6 +192,81 @@ describe("studio settings normalization", () => {
);
});
it("normalizes office agent state mappings per gateway", () => {
const normalized = normalizeStudioSettings({
office: {
" [REDACTED] ": {
agentStateMapping: {
local: { running: "idle" },
remote: { meeting: "error" },
},
},
},
});
expect(normalized.office["[REDACTED]"]).toEqual(
expect.objectContaining({
agentStateMapping: {
local: {
idle: "idle",
running: "idle",
error: "error",
},
remote: {
idle: "idle",
working: "working",
meeting: "error",
error: "error",
},
},
}),
);
});
it("merges office agent state mapping patches", () => {
const current = normalizeStudioSettings({
office: {
"[REDACTED]": {
agentStateMapping: {
local: { running: "working" },
remote: { meeting: "working" },
},
},
},
});
const merged = mergeStudioSettings(current, {
office: {
"[REDACTED]": {
agentStateMapping: {
local: { running: "idle" },
remote: { error: "working" },
},
},
},
});
expect(merged.office["[REDACTED]"]).toEqual(
expect.objectContaining({
agentStateMapping: {
local: {
idle: "idle",
running: "idle",
error: "error",
},
remote: {
idle: "idle",
working: "working",
meeting: "working",
error: "working",
},
},
}),
);
});
it("normalizes task board cards per gateway", () => {
const normalized = normalizeStudioSettings({
taskBoard: {