Skip to content

Commit e7a9703

Browse files
committed
refactor(client): hardening from code review — Important items I1-I6
I1 — ActivityFeed now stores selection as a {agentId, entryKey} tuple instead of a bare string key. The stale-clear effect compares exact agent ids rather than string-prefixing the key, removing the theoretical prefix-collision hole. I2 — Reword ensureHaloTexture's comment to reflect that the cache lives in Phaser's global TextureManager, not per-scene. I3 — Snapshot halo.scaleX into a local `baseScale` after setDisplaySize and tween to baseScale * 1.25 — same math, but clearly shows the baseline → peak factor relationship. I4 — Move sprite.setData('isHero', true) from setInteractiveForSelection to the HeroSprite constructor, so the tag survives any future code path that enables input without going through setInteractiveForSelection. I5 — Update the ActivityRow memo-comparator comment: parents now pass fresh inline closures per render; safety comes from each closure capturing row-deterministic data (entryKey, agentId), not from parent-side memoization. I6 — HeroAvatar `size="inherit"` now carries an explicit JSDoc caveat and a dev-mode runtime warning that logs when the parent element renders with 0×0 dimensions, surfacing layout mistakes before they ship.
1 parent 6053673 commit e7a9703

4 files changed

Lines changed: 61 additions & 51 deletions

File tree

client/src/components/ActivityFeed.tsx

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import type { ActivityLogEntry, AgentState } from '../types/agent';
3-
import { useFeedPrefs, type FoldState, type ViewMode } from '../hooks/useFeedPrefs';
3+
import { useFeedPrefs, type FoldState } from '../hooks/useFeedPrefs';
44
import { ActivityFeedHeader } from './ActivityFeedHeader';
55
import { ActivityRow } from './ActivityRow';
6-
import { AgentGroup } from './AgentGroup';
76
import {
8-
filterByAgent, groupByAgent, getAgentNameFallback, categorizeEntry, detectCategories,
7+
filterByAgent, getAgentNameFallback, categorizeEntry, detectCategories,
98
type ActionFilter,
109
} from './activityFeedUtils';
1110
import './ActivityFeed.css';
@@ -21,20 +20,23 @@ const SCROLL_PIN_THRESHOLD_PX = 8;
2120

2221
export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent }: ActivityFeedProps) {
2322
const [prefs, updatePrefs] = useFeedPrefs();
24-
const { foldState, viewMode, activeHighlights, agentFilter } = prefs;
23+
const { foldState, activeHighlights, agentFilter } = prefs;
2524

2625
// Identifies the single log row the user touched in the feed. Unlike
27-
// `selectedAgentId`, this is feed-local — it pulses exactly ONE row, not
28-
// every row of the same agent. Cleared when selection moves elsewhere
29-
// (party bar, hero sprite, deselect): we detect that by checking the key's
30-
// embedded agentId prefix no longer matches `selectedAgentId`.
31-
const [selectedEntryKey, setSelectedEntryKey] = useState<string | null>(null);
26+
// `selectedAgentId`, this is feed-local — a feedback indicator for which
27+
// row was clicked, not which agent is globally selected. Stored as a
28+
// tuple so we can match on exact agent id (safer than prefix-matching
29+
// the key string, which would be vulnerable to id-prefix collisions).
30+
// Cleared when selection moves elsewhere (party bar, hero sprite,
31+
// deselect).
32+
const [selectedEntry, setSelectedEntry] = useState<{ agentId: string; entryKey: string } | null>(null);
33+
const selectedEntryKey = selectedEntry?.entryKey ?? null;
3234
useEffect(() => {
33-
if (selectedEntryKey === null) return;
34-
if (selectedAgentId === null || !selectedEntryKey.startsWith(`${selectedAgentId}-`)) {
35-
setSelectedEntryKey(null);
35+
if (selectedEntry === null) return;
36+
if (selectedAgentId !== selectedEntry.agentId) {
37+
setSelectedEntry(null);
3638
}
37-
}, [selectedAgentId, selectedEntryKey]);
39+
}, [selectedAgentId, selectedEntry]);
3840

3941
// The agent-filter chip still hides rows (explicit user filter on one agent).
4042
// The action highlights do NOT hide anything; they only tint matching rows.
@@ -48,11 +50,6 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent }: Ac
4850
[filtered],
4951
);
5052

