diff --git a/client/src/App.tsx b/client/src/App.tsx
index c5f9ab0..d98a1b5 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback, useRef } from 'react';
+import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { PhaserGame } from './game/PhaserGame';
import { useAgentState } from './hooks/useAgentState';
import { useSelectedAgent } from './hooks/useSelectedAgent';
@@ -15,10 +15,17 @@ import { Toasts, type ToastItem } from './components/Toasts';
import type { NotificationEntry } from './components/NotificationMenu';
import { useSettings } from './hooks/useSettings';
import { useAgentNotifications, type ToastPayload } from './hooks/useAgentNotifications';
+import { applyHeroLook, heroLookKey } from './heroLooks';
+import { useHeroLooks } from './hooks/useHeroLooks';
import './App.css';
export default function App() {
const { agents, activityLog, connected, configDirs } = useAgentState();
+ const [looks, upsertLook] = useHeroLooks();
+ const displayAgents = useMemo(
+ () => agents.map((a) => applyHeroLook(a, looks[heroLookKey(a)])),
+ [agents, looks],
+ );
const { selectedAgentId, selectAgent } = useSelectedAgent();
const [selectedBuilding, setSelectedBuilding] = useState<{
id: string;
@@ -73,13 +80,13 @@ export default function App() {
}, []);
const selectedAgent = selectedAgentId !== null
- ? agents.find((a) => a.id === selectedAgentId) ?? null
+ ? displayAgents.find((a) => a.id === selectedAgentId) ?? null
: null;
// Only show source badges when both providers have a LIVE agent — completed
// / error sessions don't count, otherwise the badge would linger after the
// last Codex hero finishes just because it's still in state.
- const liveAgents = agents.filter((a) => a.status !== 'completed' && a.status !== 'error');
+ const liveAgents = displayAgents.filter((a) => a.status !== 'completed' && a.status !== 'error');
const showSourceBadge = liveAgents.some((a) => a.source === 'claude')
&& liveAgents.some((a) => a.source === 'codex');
@@ -159,8 +166,8 @@ export default function App() {
}, []);
useEffect(() => {
- eventBridge.emit('agents:updated', agents);
- }, [agents]);
+ eventBridge.emit('agents:updated', displayAgents);
+ }, [displayAgents]);
useEffect(() => {
eventBridge.emit('selection:changed', selectedAgentId);
@@ -180,7 +187,7 @@ export default function App() {
{villageReady && (
{selectedAgent !== null && (
-
handleSelectAgent(null)} showSourceBadge={showSourceBadge} />
+ handleSelectAgent(null)}
+ showSourceBadge={showSourceBadge}
+ lookIsCustom={looks[heroLookKey(selectedAgent)] !== undefined}
+ onChangeLook={(look) => upsertLook(heroLookKey(selectedAgent), look)}
+ onResetLook={() => upsertLook(heroLookKey(selectedAgent), null)}
+ />
)}
{selectedBuilding !== null && (
setSelectedBuilding(null)}
/>
)}
void;
showSourceBadge: boolean;
+ lookIsCustom: boolean;
+ onChangeLook: (look: { heroClass: HeroClass; heroColor: HeroColor }) => void;
+ onResetLook: () => void;
}
function formatDuration(startMs: number): string {
@@ -37,7 +41,14 @@ function PathValue({ path, cwd, className }: { path: string; cwd: string; classN
);
}
-export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProps) {
+export function DetailPanel({
+ agent,
+ onClose,
+ showSourceBadge,
+ lookIsCustom,
+ onChangeLook,
+ onResetLook,
+}: DetailPanelProps) {
// Tick every second so duration-derived values refresh between server pushes.
const [, setTick] = useState(0);
useEffect(() => {
@@ -76,7 +87,10 @@ export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProp
return (
<>
-
+
e.stopPropagation()}
+ >
@@ -125,6 +139,15 @@ export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProp
) : (
+
Status
diff --git a/client/src/components/HeroLookPicker.css b/client/src/components/HeroLookPicker.css
new file mode 100644
index 0000000..1065c76
--- /dev/null
+++ b/client/src/components/HeroLookPicker.css
@@ -0,0 +1,94 @@
+.hero-look-picker {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.hero-look-picker-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.hero-look-picker-label {
+ color: #C4A35A;
+ font-family: 'Cinzel', serif;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.hero-look-reset {
+ background: none;
+ border: 1px solid rgba(196, 163, 90, 0.35);
+ color: #C4A35A;
+ font-family: inherit;
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.hero-look-reset:hover { background: rgba(196, 163, 90, 0.15); }
+.hero-look-reset:focus-visible {
+ outline: 2px solid #C4A35A;
+ outline-offset: 1px;
+}
+
+.hero-look-classes {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 6px;
+}
+
+.hero-look-class {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 4px 5px;
+ background: rgba(196, 163, 90, 0.06);
+ border: 1px solid rgba(196, 163, 90, 0.22);
+ border-radius: 4px;
+ color: #ddd;
+ font-family: inherit;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ cursor: pointer;
+}
+.hero-look-class:hover { background: rgba(196, 163, 90, 0.14); }
+.hero-look-class.selected {
+ border-color: #C4A35A;
+ background: rgba(196, 163, 90, 0.2);
+ color: #F5E6C8;
+}
+.hero-look-class:focus-visible {
+ outline: 2px solid #C4A35A;
+ outline-offset: 1px;
+}
+
+.hero-look-colors {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.hero-look-swatch {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 2px solid rgba(26, 26, 46, 0.9);
+ box-shadow: 0 0 0 1px rgba(196, 163, 90, 0.35);
+ cursor: pointer;
+ padding: 0;
+}
+.hero-look-swatch:hover { box-shadow: 0 0 0 2px rgba(196, 163, 90, 0.7); }
+.hero-look-swatch.selected {
+ box-shadow: 0 0 0 2px #C4A35A;
+}
+.hero-look-swatch:focus-visible {
+ outline: 2px solid #C4A35A;
+ outline-offset: 2px;
+}
diff --git a/client/src/components/HeroLookPicker.tsx b/client/src/components/HeroLookPicker.tsx
new file mode 100644
index 0000000..d43277f
--- /dev/null
+++ b/client/src/components/HeroLookPicker.tsx
@@ -0,0 +1,76 @@
+import {
+ HERO_CLASSES,
+ HERO_COLORS,
+ HERO_LABEL_COLOR,
+ type AgentState,
+ type HeroClass,
+ type HeroColor,
+} from '../types/agent';
+import { HeroAvatar } from './HeroAvatar';
+import './HeroLookPicker.css';
+
+interface HeroLookPickerProps {
+ agent: AgentState;
+ isCustom: boolean;
+ onChange: (look: { heroClass: HeroClass; heroColor: HeroColor }) => void;
+ onReset: () => void;
+}
+
+const CLASS_LABEL: Record
= {
+ warrior: 'Warrior',
+ archer: 'Archer',
+ pawn: 'Pawn',
+};
+
+export function HeroLookPicker({ agent, isCustom, onChange, onReset }: HeroLookPickerProps) {
+ return (
+
+
+ Appearance
+ {isCustom && (
+
+ )}
+
+
+
+ {HERO_CLASSES.map((cls) => {
+ const selected = agent.heroClass === cls;
+ const preview = { ...agent, heroClass: cls };
+ return (
+
+ );
+ })}
+
+
+
+ {HERO_COLORS.map((color) => {
+ const selected = agent.heroColor === color;
+ return (
+
+
+ );
+}
diff --git a/client/src/game/entities/HeroSprite.ts b/client/src/game/entities/HeroSprite.ts
index 6a6231e..6d24132 100644
--- a/client/src/game/entities/HeroSprite.ts
+++ b/client/src/game/entities/HeroSprite.ts
@@ -63,6 +63,7 @@ function ensureHaloTexture(scene: Phaser.Scene): void {
export class HeroSprite {
readonly id: string;
readonly heroClass: HeroClass;
+ readonly heroColor: HeroColor;
private scene: Phaser.Scene;
private sprite: Phaser.GameObjects.Sprite;
private nameText: Phaser.GameObjects.Text;
@@ -114,6 +115,7 @@ export class HeroSprite {
this.scene = scene;
this.id = id;
this.heroClass = heroClass;
+ this.heroColor = heroColor;
this.source = source;
this.isSubagent = isSubagent;
this._x = x;
diff --git a/client/src/game/scenes/VillageScene.ts b/client/src/game/scenes/VillageScene.ts
index 2ef7fdd..1eba106 100644
--- a/client/src/game/scenes/VillageScene.ts
+++ b/client/src/game/scenes/VillageScene.ts
@@ -26,6 +26,16 @@ function zoomAroundPointer(cam: Phaser.Cameras.Scene2D.Camera, sx: number, sy: n
cam.scrollY += before.y - after.y;
}
+/** True when the native event landed on the Phaser canvas, not a React overlay.
+ * Phaser still fires scene `pointerdown` for overlay clicks (window-level
+ * listeners / full-viewport canvas), and an empty hit list would otherwise
+ * look like a map-background click and deselect the open detail panel. */
+function isCanvasPointer(pointer: Phaser.Input.Pointer, canvas: HTMLCanvasElement): boolean {
+ const target = pointer.event?.target;
+ if (!(target instanceof Node)) return true;
+ return target === canvas || canvas.contains(target);
+}
+
/** Hide agents idle for longer than this from the Phaser scene (kept in PartyBar). */
const IDLE_HIDE_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
@@ -36,6 +46,7 @@ const GRID_SPACING_Y = 35;
export class VillageScene extends Phaser.Scene {
private buildings: Building[] = [];
private heroes = new Map();
+ private selectedHeroId: string | null = null;
private onAgentsUpdated: ((agents: unknown) => void) | null = null;
private onCameraFollow: ((agentId: unknown) => void) | null = null;
private onSelectionChanged: ((agentId: unknown) => void) | null = null;
@@ -328,6 +339,7 @@ export class VillageScene extends Phaser.Scene {
// Apply outline/tint to the selected hero whenever selection changes.
this.onSelectionChanged = (agentId: unknown) => {
const selectedId = typeof agentId === 'string' ? agentId : null;
+ this.selectedHeroId = selectedId;
for (const [id, hero] of this.heroes) {
hero.setSelected(id === selectedId);
}
@@ -340,7 +352,8 @@ export class VillageScene extends Phaser.Scene {
// meant clicking a building wiped its info panel the moment it opened
// (App.handleSelectAgent(null) clears `selectedBuildingId` too). Now the
// building's own pointerdown is responsible for switching selection.
- this.onBackgroundPointerDown = (_p, hits) => {
+ this.onBackgroundPointerDown = (pointer, hits) => {
+ if (!isCanvasPointer(pointer, this.game.canvas)) return;
if (hits.length === 0) {
eventBridge.emit('hero:clicked', null);
}
@@ -629,6 +642,33 @@ export class VillageScene extends Phaser.Scene {
}
}
+ private placeHero(agent: AgentState, x: number, y: number): HeroSprite {
+ const hero = new HeroSprite(
+ this,
+ agent.id,
+ agent.name,
+ agent.heroClass,
+ agent.heroColor,
+ x,
+ y,
+ agent.id.startsWith('agent-'),
+ agent.source,
+ );
+ hero.setHeroScale(this.heroScale);
+ hero.setActivity(agent.currentActivity);
+ hero.setStatus(agent.status);
+ hero.setErrorTimestamp(agent.lastErrorAt);
+ hero.updateDetail(agent.currentFile, agent.currentCommand);
+ hero.updateTask(agent.currentTask);
+ hero.setModel(agent.model);
+ hero.setInteractiveForSelection(() => {
+ eventBridge.emit('hero:clicked', agent.id);
+ });
+ hero.setSelected(agent.id === this.selectedHeroId);
+ this.heroes.set(agent.id, hero);
+ return hero;
+ }
+
// ---------------------------------------------------------------------------
// Agent update handler
// ---------------------------------------------------------------------------
@@ -675,33 +715,21 @@ export class VillageScene extends Phaser.Scene {
const buildingsToReposition = new Set();
for (const agent of visible) {
- const existing = this.heroes.get(agent.id);
+ let existing = this.heroes.get(agent.id);
const buildingDef = getBuildingForActivity(agent.currentActivity);
+ if (existing !== undefined
+ && (existing.heroClass !== agent.heroClass || existing.heroColor !== agent.heroColor)) {
+ const x = existing.x;
+ const y = existing.y;
+ existing.destroy();
+ this.heroes.delete(agent.id);
+ existing = this.placeHero(agent, x, y);
+ }
+
if (existing === undefined) {
// New hero: spawn at configured spawn point then assign to building
- const hero = new HeroSprite(
- this,
- agent.id,
- agent.name,
- agent.heroClass,
- agent.heroColor,
- this.heroSpawn.x,
- this.heroSpawn.y,
- agent.id.startsWith('agent-'),
- agent.source,
- );
- hero.setHeroScale(this.heroScale);
- hero.setActivity(agent.currentActivity);
- hero.setStatus(agent.status);
- hero.setErrorTimestamp(agent.lastErrorAt);
- hero.updateDetail(agent.currentFile, agent.currentCommand);
- hero.updateTask(agent.currentTask);
- hero.setModel(agent.model);
- this.heroes.set(agent.id, hero);
- hero.setInteractiveForSelection(() => {
- eventBridge.emit('hero:clicked', agent.id);
- });
+ this.placeHero(agent, this.heroSpawn.x, this.heroSpawn.y);
this.addToSlot(buildingDef.id, agent.id);
buildingsToReposition.add(buildingDef.id);
} else {
diff --git a/client/src/heroLooks.test.ts b/client/src/heroLooks.test.ts
new file mode 100644
index 0000000..d7f5d6f
--- /dev/null
+++ b/client/src/heroLooks.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect } from 'bun:test';
+import type { AgentState } from './types/agent';
+import {
+ applyHeroLook,
+ heroLookKey,
+ parseHeroLooks,
+ serializeHeroLooks,
+} from './heroLooks';
+
+function agent(over: Partial = {}): AgentState {
+ return {
+ id: 'sess-1',
+ name: 'Agent-Quest',
+ heroClass: 'warrior',
+ heroColor: 'blue',
+ status: 'active',
+ currentActivity: 'idle',
+ tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ cost: 0,
+ sessionStart: 1000,
+ toolCalls: [],
+ errors: [],
+ filesModified: [],
+ lastEvent: 1000,
+ cwd: '/proj/Agent-Quest',
+ configDir: '/Users/x/.claude',
+ source: 'claude',
+ ...over,
+ };
+}
+
+describe('heroLookKey', () => {
+ it('is stable across session ids', () => {
+ const a = agent({ id: 'aaa' });
+ const b = agent({ id: 'bbb' });
+ expect(heroLookKey(a)).toBe(heroLookKey(b));
+ });
+
+ it('differs by source, cwd, or name', () => {
+ const base = heroLookKey(agent());
+ expect(heroLookKey(agent({ source: 'codex' }))).not.toBe(base);
+ expect(heroLookKey(agent({ cwd: '/other' }))).not.toBe(base);
+ expect(heroLookKey(agent({ name: 'other' }))).not.toBe(base);
+ });
+});
+
+describe('applyHeroLook', () => {
+ it('returns the same object when no look is stored', () => {
+ const a = agent();
+ expect(applyHeroLook(a, undefined)).toBe(a);
+ });
+
+ it('returns the same object when the look already matches', () => {
+ const a = agent({ heroClass: 'archer', heroColor: 'red' });
+ expect(applyHeroLook(a, { heroClass: 'archer', heroColor: 'red' })).toBe(a);
+ });
+
+ it('overrides class and color', () => {
+ const a = agent();
+ const next = applyHeroLook(a, { heroClass: 'pawn', heroColor: 'purple' });
+ expect(next.heroClass).toBe('pawn');
+ expect(next.heroColor).toBe('purple');
+ expect(next.id).toBe(a.id);
+ expect(a.heroClass).toBe('warrior');
+ });
+});
+
+describe('parseHeroLooks / serializeHeroLooks', () => {
+ it('round-trips a valid map', () => {
+ const looks = { 'cursor\t/p\tN': { heroClass: 'archer' as const, heroColor: 'yellow' as const } };
+ expect(parseHeroLooks(serializeHeroLooks(looks))).toEqual(looks);
+ });
+
+ it('returns {} for null, garbage, or non-object', () => {
+ expect(parseHeroLooks(null)).toEqual({});
+ expect(parseHeroLooks('not json')).toEqual({});
+ expect(parseHeroLooks('"x"')).toEqual({});
+ expect(parseHeroLooks('[]')).toEqual({});
+ });
+
+ it('drops invalid keys and looks', () => {
+ const raw = JSON.stringify({
+ v: 1,
+ looks: {
+ ok: { heroClass: 'warrior', heroColor: 'blue' },
+ '': { heroClass: 'warrior', heroColor: 'blue' },
+ badClass: { heroClass: 'mage', heroColor: 'blue' },
+ badColor: { heroClass: 'warrior', heroColor: 'pink' },
+ notObj: 3,
+ },
+ });
+ expect(parseHeroLooks(raw)).toEqual({
+ ok: { heroClass: 'warrior', heroColor: 'blue' },
+ });
+ });
+});
diff --git a/client/src/heroLooks.ts b/client/src/heroLooks.ts
new file mode 100644
index 0000000..f08e053
--- /dev/null
+++ b/client/src/heroLooks.ts
@@ -0,0 +1,61 @@
+import {
+ HERO_CLASSES,
+ HERO_COLORS,
+ type AgentState,
+ type HeroClass,
+ type HeroColor,
+} from './types/agent';
+
+export interface HeroLook {
+ heroClass: HeroClass;
+ heroColor: HeroColor;
+}
+
+export type HeroLookMap = Record;
+
+/** Stable identity: same source + cwd + display name keeps the same look
+ * across sessions. Session ids change every chat. */
+export function heroLookKey(agent: Pick): string {
+ return `${agent.source}\t${agent.cwd}\t${agent.name}`;
+}
+
+export function isHeroClass(v: unknown): v is HeroClass {
+ return typeof v === 'string' && (HERO_CLASSES as readonly string[]).includes(v);
+}
+
+export function isHeroColor(v: unknown): v is HeroColor {
+ return typeof v === 'string' && (HERO_COLORS as readonly string[]).includes(v);
+}
+
+export function applyHeroLook(agent: AgentState, look: HeroLook | undefined): AgentState {
+ if (look === undefined) return agent;
+ if (agent.heroClass === look.heroClass && agent.heroColor === look.heroColor) return agent;
+ return { ...agent, heroClass: look.heroClass, heroColor: look.heroColor };
+}
+
+export function parseHeroLooks(raw: string | null): HeroLookMap {
+ if (raw === null) return {};
+ let obj: unknown;
+ try {
+ obj = JSON.parse(raw);
+ } catch {
+ return {};
+ }
+ if (obj === null || typeof obj !== 'object') return {};
+ const looksRaw = (obj as Record).looks;
+ if (looksRaw === null || typeof looksRaw !== 'object' || Array.isArray(looksRaw)) return {};
+
+ const out: HeroLookMap = {};
+ for (const [key, value] of Object.entries(looksRaw as Record)) {
+ if (key.length === 0) continue;
+ if (value === null || typeof value !== 'object') continue;
+ const rec = value as Record;
+ if (!isHeroClass(rec.heroClass) || !isHeroColor(rec.heroColor)) continue;
+ out[key] = { heroClass: rec.heroClass, heroColor: rec.heroColor };
+ }
+ return out;
+}
+
+export function serializeHeroLooks(looks: HeroLookMap): string {
+ return JSON.stringify({ v: 1, looks });
+}
diff --git a/client/src/hooks/useHeroLooks.ts b/client/src/hooks/useHeroLooks.ts
new file mode 100644
index 0000000..bbca5d5
--- /dev/null
+++ b/client/src/hooks/useHeroLooks.ts
@@ -0,0 +1,65 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ parseHeroLooks,
+ serializeHeroLooks,
+ type HeroLook,
+ type HeroLookMap,
+} from '../heroLooks';
+
+const STORAGE_KEY = 'agentquest:heroLooks';
+const WRITE_DEBOUNCE_MS = 200;
+
+export function useHeroLooks(): [HeroLookMap, (key: string, look: HeroLook | null) => void] {
+ const [looks, setLooks] = useState(() => {
+ if (typeof window === 'undefined') return {};
+ return parseHeroLooks(window.localStorage.getItem(STORAGE_KEY));
+ });
+
+ const writeTimer = useRef | null>(null);
+ const pendingValue = useRef(null);
+
+ useEffect(() => {
+ if (writeTimer.current !== null) clearTimeout(writeTimer.current);
+ pendingValue.current = looks;
+ writeTimer.current = setTimeout(() => {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, serializeHeroLooks(looks));
+ } catch { /* quota or private mode — silently ignore */ }
+ pendingValue.current = null;
+ }, WRITE_DEBOUNCE_MS);
+ return () => {
+ if (writeTimer.current !== null) clearTimeout(writeTimer.current);
+ };
+ }, [looks]);
+
+ useEffect(() => {
+ return () => {
+ if (pendingValue.current !== null) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, serializeHeroLooks(pendingValue.current));
+ } catch { /* quota or private mode — silently ignore */ }
+ pendingValue.current = null;
+ }
+ };
+ }, []);
+
+ const upsert = useCallback((key: string, look: HeroLook | null) => {
+ setLooks((prev) => {
+ if (look === null) {
+ if (!(key in prev)) return prev;
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ }
+ const existing = prev[key];
+ if (existing !== undefined
+ && existing.heroClass === look.heroClass
+ && existing.heroColor === look.heroColor) {
+ return prev;
+ }
+ return { ...prev, [key]: look };
+ });
+ }, []);
+
+ return [looks, upsert];
+}