Skip to content

Commit a406635

Browse files
committed
feat(chat): cohort lead-actor dropdown filters the agent list to one swarm at a time
1 parent 36f245e commit a406635

2 files changed

Lines changed: 102 additions & 4 deletions

File tree

src/dashboard/src/components/chat/ChatPanel.module.scss

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,44 @@
3131
line-height: 1.5;
3232
}
3333

34+
.actorFilter {
35+
display: flex;
36+
align-items: center;
37+
gap: 8px;
38+
margin: 0 0 12px;
39+
padding: 8px 10px;
40+
background: var(--bg-card);
41+
border: 1px solid var(--border);
42+
border-radius: 6px;
43+
}
44+
45+
.actorFilterLabel {
46+
font-family: var(--mono);
47+
font-size: var(--font-3xs);
48+
font-weight: 800;
49+
letter-spacing: 0.08em;
50+
text-transform: uppercase;
51+
color: var(--amber);
52+
white-space: nowrap;
53+
}
54+
55+
.actorFilterSelect {
56+
flex: 1;
57+
min-width: 0;
58+
font-family: var(--mono);
59+
font-size: var(--font-xs);
60+
padding: 4px 6px;
61+
background: var(--bg-deep);
62+
color: var(--text-1);
63+
border: 1px solid var(--border);
64+
border-radius: 4px;
65+
66+
&:focus-visible {
67+
outline: 2px solid var(--amber);
68+
outline-offset: 1px;
69+
}
70+
}
71+
3472
.agentBtn {
3573
width: 100%;
3674
text-align: left;

src/dashboard/src/components/chat/ChatPanel.tsx

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,17 +177,23 @@ export function ChatPanel({ state, onChatUsage }: ChatPanelProps) {
177177
const messages = selectedId ? (threads.get(selectedId) ?? []) : [];
178178
const history = selectedId ? (historyByAgent.get(selectedId) ?? []) : [];
179179

180-
const agents = useMemo(() => {
181-
const map = new Map<string, AgentInfo>();
180+
// Group reactions by the lead actor that produced them so cohort runs
181+
// can dropdown-filter to one actor's swarm at a time. Same character
182+
// name appearing under two different leaders (e.g. shared scenario
183+
// archetypes) gets two entries, one per actor, so the chat-thread
184+
// memory stays isolated per leader instead of cross-contaminating.
185+
const agentsByActor = useMemo(() => {
186+
const out = new Map<string, AgentInfo[]>();
182187
for (const actorName of state.actorIds) {
183188
const sideState = state.actors[actorName];
184189
if (!sideState) continue;
190+
const perActorMap = new Map<string, AgentInfo>();
185191
for (const evt of sideState.events) {
186192
if (evt.type === 'agent_reactions') {
187193
const reactions = evt.data.reactions as Array<Record<string, unknown>> || [];
188194
for (const r of reactions) {
189195
if (r.name) {
190-
map.set(r.name as string, {
196+
perActorMap.set(r.name as string, {
191197
name: r.name as string, role: r.role as string || '',
192198
department: r.department as string || '', mood: r.mood as string || 'neutral',
193199
age: r.age as number, marsborn: r.marsborn as boolean,
@@ -201,10 +207,35 @@ export function ChatPanel({ state, onChatUsage }: ChatPanelProps) {
201207
}
202208
}
203209
}
210+
if (perActorMap.size > 0) out.set(actorName, Array.from(perActorMap.values()));
204211
}
205-
return Array.from(map.values());
212+
return out;
206213
}, [state]);
207214

215+
// Cohort filter: when 3+ actors, the sidebar shows a dropdown to
216+
// scope the agent list to a single lead actor's swarm. Default for
217+
// cohorts is the first actor with any agents (so the sidebar isn't
218+
// overwhelmed by ~100 × N characters). Pair runs (2 actors) keep
219+
// the original combined view by defaulting to null = "all leaders".
220+
const isCohort = state.actorIds.length > 2;
221+
const [filterActorId, setFilterActorId] = useState<string | null>(null);
222+
useEffect(() => {
223+
if (!isCohort) {
224+
if (filterActorId !== null) setFilterActorId(null);
225+
return;
226+
}
227+
if (filterActorId && agentsByActor.has(filterActorId)) return;
228+
const firstWithAgents = state.actorIds.find(id => (agentsByActor.get(id)?.length ?? 0) > 0);
229+
setFilterActorId(firstWithAgents ?? null);
230+
}, [isCohort, agentsByActor, state.actorIds, filterActorId]);
231+
232+
const agents = useMemo(() => {
233+
if (filterActorId) return agentsByActor.get(filterActorId) ?? [];
234+
const all: AgentInfo[] = [];
235+
for (const ids of agentsByActor.values()) all.push(...ids);
236+
return all;
237+
}, [agentsByActor, filterActorId]);
238+
208239
const selected = agents.find(c => c.name === selectedId);
209240

210241
useEffect(() => {
@@ -334,6 +365,35 @@ export function ChatPanel({ state, onChatUsage }: ChatPanelProps) {
334365
: `Chat becomes available after the first turn completes. Start a simulation and come back once agents have reacted to the first crisis. Each agent has persistent memory, personality, and relationships shaped by the crises they experience.`
335366
}
336367
</p>
368+
369+
{/* Cohort filter: pick one lead actor's swarm at a time. Hidden
370+
for solo + pair runs so the existing 2-actor combined view
371+
stays unchanged. Built off `agentsByActor` so the count next
372+
to each name updates live as reactions stream in. */}
373+
{isCohort && agentsByActor.size > 0 && (
374+
<div className={styles.actorFilter}>
375+
<label className={styles.actorFilterLabel} htmlFor="chat-actor-filter">
376+
Lead actor
377+
</label>
378+
<select
379+
id="chat-actor-filter"
380+
className={styles.actorFilterSelect}
381+
value={filterActorId ?? ''}
382+
onChange={(e) => setFilterActorId(e.target.value || null)}
383+
aria-label="Filter chat agents by lead actor"
384+
>
385+
{state.actorIds.map((id) => {
386+
const count = agentsByActor.get(id)?.length ?? 0;
387+
const name = state.actors[id]?.leader?.name || id;
388+
return (
389+
<option key={id} value={id} disabled={count === 0}>
390+
{name} ({count})
391+
</option>
392+
);
393+
})}
394+
</select>
395+
</div>
396+
)}
337397
{agents.map(c => (
338398
<button
339399
key={c.name}

0 commit comments

Comments
 (0)