51-
const groups = useMemo(
52-
() => viewMode === 'byAgent' ? groupByAgent(filtered) : null,
53-
[filtered, viewMode],
54-
);
55-
5653
const agentLookup = useMemo(() => {
5754
const m = new Map<string, AgentState>();
5855
for (const a of agents) m.set(a.id, a);
@@ -135,10 +132,6 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent }: Ac
135132
},
136133
[foldState, updatePrefs],
137134
);
138-
const onViewModeChange = useCallback(
139-
(m: ViewMode) => updatePrefs({ viewMode: m }),
140-
[updatePrefs],
141-
);
142135
const onHighlightsChange = useCallback(
143136
(h: ActionFilter[]) => updatePrefs({ activeHighlights: h }),
144137
[updatePrefs],
@@ -153,15 +146,13 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent }: Ac
153146
<div className={`activity-feed fold-${foldState}`} role="log" aria-live="polite" aria-relevant="additions">
154147
<ActivityFeedHeader
155148
foldState={foldState}
156-
viewMode={viewMode}
157149
activeHighlights={activeHighlights}
158150
availableCategories={availableCategories}
159151
categoryCounts={categoryCounts}
160152
agentFilter={agentFilter}
161153
agents={agents}
162154
newCount={newWhileClosed}
163155
onFoldChange={onFoldChange}
164-
onViewModeChange={onViewModeChange}
165156
onHighlightsChange={onHighlightsChange}
166157
onClearAgentFilter={clearAgentFilter}
167158
/>
@@ -180,21 +171,6 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent }: Ac
180171
<div>Waiting for agent activity...</div>
181172
<div className="feed-empty-hint">Launch Claude Code in any project — it'll appear here.</div>
182173
</div>
183-
) : viewMode === 'byAgent' && groups !== null ? (
184-
groups.map((g) => (
185-
<AgentGroup
186-
key={g.agentId}
187-
agentId={g.agentId}
188-
agent={agentLookup.get(g.agentId)}
189-
agentName={resolveName(g.agentId)}
190-
entries={g.entries}
191-
activeHighlights={activeHighlights}
192-
selectedEntryKey={selectedEntryKey}
193-
onSelectAgent={handleSelectAgent}
194-
onSelectEntry={setSelectedEntryKey}
195-
onFilterAgent={handleFilterAgent}
196-
/>
197-
))
198174
) : (
199175
filtered.map((entry) => {
200176
const entryKey = `${entry.agentId}-${entry.timestamp}-${entry.action}-${entry.detail}`;
@@ -207,7 +183,7 @@ export function ActivityFeed({ log, agents, selectedAgentId, onSelectAgent }: Ac
207183
highlighted={shouldHighlight(entry)}
208184
isSelected={entryKey === selectedEntryKey}
209185
onSelectAgent={(id) => {
210-
setSelectedEntryKey(entryKey);
186+
setSelectedEntry({ agentId: id, entryKey });
211187
handleSelectAgent(id);
212188
}}
213189
onFilterAgent={handleFilterAgent}

client/src/components/ActivityRow.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,11 @@ function ContextMenu({ x, y, onClose, actions }: ContextMenuProps) {
157157
}
158158

159159
// Callbacks (onSelectAgent, onFilterAgent) intentionally omitted from the
160-
// comparator — the parent must memoize them (useCallback) so their identity
161-
// stays stable across renders, otherwise stale closures can fire.
160+
// comparator. They are passed as fresh inline closures per render by the
161+
// parent (ActivityFeed / AgentGroup) — each closure captures only
162+
// row-deterministic data (`entryKey`, `agentId`), so a skipped re-render
163+
// keeps a closure that still targets the correct row. The comparator
164+
// trades closure identity for predictable, stable row behavior.
162165
export const ActivityRow = memo(ActivityRowImpl, (prev, next) =>
163166
prev.entry.timestamp === next.entry.timestamp &&
164167
prev.entry.agentId === next.entry.agentId &&

client/src/components/HeroAvatar.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CSSProperties } from 'react';
1+
import { useEffect, useRef, type CSSProperties } from 'react';
22
import { getActiveTheme } from '../game/themes/registry';
33
import type { AgentState } from '../types/agent';
44

@@ -9,6 +9,12 @@ interface HeroAvatarProps {
99
* width/height via CSS (the background-size then scales in percentages
1010
* of that parent). Use 'inherit' when you want the avatar to respond
1111
* to a CSS variable, e.g. inside a responsive grid cell.
12+
*
13+
* IMPORTANT: when `size="inherit"`, the parent element MUST have an
14+
* explicit width AND height (either inline style or CSS). Without them
15+
* the avatar renders 0×0 silently. The dev-mode warning below logs
16+
* the failure once per element instance to surface layout mistakes
17+
* before they ship.
1218
*/
1319
size?: number | 'inherit';
1420
className?: string;
@@ -27,8 +33,24 @@ export function HeroAvatar({ agent, size = DEFAULT_SIZE, className, title }: Her
2733
? { width: '100%', height: '100%' }
2834
: { width: size, height: size };
2935

36+
const ref = useRef<HTMLDivElement | null>(null);
37+
useEffect(() => {
38+
if (!inherit) return;
39+
if (import.meta.env.MODE === 'production') return;
40+
const el = ref.current;
41+
if (el === null) return;
42+
const rect = el.getBoundingClientRect();
43+
if (rect.width === 0 || rect.height === 0) {
44+
console.warn(
45+
'[HeroAvatar] size="inherit" requires the parent to have explicit width and height — got 0×0.',
46+
el.parentElement,
47+
);
48+
}
49+
}, [inherit]);
50+
3051
return (
3152
<div
53+
ref={ref}
3254
className={className}
3355
title={title ?? agent.heroClass}
3456
role="img"

client/src/game/entities/HeroSprite.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ const HALO_TEXTURE_KEY = 'hero-selection-halo';
3535

3636
/**
3737
* Lazily build a soft radial-gradient texture we can reuse as the selection
38-
* halo. Generated once per scene; subsequent callers hit the texture cache.
39-
* The gradient fades white → transparent so the glow blends with the scene
40-
* instead of looking like a flat disc.
38+
* halo. Cached in Phaser's global TextureManager for the lifetime of the
39+
* game — scenes share it via the same key. The gradient fades white →
40+
* transparent so the glow blends with the scene instead of looking like a
41+
* flat disc.
4142
*/
4243
function ensureHaloTexture(scene: Phaser.Scene): void {
4344
if (scene.textures.exists(HALO_TEXTURE_KEY)) return;
@@ -119,6 +120,10 @@ export class HeroSprite {
119120
// Flip sprites that natively face left so they face right by default
120121
this.sprite.setFlipX(this.facesLeft);
121122
if (cfg.tint !== null) this.sprite.setTint(cfg.tint);
123+
// Tag at construction time so the scene-level background-click
124+
// detector (VillageScene) can classify pointerdown hits correctly
125+
// regardless of which code path later wires up interactivity.
126+
this.sprite.setData('isHero', true);
122127

123128
// Label offsets derived from actual sprite height — scale with theme.
124129
const halfH = this.sprite.displayHeight / 2;
@@ -229,9 +234,8 @@ export class HeroSprite {
229234
if (!this.sprite.input || !this.sprite.input.enabled) {
230235
this.sprite.setInteractive({ useHandCursor: true });
231236
}
232-
// Tag unconditionally so the scene-level background click detector can
233-
// recognize a hero hit regardless of the setInteractive branch above.
234-
this.sprite.setData('isHero', true);
237+
// The `isHero` tag is set in the constructor so it's present even for
238+
// spawn paths that never call this method.
235239
this.sprite.on('pointerdown', onClick);
236240
}
237241

@@ -259,11 +263,16 @@ export class HeroSprite {
259263
halo.setAlpha(0.35);
260264
halo.setDepth(this.sprite.depth - 0.1);
261265
this.selectionHalo = halo;
266+
// Capture the baseline scale AFTER setDisplaySize so the tween
267+
// yoyos between this fixed baseline and baseline × peak factor.
268+
// Using halo.scaleX directly in the tween target would be the
269+
// same math but reads as if the scale were self-referential.
270+
const baseScale = halo.scaleX;
262271
this.selectionTween = this.scene.tweens.add({
263272
targets: halo,
264273
alpha: 0.75,
265-
scaleX: halo.scaleX * 1.25,
266-
scaleY: halo.scaleY * 1.25,
274+
scaleX: baseScale * 1.25,
275+
scaleY: baseScale * 1.25,
267276
duration: 650,
268277
yoyo: true,
269278
repeat: -1,

0 commit comments

Comments
 (0)