Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 24 additions & 10 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand All @@ -180,7 +187,7 @@ export default function App() {
{villageReady && (
<div className="overlay">
<TopBar
agents={agents}
agents={displayAgents}
connected={connected}
notifications={{
entries: notifications,
Expand All @@ -193,25 +200,32 @@ export default function App() {
/>
<NoInstallBanner configDirs={configDirs} connected={connected} />
<PartyBar
agents={agents}
agents={displayAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
showSourceBadge={showSourceBadge}
/>
{selectedAgent !== null && (
<DetailPanel agent={selectedAgent} onClose={() => handleSelectAgent(null)} showSourceBadge={showSourceBadge} />
<DetailPanel
agent={selectedAgent}
onClose={() => handleSelectAgent(null)}
showSourceBadge={showSourceBadge}
lookIsCustom={looks[heroLookKey(selectedAgent)] !== undefined}
onChangeLook={(look) => upsertLook(heroLookKey(selectedAgent), look)}
onResetLook={() => upsertLook(heroLookKey(selectedAgent), null)}
/>
)}
{selectedBuilding !== null && (
<BuildingInfoPanel
buildingId={selectedBuilding.id}
anchor={selectedBuilding.anchor}
agents={agents}
agents={displayAgents}
onClose={() => setSelectedBuilding(null)}
/>
)}
<ActivityFeed
log={activityLog}
agents={agents}
agents={displayAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
showSourceBadge={showSourceBadge}
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/DetailPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
overflow: hidden;
display: flex;
flex-direction: column;
z-index: 25;
/* Above toasts (40) and the activity feed (25). Toasts share this
* top-right slot; if they stack on top, empty toast-column space has
* pointer-events:none and the click falls through to the Phaser canvas,
* which treats it as a map click and closes this panel. */
z-index: 45;
font-family: 'Fira Code', monospace;
}

Expand Down
29 changes: 26 additions & 3 deletions client/src/components/DetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { HERO_LABEL_COLOR, SOURCE_BADGE_COLOR, modelBadge, displayActivity, type AgentState } from '../types/agent';
import { HERO_LABEL_COLOR, SOURCE_BADGE_COLOR, modelBadge, displayActivity, type AgentState, type HeroClass, type HeroColor } from '../types/agent';
import { HeroAvatar } from './HeroAvatar';
import { HeroLookPicker } from './HeroLookPicker';
import { SessionReport } from './SessionReport';
import { configDirLabel } from './configDirLabel';
import { isPath, resolvePath } from './activityFeedUtils';
Expand All @@ -11,6 +12,9 @@ interface DetailPanelProps {
agent: AgentState;
onClose: () => void;
showSourceBadge: boolean;
lookIsCustom: boolean;
onChangeLook: (look: { heroClass: HeroClass; heroColor: HeroColor }) => void;
onResetLook: () => void;
}

function formatDuration(startMs: number): string {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -76,7 +87,10 @@ export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProp

return (
<>
<div className="detail-panel">
<div
className="detail-panel"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="detail-topbar">
<span className="detail-avatar-wrap">
<HeroAvatar agent={agent} size={44} />
Expand Down Expand Up @@ -125,6 +139,15 @@ export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProp
<div className="detail-body"><SessionReport agent={agent} /></div>
) : (
<div className="detail-body">
<div className="detail-section">
<div className="detail-section-title">Hero</div>
<HeroLookPicker
agent={agent}
isCustom={lookIsCustom}
onChange={onChangeLook}
onReset={onResetLook}
/>
</div>
<div className="detail-section">
<div className="detail-section-title">Status</div>
<div className="detail-row">
Expand Down
94 changes: 94 additions & 0 deletions client/src/components/HeroLookPicker.css
Original file line number Diff line number Diff line change
@@ -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;
}
76 changes: 76 additions & 0 deletions client/src/components/HeroLookPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<HeroClass, string> = {
warrior: 'Warrior',
archer: 'Archer',
pawn: 'Pawn',
};

export function HeroLookPicker({ agent, isCustom, onChange, onReset }: HeroLookPickerProps) {
return (
<div className="hero-look-picker">
<div className="hero-look-picker-head">
<span className="hero-look-picker-label">Appearance</span>
{isCustom && (
<button type="button" className="hero-look-reset" onClick={onReset}>
Reset
</button>
)}
</div>

<div className="hero-look-classes" role="group" aria-label="Hero class">
{HERO_CLASSES.map((cls) => {
const selected = agent.heroClass === cls;
const preview = { ...agent, heroClass: cls };
return (
<button
key={cls}
type="button"
className={`hero-look-class ${selected ? 'selected' : ''}`}
aria-pressed={selected}
aria-label={CLASS_LABEL[cls]}
onClick={() => onChange({ heroClass: cls, heroColor: agent.heroColor })}
>
<HeroAvatar agent={preview} size={36} title={CLASS_LABEL[cls]} />
<span>{CLASS_LABEL[cls]}</span>
</button>
);
})}
</div>

<div className="hero-look-colors" role="group" aria-label="Hero color">
{HERO_COLORS.map((color) => {
const selected = agent.heroColor === color;
return (
<button
key={color}
type="button"
className={`hero-look-swatch ${selected ? 'selected' : ''}`}
style={{ background: HERO_LABEL_COLOR[color] }}
aria-pressed={selected}
aria-label={color}
title={color}
onClick={() => onChange({ heroClass: agent.heroClass, heroColor: color })}
/>
);
})}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions client/src/game/entities/HeroSprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading