Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
571149e
feat(avatar): add auto/default/custom avatar sources with per-agent s…
Apr 3, 2026
68a130e
feat(avatar): wire AvatarSelector into AgentBrainPanel for post-creat…
Apr 3, 2026
6bd839d
feat(footer): add AvatarModeToggle dropdown (auto/default/custom)
Apr 3, 2026
d41bde0
fix: resolve lint errors in AgentInspectPanels
Apr 3, 2026
1f431ae
fix(footer): show avatar mode toggle whenever agents exist, not only …
Apr 3, 2026
823f5e0
fix(footer): always show avatar mode toggle next to theme toggle
Apr 3, 2026
5ca6526
fix(avatar): share avatar mode via React context so FooterBar stays i…
Apr 3, 2026
4f98ec2
fix(avatar): AgentAvatar reads context mode so fleet tiles stay in sync
Apr 3, 2026
39af823
fix(avatar): derive unique default index from seed hash so agents get…
Apr 3, 2026
fd0b780
fix(avatar): blend seed hash with explicit index so all agents get un…
Apr 3, 2026
4800a7c
fix: Add AvatarModeContext.tsx to eslint exception for setState-in-ef…
Apr 3, 2026
964cca9
chore: fix lint error — use lazy init instead of useEffect for localS…
Apr 3, 2026
d74a4ab
feat(avatar): increase fleet tile avatar from 80px to 96px
Apr 3, 2026
6a0a603
chore: replace default avatar images with updated bot profiles
Apr 3, 2026
ec75b60
fix(tasks): TasksDashboard avatar now respects global avatar mode toggle
Apr 3, 2026
17d8fca
feat(avatar): make fleet tile avatar fill entire card height
Apr 3, 2026
6678e36
fix(avatar): center fill-mode avatar with relative+object-cover
Apr 3, 2026
cc9e3a6
refactor(fleet): standardize card layout with centered avatar and cle…
Apr 3, 2026
332615d
style(fleet): add spacing between name and model badge
Apr 3, 2026
eb70e0b
style(fleet): show identityName as muted subtitle above agent name
Apr 3, 2026
3c99632
style(fleet): Soul name bold/larger, agent name small muted
Apr 3, 2026
c8b492f
test(e2e): fix settings panel test to use specific heading locator
Apr 3, 2026
f1ff02e
fix(fleet): correct identity name priority + increase card size
Apr 3, 2026
4f13a87
fix(identity): add identityName fields to AgentsListResult in hydration
Apr 3, 2026
a9fd4e5
style(fleet): show agentId instead of agentName as subtitle
Apr 3, 2026
8de90f4
feat(avatar): use 12 PNG profile images, update DEFAULT_AVATAR_COUNT
Apr 3, 2026
98c01ca
fix(identity): use agent.identity?.name as the only source of truth
Apr 3, 2026
2787990
fix(identity): always derive identityName from agent.identity?.name
Apr 3, 2026
d5dcb2c
fix(identity): restore flat identityName field with correct priority
Apr 3, 2026
8aa06bb
feat(fleet): fetch IDENTITY.md per agent to show soul names in cards
Apr 4, 2026
04d5388
fix: three avatar display fixes
Apr 4, 2026
f45116b
fix(tasks): use shared deriveDefaultIndex for consistent avatar mapping
Apr 4, 2026
b617fee
fix(footer): deriveDefaultIndex for footer avatar in default mode
Apr 4, 2026
f1c4a7d
test: add unit and e2e coverage for avatar system, settings, and footer
Apr 6, 2026
bd31630
fix(tests): resolve TypeScript errors from CI
Apr 6, 2026
f718f71
fix(tests): remove unused testTimeout import from vitest
Apr 6, 2026
5dec9c8
fix(avatarSelector test): use container.querySelector for grid buttons
Apr 6, 2026
9df2958
Merge remote-tracking branch 'origin/master' into feat/avatar-customi…
Apr 6, 2026
d0e6742
chore(lint): remove unused imports and variables
Apr 6, 2026
789c1cc
chore: restore 0.9.0-alpha.0 version, CHANGELOG, and alpha README header
Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<div align="center">

# 🎛️ rocCLAW
# ️ rocCLAW — Alpha Preview

**Your focused operator rocclaw for OpenClaw**
> **This is early, unstable software (v0.9.0-alpha).** Expect rough edges, breaking changes, and incomplete features. Do not use in production environments. Feedback welcome!

**Your focused operator studio for OpenClaw AI agents**

<p align="center">
<a href="https://discord.gg/EFkFHbZw"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-20.9%2B-339933?logo=nodedotjs&logoColor=white" alt="Node.js"></a>
<a href="https://github.com/simoncatbot/rocclaw/releases"><img src="https://img.shields.io/github/v/release/simoncatbot/rocclaw?include_prereleases&logo=github&color=blue" alt="GitHub Release"></a>
<a href="https://github.com/simoncatbot/rocclaw/releases"><img src="https://img.shields.io/github/v/release/simoncatbot/rocclaw?include_prereleases&logo=github&color=red" alt="GitHub Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License"></a>
<img src="https://img.shields.io/badge/version-0.9.0--alpha-red" alt="Version">
</p>

</div>
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -19,7 +19,9 @@
"agent",
"dashboard",
"chat",
"nextjs"
"nextjs",
"alpha",
"operator-studio"
],
"author": "SimonCatBot",
"license": "MIT",
Expand Down
110 changes: 110 additions & 0 deletions tests/e2e/agent-avatar-settings.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
130 changes: 130 additions & 0 deletions tests/e2e/avatar-toggle.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading
Loading