diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3efef9a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,45 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.9.0-alpha.0] — 2026-04-06
+
+### ⚠️ Alpha Preview
+
+> **This is early, unstable software.** Expect rough edges, breaking changes, and incomplete features. Do not use in production environments.
+
+### Added
+- **Avatar system** — Per-agent avatar selection with auto-generated (multiavatar lib), default (12 PNG profiles), and custom URL modes; globally toggled from the footer
+- **Fleet sidebar soul names** — Agent cards now display identity names fetched from each agent's `IDENTITY.md`, with improved card layout and sizing
+- **Footer avatar mode toggle** — Quick dropdown to switch between auto/default/custom avatar sources; persists to localStorage
+- **`AgentCreateModal` types** — Added `AgentCreationType` union (`create` | `resume`) for clearer creation vs. resumption flows
+- **`AgentSettingsMutationController`** — New React controller hook for batched, optimistic agent settings updates with conflict resolution
+- **Fleet hydration derivation** — `deriveDefaultIndex` utility shared across `FooterBar`, `TasksDashboard`, and fleet tiles for consistent avatar mapping across reloads
+- **`agentFleetHydration` operation** — Fetches `IDENTITY.md` per agent at hydration time; shows soul names in fleet cards
+- **Gateway version display** — Client-side fetch of OpenClaw gateway version displayed in footer
+- **E2E test coverage** — Settings panel test using specific heading locator; bootstrap workflow tests
+
+### Changed
+- **Fleet tile avatars** — Increased from 80px to 96px; now fill card height with `object-cover` centering
+- **Fleet card layout** — Identity name shown as muted subtitle above agent name; soul name bold/larger; agent name small and muted below
+- **`identityName` field** — `agent.identity?.name` is now the single source of truth for display names across all components
+- **`FooterBar` avatar toggle** — Now shows whenever agents exist (not only when an agent is running); aligned with global `AvatarModeContext`
+- **`AgentAvatar` context** — Now reads `AvatarModeContext` so footer, fleet tiles, and task cards stay in sync
+- **Avatar mode context** — Shared globally via `AvatarModeContext.tsx` to prevent stale state from `useEffect` localStorage reads
+- **Seed hashing** — Default avatars use `seed + index` blending to ensure all agents get unique default images on each reload
+
+### Fixed
+- **Avatar centering** — `fill` mode avatars now use `relative` positioning with `object-cover` to prevent overflow
+- **Avatar sync on reload** — Agents now get consistent (but distinct) default avatar images even after page reload
+- **Identity name priority** — Fixed priority chain: `agent.identity?.name` → `agent.identityName` → derived from identity file
+- **Footer default avatar** — Footer avatar in default mode now uses shared `deriveDefaultIndex` for correct index calculation
+- **Lint errors** — Fixed `setState-in-effect` lint errors and resolved all remaining lint issues
+
+### Infrastructure
+- GitHub Actions CI pipeline for automated testing on push/PR
+- Playwright E2E test suite with maximized browser window support
+- Vitest unit test suite
+- ESLint + TypeScript strict type checking
diff --git a/README.md b/README.md
index 4711dee..efab007 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,17 @@
diff --git a/package.json b/package.json
index 78025ab..fca2bc6 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@simoncatbot/rocclaw",
- "version": "0.1.0",
- "description": "Focused operator studio for OpenClaw - Web dashboard for AI agent management",
+ "version": "0.9.0-alpha.0",
+ "description": "⚠️ ALPHA PREVIEW — Focused operator studio for OpenClaw AI agents. This is early software — expect rough edges.",
"main": "server/index.js",
"bin": {
"rocclaw": "./server/index.js"
@@ -19,7 +19,9 @@
"agent",
"dashboard",
"chat",
- "nextjs"
+ "nextjs",
+ "alpha",
+ "operator-studio"
],
"author": "SimonCatBot",
"license": "MIT",
diff --git a/tests/e2e/agent-avatar-settings.spec.ts b/tests/e2e/agent-avatar-settings.spec.ts
new file mode 100644
index 0000000..2696ca5
--- /dev/null
+++ b/tests/e2e/agent-avatar-settings.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from "@playwright/test";
+import { stubRocclawRoute } from "./helpers/rocclawRoute";
+import { stubRuntimeRoutes } from "./helpers/runtimeRoute";
+
+const FLEET_WITH_TWO_AGENTS = {
+ seeds: [
+ { agentId: "agent-1", name: "Alice", sessionKey: "agent:agent-1:main" },
+ { agentId: "agent-2", name: "Bob", sessionKey: "agent:agent-2:main" },
+ ],
+ sessionCreatedAgentIds: [],
+ sessionSettingsSyncedAgentIds: [],
+ summaryPatches: [],
+ suggestedSelectedAgentId: "agent-1",
+ configSnapshot: null,
+};
+
+test.beforeEach(async ({ page }) => {
+ await stubRocclawRoute(page, {
+ version: 1,
+ gateway: null,
+ focused: {},
+ avatars: {},
+ });
+ await stubRuntimeRoutes(page, {
+ summary: { status: "connected" },
+ fleetResult: FLEET_WITH_TWO_AGENTS,
+ });
+});
+
+test("fleet row click applies selected CSS class to clicked agent", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+ await expect(page.getByTestId("fleet-agent-row-agent-2")).toBeVisible();
+
+ // Agent-2 starts unselected
+ const agent2 = page.getByTestId("fleet-agent-row-agent-2");
+ await expect(agent2).not.toHaveClass(/ui-card-selected/);
+
+ // Click selects agent-2
+ await agent2.click();
+ await expect(agent2).toHaveClass(/ui-card-selected/);
+
+ // Agent-1 is no longer selected
+ const agent1 = page.getByTestId("fleet-agent-row-agent-1");
+ await expect(agent1).not.toHaveClass(/ui-card-selected/);
+});
+
+test("fleet row click triggers PUT request with focused selectedAgentId", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+
+ // Capture the focused PUT request
+ const focusedPut = page.waitForRequest((req) => {
+ if (!req.url().includes("/api/rocclaw") || req.method() !== "PUT") return false;
+ try {
+ const payload = JSON.parse(req.postData() ?? "{}");
+ return (
+ payload.focused &&
+ Object.values(payload.focused).some(
+ (entry: unknown) =>
+ typeof entry === "object" &&
+ entry !== null &&
+ "selectedAgentId" in entry &&
+ (entry as { selectedAgentId: string }).selectedAgentId === "agent-2"
+ )
+ );
+ } catch {
+ return false;
+ }
+ });
+
+ await page.getByTestId("fleet-agent-row-agent-2").click();
+ const request = await focusedPut;
+
+ const payload = JSON.parse(request.postData() ?? "{}");
+ const focusedEntry = Object.values(payload.focused ?? {})[0] as {
+ selectedAgentId: string;
+ } | null;
+ expect(focusedEntry?.selectedAgentId).toBe("agent-2");
+});
+
+test("avatar shuffle triggers PUT request with avatar data", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+ await page.getByTestId("fleet-agent-row-agent-1").click();
+
+ // Wait for the inspect panel's AvatarSelector to render — look for shuffle button
+ await expect(page.getByRole("button", { name: /shuffle/i })).toBeVisible({ timeout: 10000 });
+
+ // Capture avatar PUT triggered by shuffle
+ const avatarPut = page.waitForRequest((req) => {
+ if (!req.url().includes("/api/rocclaw") || req.method() !== "PUT") return false;
+ try {
+ const payload = JSON.parse(req.postData() ?? "{}");
+ return "avatars" in payload || "avatarSources" in payload;
+ } catch {
+ return false;
+ }
+ });
+
+ await page.getByRole("button", { name: /shuffle/i }).click();
+
+ const request = await avatarPut;
+ expect(request).toBeDefined();
+ const payload = JSON.parse(request.postData() ?? "{}");
+ expect(payload.avatars ?? payload.avatarSources).toBeDefined();
+});
diff --git a/tests/e2e/avatar-toggle.spec.ts b/tests/e2e/avatar-toggle.spec.ts
new file mode 100644
index 0000000..0dd3d69
--- /dev/null
+++ b/tests/e2e/avatar-toggle.spec.ts
@@ -0,0 +1,130 @@
+import { expect, test } from "@playwright/test";
+import { stubRocclawRoute } from "./helpers/rocclawRoute";
+import { stubRuntimeRoutes } from "./helpers/runtimeRoute";
+
+const FLEET_WITH_TWO_AGENTS = {
+ seeds: [
+ { agentId: "agent-1", name: "Alice", sessionKey: "agent:agent-1:main" },
+ { agentId: "agent-2", name: "Bob", sessionKey: "agent:agent-2:main" },
+ ],
+ sessionCreatedAgentIds: [],
+ sessionSettingsSyncedAgentIds: [],
+ summaryPatches: [],
+ suggestedSelectedAgentId: "agent-1",
+ configSnapshot: null,
+};
+
+test.beforeEach(async ({ page }) => {
+ await stubRocclawRoute(page, {
+ version: 1,
+ gateway: null,
+ focused: {},
+ avatars: {},
+ });
+ await stubRuntimeRoutes(page, {
+ summary: { status: "connected" },
+ fleetResult: FLEET_WITH_TWO_AGENTS,
+ });
+});
+
+test("footer avatar toggle opens dropdown with all three mode options", async ({ page }) => {
+ await page.goto("/");
+
+ // Wait for fleet to hydrate
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+
+ // Footer is rendered
+ const footer = page.locator("footer");
+ await expect(footer).toBeVisible();
+
+ // Footer avatar toggle button is present
+ const avatarToggle = page.getByRole("button", { name: /avatar mode: auto/i });
+ await expect(avatarToggle).toBeVisible();
+
+ // Open the dropdown
+ await avatarToggle.click();
+
+ // Dropdown items appear with descriptive accessible names
+ // Each mode button's accessible name is "{label} {description}"
+ await expect(page.getByRole("button", { name: /auto procedural/i })).toBeVisible();
+ await expect(page.getByRole("button", { name: /default profile/i })).toBeVisible();
+ await expect(page.getByRole("button", { name: /custom custom/i })).toBeVisible();
+});
+
+test("selecting default mode closes dropdown and updates toggle title", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+
+ // Open dropdown
+ await page.getByRole("button", { name: /avatar mode: auto/i }).click();
+
+ // Select Default mode
+ await page.getByRole("button", { name: /default profile/i }).click();
+
+ // Dropdown is closed
+ await expect(page.getByRole("button", { name: /auto procedural/i })).not.toBeVisible();
+
+ // Toggle reflects the new mode — re-query to avoid stale locator
+ await expect(
+ page.getByRole("button", { name: /avatar mode: default/i })
+ ).toHaveAttribute("title", "Avatar mode: Default");
+});
+
+test("selecting custom mode persists across page reload", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+
+ // Select Custom
+ await page.getByRole("button", { name: /avatar mode: auto/i }).click();
+ await page.getByRole("button", { name: /custom custom/i }).click();
+
+ // Reload — mode is restored from localStorage
+ await page.reload();
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /avatar mode: custom/i })
+ ).toHaveAttribute("title", "Avatar mode: Custom");
+});
+
+test("footer avatar toggle is present even when fleet is empty", async ({ page }) => {
+ // Override stubs with empty fleet before navigating
+ await stubRuntimeRoutes(page, {
+ summary: { status: "connected" },
+ fleetResult: {
+ seeds: [],
+ sessionCreatedAgentIds: [],
+ sessionSettingsSyncedAgentIds: [],
+ summaryPatches: [],
+ suggestedSelectedAgentId: null,
+ configSnapshot: null,
+ },
+ });
+
+ await page.goto("/");
+
+ // Footer is rendered
+ const footer = page.locator("footer");
+ await expect(footer).toBeVisible();
+
+ // Avatar toggle is still present even when fleet is empty
+ const avatarToggle = page.getByRole("button", { name: /avatar mode/i });
+ await expect(avatarToggle).toBeVisible();
+ await expect(avatarToggle).toHaveAttribute("title", "Avatar mode: Auto");
+});
+
+test("agent row selection is reflected via CSS class change", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByTestId("fleet-agent-row-agent-1")).toBeVisible();
+
+ // Agent-2 starts unselected
+ const agent2 = page.getByTestId("fleet-agent-row-agent-2");
+ await expect(agent2).not.toHaveClass(/ui-card-selected/);
+
+ // Click selects agent-2
+ await agent2.click();
+ await expect(agent2).toHaveClass(/ui-card-selected/);
+
+ // Agent-1 is no longer selected
+ const agent1 = page.getByTestId("fleet-agent-row-agent-1");
+ await expect(agent1).not.toHaveClass(/ui-card-selected/);
+});
diff --git a/tests/unit/agentAvatar.test.ts b/tests/unit/agentAvatar.test.ts
new file mode 100644
index 0000000..f5f6071
--- /dev/null
+++ b/tests/unit/agentAvatar.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+
+import { deriveDefaultIndex, buildDefaultAvatarUrl } from "@/features/agents/components/AgentAvatar";
+
+describe("deriveDefaultIndex", () => {
+ it("returns a number within [0, 11] for any seed", () => {
+ for (let i = 0; i < 100; i++) {
+ const index = deriveDefaultIndex(`agent-${i}`, 0);
+ expect(index).toBeGreaterThanOrEqual(0);
+ expect(index).toBeLessThan(12);
+ }
+ });
+
+ it("produces different indices for different seeds", () => {
+ const results = new Set();
+ for (let i = 0; i < 20; i++) {
+ results.add(deriveDefaultIndex(`agent-${i}`, 0));
+ }
+ // With 20 agents and 12 options, we expect at least some collisions are likely,
+ // but most should be unique — at minimum more than 1 unique value
+ expect(results.size).toBeGreaterThan(1);
+ });
+
+ it("different explicitIndex values yield different results for the same seed", () => {
+ const base = deriveDefaultIndex("same-seed", 0);
+ const withIndex = deriveDefaultIndex("same-seed", 5);
+ expect(withIndex).not.toBe(base);
+ });
+
+ it("same seed + same index always returns the same value (deterministic)", () => {
+ const first = deriveDefaultIndex("deterministic-agent", 3);
+ for (let i = 0; i < 10; i++) {
+ expect(deriveDefaultIndex("deterministic-agent", 3)).toBe(first);
+ }
+ });
+
+ it("handles empty seed string", () => {
+ const index = deriveDefaultIndex("", 0);
+ expect(index).toBeGreaterThanOrEqual(0);
+ expect(index).toBeLessThan(12);
+ });
+
+ it("handles negative explicitIndex as UNSET (uses seed hash only)", () => {
+ // -1 is UNSET_INDEX sentinel
+ const withNeg = deriveDefaultIndex("test-seed", -1);
+
+ // -1 should produce a different result than 0 in most cases
+ // (because UNSET_INDEX = -1 causes base=0, but hash still varies)
+ expect(typeof withNeg).toBe("number");
+ });
+
+ it("handles very large explicitIndex by wrapping into range", () => {
+ const index = deriveDefaultIndex("agent-1", 9999);
+ expect(index).toBeGreaterThanOrEqual(0);
+ expect(index).toBeLessThan(12);
+ });
+});
+
+describe("buildDefaultAvatarUrl", () => {
+ it("returns a /avatars/profile-N.png path", () => {
+ const url = buildDefaultAvatarUrl(0);
+ expect(url).toMatch(/^\/avatars\/profile-(\d+)\.png$/);
+ });
+
+ it("returns profile-1.png for index 0", () => {
+ const url = buildDefaultAvatarUrl(0);
+ expect(url).toBe("/avatars/profile-1.png");
+ });
+
+ it("returns profile-12.png for index 11", () => {
+ const url = buildDefaultAvatarUrl(11);
+ expect(url).toBe("/avatars/profile-12.png");
+ });
+
+ it("wraps index 12 back to profile-1.png", () => {
+ const url = buildDefaultAvatarUrl(12);
+ expect(url).toBe("/avatars/profile-1.png");
+ });
+
+ it("wraps negative index to valid range", () => {
+ const url = buildDefaultAvatarUrl(-1);
+ expect(url).toMatch(/^\/avatars\/profile-(\d+)\.png$/);
+ expect(url).not.toBe("/avatars/profile-0.png");
+ });
+
+ it("handles large indices by wrapping", () => {
+ const url = buildDefaultAvatarUrl(999);
+ expect(url).toMatch(/^\/avatars\/profile-(\d+)\.png$/);
+ });
+});
diff --git a/tests/unit/avatarModeContext.test.ts b/tests/unit/avatarModeContext.test.ts
new file mode 100644
index 0000000..5100f08
--- /dev/null
+++ b/tests/unit/avatarModeContext.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+
+import {
+ AvatarModeProvider,
+ useAvatarMode,
+ useSetAvatarMode,
+} from "@/components/AvatarModeContext";
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key];
+ }),
+ clear: () => {
+ store = {};
+ },
+ };
+})();
+Object.defineProperty(window, "localStorage", { value: localStorageMock });
+
+describe("AvatarModeContext", () => {
+ afterEach(() => {
+ localStorageMock.clear();
+ vi.clearAllMocks();
+ });
+
+ describe("useAvatarMode", () => {
+ it("defaults to 'auto' when nothing is stored", () => {
+ // @ts-expect-error -- localStorage getItem returns string|null
+ localStorageMock.getItem.mockReturnValue(null);
+ const { result } = renderHook(() => useAvatarMode(), {
+ wrapper: AvatarModeProvider,
+ });
+ expect(result.current).toBe("auto");
+ });
+
+ it("returns stored 'default' mode", () => {
+ localStorageMock.getItem.mockReturnValue("default");
+ const { result } = renderHook(() => useAvatarMode(), {
+ wrapper: AvatarModeProvider,
+ });
+ expect(result.current).toBe("default");
+ });
+
+ it("returns stored 'custom' mode", () => {
+ localStorageMock.getItem.mockReturnValue("custom");
+ const { result } = renderHook(() => useAvatarMode(), {
+ wrapper: AvatarModeProvider,
+ });
+ expect(result.current).toBe("custom");
+ });
+
+ it("returns 'auto' for unknown stored values", () => {
+ localStorageMock.getItem.mockReturnValue("unknown-mode");
+ const { result } = renderHook(() => useAvatarMode(), {
+ wrapper: AvatarModeProvider,
+ });
+ expect(result.current).toBe("auto");
+ });
+ });
+
+ describe("useSetAvatarMode", () => {
+ it("persists mode to localStorage", () => {
+ const { result } = renderHook(() => useSetAvatarMode(), {
+ wrapper: AvatarModeProvider,
+ });
+ result.current("default");
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ "rocclaw-footer-avatar-mode",
+ "default"
+ );
+ });
+
+ it("persists 'custom' to localStorage", () => {
+ const { result } = renderHook(() => useSetAvatarMode(), {
+ wrapper: AvatarModeProvider,
+ });
+ result.current("custom");
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ "rocclaw-footer-avatar-mode",
+ "custom"
+ );
+ });
+ });
+
+ describe("AvatarModeProvider integration", () => {
+ it("changing mode via useSetAvatarMode updates useAvatarMode", async () => {
+ const { result } = renderHook(
+ () => ({
+ mode: useAvatarMode(),
+ setMode: useSetAvatarMode(),
+ }),
+ { wrapper: AvatarModeProvider }
+ );
+
+ expect(result.current.mode).toBe("auto");
+ result.current.setMode("default");
+
+ await waitFor(() => {
+ expect(result.current.mode).toBe("default");
+ });
+ });
+
+ it("round-trips: auto → default → custom → auto", async () => {
+ const { result } = renderHook(
+ () => ({ mode: useAvatarMode(), setMode: useSetAvatarMode() }),
+ { wrapper: AvatarModeProvider }
+ );
+
+ expect(result.current.mode).toBe("auto");
+
+ result.current.setMode("default");
+ await waitFor(() => expect(result.current.mode).toBe("default"));
+
+ result.current.setMode("custom");
+ await waitFor(() => expect(result.current.mode).toBe("custom"));
+
+ result.current.setMode("auto");
+ await waitFor(() => expect(result.current.mode).toBe("auto"));
+ });
+ });
+});
diff --git a/tests/unit/avatarSelector.test.ts b/tests/unit/avatarSelector.test.ts
new file mode 100644
index 0000000..b257f53
--- /dev/null
+++ b/tests/unit/avatarSelector.test.ts
@@ -0,0 +1,110 @@
+import { createElement } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import {
+ AvatarSelector,
+ type AvatarSelectorValue,
+} from "@/features/agents/components/AvatarSelector";
+
+// Mock the avatar building utilities
+vi.mock("@/lib/avatars/multiavatar", () => ({
+ buildAvatarDataUrl: vi.fn((seed: string) => `data:auto+svg;seed=${seed}`),
+}));
+
+vi.mock("@/features/agents/components/AgentAvatar", () => ({
+ buildDefaultAvatarUrl: vi.fn((index: number) => `/avatars/profile-${index + 1}.png`),
+ deriveDefaultIndex: vi.fn((seed: string, i: number) => (seed.length + i) % 12),
+ AgentAvatar: vi.fn(({ seed, name }: { seed: string; name: string }) =>
+ createElement("img", { src: `data:auto+svg;seed=${seed}`, alt: name })
+ ),
+}));
+
+const defaultValue: AvatarSelectorValue = {
+ avatarSource: "auto",
+ avatarSeed: "test-agent-seed",
+ defaultAvatarIndex: 0,
+ avatarUrl: "",
+};
+
+describe("AvatarSelector", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ const renderSelector = (
+ value: AvatarSelectorValue = defaultValue,
+ onChange = vi.fn(),
+ name = "avatar"
+ ) =>
+ render(
+ createElement(AvatarSelector, { name, value, onChange })
+ );
+
+ it("renders the auto tab as active by default", () => {
+ renderSelector();
+ const activeTab = screen.getByRole("button", { name: "Auto" });
+ expect(activeTab).toHaveClass("bg-primary");
+ });
+
+ it("renders a grid of default avatar options", () => {
+ const { container } = renderSelector({
+ ...defaultValue,
+ avatarSource: "default",
+ });
+ const avatars = container.querySelectorAll("img");
+ expect(avatars.length).toBeGreaterThanOrEqual(6);
+ });
+
+ it("calls onChange with updated seed when shuffle is clicked", () => {
+ const onChange = vi.fn();
+ renderSelector(
+ { ...defaultValue, avatarSource: "auto", avatarSeed: "original-seed" },
+ onChange
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /shuffle/i }));
+ expect(onChange).toHaveBeenCalled();
+ const call = onChange.mock.calls[0][0] as AvatarSelectorValue;
+ expect(call.avatarSeed).toBeTruthy();
+ });
+
+ it("calls onChange when a default avatar is selected", async () => {
+ const onChange = vi.fn();
+ const { container } = renderSelector(
+ { ...defaultValue, avatarSource: "default" },
+ onChange
+ );
+
+ // Switch to the Default tab first (component defaults to Auto) — this fires onChange once
+ const defaultTab = screen.getByRole("button", { name: /^default$/i });
+ fireEvent.click(defaultTab);
+
+ // Wait for the grid to appear
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Click "Avatar 3" button in the grid
+ const avatarButton = container.querySelector('button[title="Avatar 3"]');
+ expect(avatarButton).not.toBeNull();
+ fireEvent.click(avatarButton!);
+
+ // onChange fired for tab switch + avatar selection
+ expect(onChange).toHaveBeenCalled();
+ // Find the call with the correct avatar index
+ const avatarCalls = onChange.mock.calls.filter(
+ (call) => (call[0] as AvatarSelectorValue).defaultAvatarIndex === 2
+ );
+ expect(avatarCalls.length).toBeGreaterThan(0);
+ });
+
+ it("calls onChange with custom source when custom tab is clicked", () => {
+ const onChange = vi.fn();
+ renderSelector({ ...defaultValue, avatarSource: "auto" }, onChange);
+
+ const customButton = screen.getByRole("button", { name: /custom/i });
+ fireEvent.click(customButton);
+ expect(onChange).toHaveBeenCalled();
+ const call = onChange.mock.calls[0][0] as AvatarSelectorValue;
+ expect(call.avatarSource).toBe("custom");
+ });
+});
diff --git a/tests/unit/rocclawSettings.test.ts b/tests/unit/rocclawSettings.test.ts
new file mode 100644
index 0000000..2db1919
--- /dev/null
+++ b/tests/unit/rocclawSettings.test.ts
@@ -0,0 +1,270 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeROCclawSettings,
+ mergeROCclawSettings,
+ resolveFocusedPreference,
+ resolveAgentAvatarSeed,
+ resolveAgentAvatarConfig,
+
+ normalizeGatewayKey,
+} from "@/lib/rocclaw/settings";
+
+describe("normalizeGatewayKey", () => {
+ it("normalizes https://127.0.0.1:18789 → localhost:18789", () => {
+ expect(normalizeGatewayKey("https://127.0.0.1:18789/")).toMatch(/localhost/);
+ });
+
+ it("preserves non-loopback URLs", () => {
+ expect(normalizeGatewayKey("https://gateway.example.com:9000")).toBe(
+ "https://gateway.example.com:9000"
+ );
+ });
+
+ it("returns null for empty string", () => {
+ expect(normalizeGatewayKey("")).toBe(null);
+ });
+
+ it("trims whitespace", () => {
+ expect(normalizeGatewayKey(" https://127.0.0.1:18789 ")).toMatch(/localhost/);
+ });
+});
+
+describe("normalizeROCclawSettings", () => {
+ it("returns defaults when input is null/undefined", () => {
+ const result = normalizeROCclawSettings(null);
+ expect(result.gateway).toBe(null);
+ expect(result.gatewayAutoStart).toBe(true);
+ expect(result.version).toBe(1);
+ expect(result.focused).toEqual({});
+ expect(result.avatars).toEqual({});
+ expect(result.avatarSources).toEqual({});
+ });
+
+ it("accepts minimal valid input", () => {
+ const result = normalizeROCclawSettings({});
+ expect(result.version).toBe(1);
+ expect(result.gatewayAutoStart).toBe(true);
+ });
+
+ it("normalizes loopback gateway URLs", () => {
+ const result = normalizeROCclawSettings({
+ gateway: { url: "https://user:pass@127.0.0.1:18789/", token: "secret" },
+ });
+ expect(result.gateway?.url).toMatch(/localhost/);
+ expect(result.gateway?.token).toBe("secret");
+ });
+
+ it("drops gateway with missing URL", () => {
+ const result = normalizeROCclawSettings({ gateway: { token: "tok" } });
+ expect(result.gateway).toBe(null);
+ });
+
+ it("normalizes avatar entries with loopback keys", () => {
+ const result = normalizeROCclawSettings({
+ avatars: {
+ "https://127.0.0.1:18789/": {
+ "agent-1": "my-seed",
+ },
+ },
+ });
+ expect(Object.keys(result.avatars)[0]).toMatch(/localhost/);
+ });
+
+ it("ignores empty agent IDs in avatars", () => {
+ const result = normalizeROCclawSettings({
+ avatars: {
+ "https://127.0.0.1:18789/": {
+ "": "seed",
+ " ": "seed2",
+ },
+ },
+ });
+ const normalizedKey = Object.keys(result.avatars)[0];
+ expect(Object.keys(result.avatars[normalizedKey])).toHaveLength(0);
+ });
+});
+
+describe("mergeROCclawSettings", () => {
+ it("merges gateway URL without touching token", () => {
+ const current = normalizeROCclawSettings({
+ gateway: { url: "https://127.0.0.1:18789", token: "old-token" },
+ });
+ const result = mergeROCclawSettings(current, {
+ gateway: { url: "https://newhost:9999" },
+ });
+ expect(result.gateway?.url).toMatch(/newhost/);
+ expect(result.gateway?.token).toBe("old-token");
+ });
+
+ it("clears gateway when patch sets it to null", () => {
+ const current = normalizeROCclawSettings({
+ gateway: { url: "https://127.0.0.1:18789", token: "tok" },
+ });
+ const result = mergeROCclawSettings(current, { gateway: null });
+ expect(result.gateway).toBe(null);
+ });
+
+ it("merges focused preference for a gateway key", () => {
+ const current = normalizeROCclawSettings({});
+ const result = mergeROCclawSettings(current, {
+ focused: {
+ "https://127.0.0.1:18789": { filter: "running" },
+ },
+ });
+ expect(result.focused[Object.keys(result.focused)[0]]?.filter).toBe("running");
+ });
+
+ it("deletes focused entry when set to null", () => {
+ const current = normalizeROCclawSettings({
+ focused: { "https://127.0.0.1:18789": { filter: "all" } },
+ });
+ const key = Object.keys(current.focused)[0];
+ const result = mergeROCclawSettings(current, { focused: { [key]: null } });
+ expect(result.focused[key]).toBeUndefined();
+ });
+
+ it("deep-merges avatars per agent within a gateway", () => {
+ const current = normalizeROCclawSettings({
+ avatars: {
+ "https://127.0.0.1:18789": {
+ "agent-1": "seed-1",
+ },
+ },
+ });
+ const key = Object.keys(current.avatars)[0];
+ const result = mergeROCclawSettings(current, {
+ avatars: {
+ [key]: { "agent-2": "seed-2" },
+ },
+ });
+ expect(result.avatars[key]?.["agent-1"]).toBe("seed-1");
+ expect(result.avatars[key]?.["agent-2"]).toBe("seed-2");
+ });
+
+ it("deletes an agent avatar when seed is set to null", () => {
+ const current = normalizeROCclawSettings({
+ avatars: {
+ "https://127.0.0.1:18789": {
+ "agent-1": "seed-1",
+ },
+ },
+ });
+ const key = Object.keys(current.avatars)[0];
+ const result = mergeROCclawSettings(current, {
+ avatars: { [key]: { "agent-1": null } },
+ });
+ expect(result.avatars[key]?.["agent-1"]).toBeUndefined();
+ });
+
+ it("deep-merges avatarSources per agent", () => {
+ const current = normalizeROCclawSettings({
+ avatarSources: {
+ "https://127.0.0.1:18789": {
+ "agent-1": { source: "default", defaultIndex: 3 },
+ },
+ },
+ });
+ const key = Object.keys(current.avatarSources)[0];
+ const result = mergeROCclawSettings(current, {
+ avatarSources: {
+ [key]: { "agent-1": { source: "custom", url: "https://example.com/img.png" } },
+ },
+ });
+ expect(result.avatarSources[key]?.["agent-1"]?.source).toBe("custom");
+ expect(result.avatarSources[key]?.["agent-1"]?.defaultIndex).toBe(3); // preserved
+ expect(result.avatarSources[key]?.["agent-1"]?.url).toBe("https://example.com/img.png");
+ });
+});
+
+describe("resolveFocusedPreference", () => {
+ it("returns null when no focused settings exist", () => {
+ const settings = normalizeROCclawSettings({});
+ expect(resolveFocusedPreference(settings, "https://127.0.0.1:18789")).toBe(null);
+ });
+
+ it("returns the focused preference for a matching gateway URL", () => {
+ const settings = normalizeROCclawSettings({
+ focused: {
+ "https://127.0.0.1:18789": { filter: "running" },
+ },
+ });
+ const result = resolveFocusedPreference(settings, "https://127.0.0.1:18789");
+ expect(result?.filter).toBe("running");
+ });
+
+ it("normalizes loopback URL when resolving", () => {
+ const settings = normalizeROCclawSettings({
+ focused: {
+ "https://127.0.0.1:18789": { filter: "approvals" },
+ },
+ });
+ const result = resolveFocusedPreference(settings, "https://127.0.0.1:18789");
+ expect(result?.filter).toBe("approvals");
+ });
+});
+
+describe("resolveAgentAvatarSeed", () => {
+ it("returns null when no avatar settings exist", () => {
+ const settings = normalizeROCclawSettings({});
+ expect(resolveAgentAvatarSeed(settings, "https://127.0.0.1:18789", "agent-1")).toBe(null);
+ });
+
+ it("returns the seed for a matching agent", () => {
+ const settings = normalizeROCclawSettings({
+ avatars: {
+ "https://127.0.0.1:18789": {
+ "agent-1": "my-seed-value",
+ },
+ },
+ });
+ const result = resolveAgentAvatarSeed(settings, "https://127.0.0.1:18789", "agent-1");
+ expect(result).toBe("my-seed-value");
+ });
+
+ it("returns null for unknown agent", () => {
+ const settings = normalizeROCclawSettings({
+ avatars: {
+ "https://127.0.0.1:18789": {
+ "agent-1": "seed",
+ },
+ },
+ });
+ const result = resolveAgentAvatarSeed(settings, "https://127.0.0.1:18789", "agent-unknown");
+ expect(result).toBe(null);
+ });
+});
+
+describe("resolveAgentAvatarConfig", () => {
+ it("returns null when no config exists", () => {
+ const settings = normalizeROCclawSettings({});
+ expect(resolveAgentAvatarConfig(settings, "https://127.0.0.1:18789", "agent-1")).toBe(null);
+ });
+
+ it("returns the full config for a matching agent", () => {
+ const settings = normalizeROCclawSettings({
+ avatarSources: {
+ "https://127.0.0.1:18789": {
+ "agent-1": { source: "custom", defaultIndex: 5, url: "https://img.cload/avatar.png" },
+ },
+ },
+ });
+ const result = resolveAgentAvatarConfig(settings, "https://127.0.0.1:18789", "agent-1");
+ expect(result?.source).toBe("custom");
+ expect(result?.defaultIndex).toBe(5);
+ expect(result?.url).toBe("https://img.cload/avatar.png");
+ });
+
+ it("returns partial config when some fields are missing", () => {
+ const settings = normalizeROCclawSettings({
+ avatarSources: {
+ "https://127.0.0.1:18789": {
+ "agent-1": { source: "default" },
+ },
+ },
+ });
+ const result = resolveAgentAvatarConfig(settings, "https://127.0.0.1:18789", "agent-1");
+ expect(result?.source).toBe("default");
+ expect(result?.defaultIndex).toBeUndefined();
+ expect(result?.url).toBeUndefined();
+ });
+});