mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 03:02:37 +00:00
Add office fleet visibility and state mapping
Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
co-authored by
Luke The Dev
parent
e59dcbe520
commit
0def2d2e8a
@@ -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>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user