Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e0e36e9
[issues-1] Hide completed agents from village by default + TopBar tog…
dadadamarine May 19, 2026
b06049c
[issues-2] feat(agents): mirror Claude Code session display title abo…
dadadamarine May 19, 2026
5fefc47
feat: visually distinguish sub-agent sessions as companion sprites (#…
dadadamarine May 19, 2026
7338481
[issues-7] Bound AgentState history + surface WS diagnostics (#8)
dadadamarine May 19, 2026
e3a7e83
[issues-9] WS onclose: stale-ws identity guard (StrictMode race) (#10)
dadadamarine May 19, 2026
d4c80b7
[issues-11] WS diagnostic: log Bun send result + drop onerror.close r…
dadadamarine May 20, 2026
a231ac9
[issues-13] BootScene preload diagnostics + negative result for maxPa…
dadadamarine May 20, 2026
cbb2f91
fix: StrictMode race in Phaser game init + label IA cleanup (#17)
dadadamarine May 20, 2026
0c94c43
[issues-16] hero label IA + party index (#18)
dadadamarine May 25, 2026
46d71da
fix: bubble gap + 2-line wrap + visual distinction + LINEAR text (#19)
dadadamarine May 25, 2026
5a35fdd
feat: idle wander + completion VFX + brightness boost (#20) (#21)
dadadamarine May 27, 2026
014f77b
[issues-20] building label → activity + font noise fix (#22)
dadadamarine May 27, 2026
1c6127f
refactor: reposition buildings in workflow order for coherent hero mo…
dadadamarine May 28, 2026
98c6b13
refactor: address Codex senior-engineer review comments (#23) (#28)
dadadamarine May 28, 2026
3bf867c
feat: sub-agent visual distinction — glow ring, gear badge, dashed co…
dadadamarine May 28, 2026
15da7cd
fix: address Codex review — build errors, snapshot ordering, wander m…
dadadamarine May 28, 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
2 changes: 1 addition & 1 deletion client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<title>Agent Quest</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
Expand Down
47 changes: 37 additions & 10 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { PhaserGame } from './game/PhaserGame';
import { useAgentState } from './hooks/useAgentState';
import { useSelectedAgent } from './hooks/useSelectedAgent';
import { useTopBarPrefs } from './hooks/useTopBarPrefs';
import { computeShowSourceBadge, filterAgentsForPresentation } from './presentation/agentPresentation';
import { eventBridge } from './game/EventBridge';
import { TopBar } from './components/TopBar';
import { PartyBar } from './components/PartyBar';
Expand All @@ -15,6 +17,15 @@ import './App.css';
export default function App() {
const { agents, activityLog, connected, configDirs } = useAgentState();
const { selectedAgentId, selectAgent } = useSelectedAgent();
const [topBarPrefs, updateTopBarPrefs] = useTopBarPrefs();

// Presentation projection: agents shown in the Party Bar and the Phaser
// village scene. TopBar stats, DetailPanel lookup, ActivityFeed, and
// BuildingInfoPanel continue to use the unfiltered `agents` snapshot.
const presentationAgents = useMemo(
() => filterAgentsForPresentation(agents, topBarPrefs.showCompletedAgents),
[agents, topBarPrefs.showCompletedAgents],
);
const [selectedBuilding, setSelectedBuilding] = useState<{
id: string;
anchor: { x: number; y: number };
Expand All @@ -33,10 +44,9 @@ export default function App() {

// 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 showSourceBadge = liveAgents.some((a) => a.source === 'claude')
&& liveAgents.some((a) => a.source === 'codex');
// last Codex hero finishes just because it's still in state. Computed by a
// shared helper so VillageScene stays in lockstep.
const showSourceBadge = useMemo(() => computeShowSourceBadge(agents), [agents]);

// When selecting agent, clear building
const handleSelectAgent = useCallback((id: string | null) => {
Expand Down Expand Up @@ -103,8 +113,18 @@ export default function App() {
}, []);

useEffect(() => {
eventBridge.emit('agents:updated', agents);
}, [agents]);
eventBridge.emit('agents:updated', presentationAgents);
}, [presentationAgents]);

// Deselect when the selected agent gets hidden by the presentation projection
// (e.g. user flips the "Show completed agents" toggle off while a completed
// hero is selected). Without this the DetailPanel would linger pointing at a
// hero that is no longer on screen.
useEffect(() => {
if (selectedAgentId === null) return;
const stillVisible = presentationAgents.some((a) => a.id === selectedAgentId);
if (!stillVisible) selectAgent(null);
}, [presentationAgents, selectedAgentId, selectAgent]);

useEffect(() => {
eventBridge.emit('selection:changed', selectedAgentId);
Expand All @@ -123,10 +143,17 @@ export default function App() {
<PhaserGame />
{villageReady && (
<div className="overlay">
<TopBar agents={agents} connected={connected} />
<TopBar
agents={agents}
connected={connected}
showCompletedAgents={topBarPrefs.showCompletedAgents}
onToggleShowCompletedAgents={() =>
updateTopBarPrefs({ showCompletedAgents: !topBarPrefs.showCompletedAgents })
}
/>
<NoInstallBanner configDirs={configDirs} connected={connected} />
<PartyBar
agents={agents}
agents={presentationAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
showSourceBadge={showSourceBadge}
Expand All @@ -138,7 +165,7 @@ export default function App() {
<BuildingInfoPanel
buildingId={selectedBuilding.id}
anchor={selectedBuilding.anchor}
agents={agents}
agents={presentationAgents}
onClose={() => setSelectedBuilding(null)}
/>
)}
Expand Down
18 changes: 16 additions & 2 deletions client/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,23 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent, show
return m;
}, [agents]);

const STATUS_ORDER: Record<AgentState['status'], number> = {
active: 0, waiting: 1, idle: 2, error: 3, completed: 4,
};
const agentIndexMap = useMemo(() => {
const sorted = [...agents].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
const m = new Map<string, number>();
sorted.forEach((a, i) => m.set(a.id, i + 1));
return m;
}, [agents]);

const resolveName = useCallback(
(agentId: string) => agentLookup.get(agentId)?.name ?? getAgentNameFallback(agentId),
[agentLookup],
(agentId: string) => {
const name = agentLookup.get(agentId)?.name ?? getAgentNameFallback(agentId);
const idx = agentIndexMap.get(agentId);
return idx !== undefined ? `${idx}. ${name}` : name;
},
[agentLookup, agentIndexMap],
);

// --- Auto-scroll lock + closed-state counter ---
Expand Down
7 changes: 4 additions & 3 deletions client/src/components/BuildingInfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ export function BuildingInfoPanel({ buildingId, anchor, agents, onClose }: Build

if (building === undefined) return null;

const agentsHere = agents.filter(
(a) => a.currentActivity === building.activity && (a.status === 'active' || a.status === 'idle'),
);
// `agents` is the App-level presentation projection — it already excludes
// `error` / `waiting`, and gates `completed` behind the TopBar toggle.
// We only need to match this building's activity here.
const agentsHere = agents.filter((a) => a.currentActivity === building.activity);

const style = position === null
? { visibility: 'hidden' as const }
Expand Down
19 changes: 19 additions & 0 deletions client/src/components/PartyBar.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@
.partybar-status-overlay.error { background: #8B2500; }
.partybar-status-overlay.waiting { background: #FFD700; }

/* === Index marker (matches the number above the sprite in the village) === */
.partybar-index {
position: absolute;
top: -4px;
left: -4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.85);
color: #F5E6C8;
font: 600 11px/18px 'Fira Code', monospace;
text-align: center;
border: 1px solid rgba(244, 230, 200, 0.4);
box-sizing: border-box;
pointer-events: none;
z-index: 1;
}

/* === Row body (Full only) === */
.partybar-row-body {
display: flex;
Expand Down
25 changes: 17 additions & 8 deletions client/src/components/PartyBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ const STATUS_ORDER: Record<AgentState['status'], number> = {

interface PartyRowProps {
agent: AgentState;
index: number;
mode: 'full' | 'icons';
isSelected: boolean;
onClick: () => void;
showSourceBadge: boolean;
}

function PartyRow({ agent, mode, isSelected, onClick, showSourceBadge }: PartyRowProps) {
function PartyRow({ agent, index, mode, isSelected, onClick, showSourceBadge }: PartyRowProps) {
const [flashing, setFlashing] = useState(false);
const prevSelected = useRef(isSelected);

Expand Down Expand Up @@ -65,6 +66,7 @@ function PartyRow({ agent, mode, isSelected, onClick, showSourceBadge }: PartyRo
title={title}
>
<span className="partybar-avatar-wrap">
<span className="partybar-index" aria-label={`hero ${index}`}>{index}</span>
<HeroAvatar agent={agent} size={AVATAR_SIZE} />
<span className={`partybar-status-overlay ${agent.status}`} aria-hidden="true" />
</span>
Expand Down Expand Up @@ -121,10 +123,13 @@ export function PartyBar({ agents, selectedAgentId, onSelectAgent, showSourceBad
const [prefs, updatePrefs] = usePartyPrefs();
const mode: 'full' | 'icons' = prefs.foldState;

const visible = agents.filter((a) => a.status === 'active' || a.status === 'idle');
const sorted = [...visible].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
const activeCount = visible.filter((a) => a.status === 'active').length;
const idleCount = visible.filter((a) => a.status === 'idle').length;
// `agents` is the App-level presentation projection — it already excludes
// `error` / `waiting`, and only includes `completed` when the TopBar toggle
// is on. Sort by status (active first) so completed rows land at the bottom.
const sorted = [...agents].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
const activeCount = agents.filter((a) => a.status === 'active').length;
const idleCount = agents.filter((a) => a.status === 'idle').length;
const completedCount = agents.filter((a) => a.status === 'completed').length;

const toggleFold = useCallback(() => {
updatePrefs({ foldState: mode === 'full' ? 'icons' : 'full' });
Expand All @@ -138,9 +143,12 @@ export function PartyBar({ agents, selectedAgentId, onSelectAgent, showSourceBad
<div className={`partybar mode-${mode}`} role="list" aria-label="Party">
<div className="partybar-header">
{mode === 'full' ? (
<span className="partybar-title">Party ({activeCount} active, {idleCount} idle)</span>
<span className="partybar-title">
Party ({activeCount} active, {idleCount} idle
{completedCount > 0 ? `, ${completedCount} done` : ''})
</span>
) : (
<span className="partybar-title-compact">{activeCount + idleCount}</span>
<span className="partybar-title-compact">{sorted.length}</span>
)}
<button
type="button"
Expand All @@ -152,10 +160,11 @@ export function PartyBar({ agents, selectedAgentId, onSelectAgent, showSourceBad
</div>

<div className="partybar-list">
{sorted.map((agent) => (
{sorted.map((agent, i) => (
<PartyRow
key={agent.id}
agent={agent}
index={i + 1}
mode={mode}
isSelected={agent.id === selectedAgentId}
onClick={() => handleClick(agent.id)}
Expand Down
33 changes: 32 additions & 1 deletion client/src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import './TopBar.css';
interface TopBarProps {
agents: AgentState[];
connected: boolean;
showCompletedAgents: boolean;
onToggleShowCompletedAgents: () => void;
}

export function TopBar({ agents, connected }: TopBarProps) {
export function TopBar({
agents,
connected,
showCompletedAgents,
onToggleShowCompletedAgents,
}: TopBarProps) {
const active = agents.filter((a) => a.status === 'active').length;
const idle = agents.filter((a) => a.status === 'idle').length;
const completed = agents.filter((a) => a.status === 'completed').length;
Expand Down Expand Up @@ -116,19 +123,43 @@ export function TopBar({ agents, connected }: TopBarProps) {

<div className="topbar-effects">
<button
type="button"
className={`topbar-effect-btn ${nightOn ? 'active' : ''}`}
onClick={toggleNight}
aria-pressed={nightOn}
aria-label={nightOn ? 'Switch to day mode' : 'Switch to night mode'}
title={nightOn ? 'Day mode' : 'Night mode'}
>
{nightOn ? '\u{1F319}' : '\u{2600}\u{FE0F}'}
</button>
<button
type="button"
className={`topbar-effect-btn ${rainOn ? 'active' : ''}`}
onClick={toggleRain}
aria-pressed={rainOn}
aria-label={rainOn ? 'Stop rain effect' : 'Start rain effect'}
title={rainOn ? 'Stop rain' : 'Start rain'}
>
{'\u{1F327}\u{FE0F}'}
</button>
<button
type="button"
className={`topbar-effect-btn ${showCompletedAgents ? 'active' : ''}`}
onClick={onToggleShowCompletedAgents}
aria-pressed={showCompletedAgents}
aria-label={
showCompletedAgents
? 'Hide completed agents from the village'
: 'Show completed agents in the village'
}
title={
showCompletedAgents
? 'Hide completed agents'
: 'Show completed agents'
}
>
{showCompletedAgents ? '\u{1F441}\u{FE0F}' : '\u{1F47B}'}
</button>
<a
className="topbar-effect-btn"
data-mobile-hide="true"
Expand Down
3 changes: 2 additions & 1 deletion client/src/editor/panels/EditorTopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { editorBridge } from '../EditorBridge';
import { editorStore, useEditorStore } from '../state/editor-store';
import type { SlotInfo } from '../types/map';
import { truncateLabel } from '../../utils/truncateLabel';

export function EditorTopBar() {
const dirty = useEditorStore((s) => s.dirty);
Expand Down Expand Up @@ -57,7 +58,7 @@ export function EditorTopBar() {
const getSlotLabel = (info: SlotInfo): string => {
const star = info.isActive ? ' \u2605' : '';
if (info.isEmpty) return `${info.slot}${star}`;
const name = info.name.length > 10 ? info.name.slice(0, 10) + '...' : info.name;
const name = truncateLabel(info.name, 10);
return `${info.slot} \u2014 ${name}${star}`;
};

Expand Down
41 changes: 32 additions & 9 deletions client/src/game/PhaserGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,47 @@ import { useEffect, useRef } from 'react';
import * as Phaser from 'phaser';
import { gameConfig } from './config';

// React StrictMode (dev) mounts twice — mount → cleanup → mount. A useRef-per-
// component pattern lets cleanup destroy() the game before the second mount
// builds a fresh one, but Phaser's LoaderPlugin keeps in-flight HTTP requests
// alive past destroy(). Both Phaser.Game instances then fetch the same ~80-file
// asset batch in parallel, saturate the browser's HTTP/1.1 connection pool,
// and hang preload at ~31/83.
//
// Anchor the game to a module-level slot: the second mount reuses the existing
// instance and reparents the canvas. Cleanup defers destruction so a fast
// remount can cancel it. The game tears down only on genuine unmount.
let liveGame: Phaser.Game | null = null;
let pendingDestroy: ReturnType<typeof setTimeout> | null = null;

export function PhaserGame() {
const containerRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<Phaser.Game | null>(null);

useEffect(() => {
if (containerRef.current === null) return;
if (gameRef.current !== null) return;

const game = new Phaser.Game({
...gameConfig,
parent: containerRef.current,
});
if (pendingDestroy !== null) {
clearTimeout(pendingDestroy);
pendingDestroy = null;
}

gameRef.current = game;
if (liveGame === null) {
liveGame = new Phaser.Game({
...gameConfig,
parent: containerRef.current,
});
} else if (liveGame.scale.parent !== containerRef.current) {
containerRef.current.appendChild(liveGame.canvas);
liveGame.scale.parent = containerRef.current;
liveGame.scale.refresh();
}

return () => {
game.destroy(true);
gameRef.current = null;
pendingDestroy = setTimeout(() => {
liveGame?.destroy(true);
liveGame = null;
pendingDestroy = null;
}, 0);
};
}, []);

Expand Down
Loading