feat(client): give the members board's other agents the member-card form

- The other-agents section renders the family's plain subagents, ancestors, and
  siblings as the same cards the team roster uses — label as the identity (two
  clamped lines), node-state dot, status + model line, landed latest reply, and
  an open button; the card hue follows the graph's family hue
- Member cards gain the run's activity meta (tokens · duration · steps, with
  the pressure-tinted occupancy figure), team cards included
- A compact card layout (8x10 padding, tighter gaps) and a 200px grid floor
  keep the two-column read the common case; the fleet-row list is gone
This commit is contained in:
bowenliang123 committed 2026-10-12 00:37:44 +08:00
1 parent 9d8b7b7bf0
commit d648edec1e
4 files changed
+154 -112

No files matched your search

+75 -41
View File
@@ -1,15 +1,17 @@
/**
* The Fleet members board — every agent of the family in one panel, in two sections: the team's
* roster as full cards (identity, duty, latest reply, live task count), then the family's other
* agents (plain subagents, ancestors, siblings) as compact rows. The toolbar's search, status
* filter, and sort steer both sections. A pick pins the agent (ringing its graph node); a member
* card's task chip filters the task board to that owner; the open button jumps to the session.
* agents (plain subagents, ancestors, siblings) as cards in the same form — their label in place
* of a roster name, and the run's tokens/duration/steps in place of the duty line. The toolbar's
* search, status filter, and sort steer both sections. A pick pins the agent (ringing its graph
* node); a member card's task chip filters the task board to that owner; the open button jumps
* to the session.
*/
import { useMemo, useState, type CSSProperties, type ReactElement } from 'react'
import { IconUserOutlineRegular, IconUsersOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentForest, AgentNode } from '../agentTree'
import { familyHue, fmtDurationCompact } from '../agentTree'
import { familyHue, fmtDurationCompact, pressureColorOf } from '../agentTree'
import { tasksOfOwner, teamMemberOf, type FleetTeam } from '../fleetTeam'
import type { FleetDetail, FleetMemberInfo } from '../../shared/types'
import type { ViewKit } from '../viewkit'
@@ -33,12 +35,29 @@ export interface MemberBoardProps {
fmt: ViewKit['fmt']
}
/** One member's card hue: the lead carries the brand color, teammates their golden-angled roster
* hue (the graph's family-hue idiom). */
function memberStyle(index: number, lead: boolean): CSSProperties {
return {
'--lc-member-hue': lead ? 'var(--dsw-alias-brand-primary, var(--color-blue-500))' : familyHue(index),
} as CSSProperties
/** One card's hue: the lead carries the brand color, every other card a golden-angled hue —
* teammates by roster order, other agents by their level-1 family (the graph's link hue). */
const BRAND_HUE = 'var(--dsw-alias-brand-primary, var(--color-blue-500))'
function memberStyle(hue: string): CSSProperties {
return { '--lc-member-hue': hue } as CSSProperties
}
/** The run's activity line above a card's foot: consumption, duration, then steps — the graph
* cards' own meta readout. */
function memberMeta(node: AgentNode, t: ViewKit['t'], fmt: ViewKit['fmt']): ReactElement | null {
const bits: string[] = []
if (node.head !== null && node.head.tokens > 0) bits.push(fmt(node.head.tokens))
if (node.durationMs !== null) bits.push(fmtDurationCompact(node.durationMs))
if (node.requests > 0) bits.push(t('agents.steps', { n: node.requests }))
const pct = node.head !== null ? node.head.pct : null
if (bits.length === 0 && pct === null) return null
return (
<div className="lc-member-meta">
<span className="lc-member-meta-text">{bits.join(' · ')}</span>
{pct !== null ? <span className="lc-member-meta-pct" style={{ color: pressureColorOf(pct) }}>{pct}%</span> : null}
</div>
)
}
type StatusFilter = 'all' | 'running' | 'done' | 'idle'
@@ -183,7 +202,7 @@ export function MemberBoard(props: MemberBoardProps): ReactElement {
+ (props.pinnedId === member.id ? ' lc-member-pinned' : '')
+ (member.phase === 'failed' ? ' lc-member-failed' : '')
}
style={memberStyle(index, member.role === 'lead')}
style={memberStyle(member.role === 'lead' ? BRAND_HUE : familyHue(index))}
role="button"
tabIndex={0}
onClick={() => { props.onPin(props.pinnedId === member.id ? null : member.id) }}
@@ -225,6 +244,7 @@ export function MemberBoard(props: MemberBoardProps): ReactElement {
{t('fleet.members.reply')} {detail.lastReply.text}
</div>
) : null}
{node !== undefined ? memberMeta(node, t, fmt) : null}
<div className="lc-member-foot">
{activeTasks.length > 0 ? (
<button
@@ -273,38 +293,52 @@ export function MemberBoard(props: MemberBoardProps): ReactElement {
{t('fleet.board.others')}
<span className="lc-kind-n">{fmt(others.length)}</span>
</div>
<ul className="lc-fleet-rows">
<div className="lc-members-grid">
{others.map((node) => {
const pct = node.head !== null ? node.head.pct : null
const meta: string[] = []
if (node.head !== null) meta.push(fmt(node.head.tokens))
if (node.model !== undefined) meta.push(node.model)
if (node.durationMs !== null) meta.push(fmtDurationCompact(node.durationMs))
if (node.requests > 0) meta.push(t('agents.steps', { n: node.requests }))
const detail = props.detailOf(node.id)?.detail ?? null
return (
<li key={node.id}>
<div
className={'lc-fleet-row' + (props.pinnedId === node.id ? ' lc-fleet-row-pinned' : '')}
role="button"
tabIndex={0}
onClick={() => { props.onPin(props.pinnedId === node.id ? null : node.id) }}
onKeyDown={(ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return
ev.preventDefault()
props.onPin(props.pinnedId === node.id ? null : node.id)
}}
onMouseEnter={() => { props.onHover(node.id) }}
onMouseLeave={() => { props.onHover(null) }}
>
{node.running || node.completed
? <StateDot state={node.running ? 'ongoing' : 'done'} size={10} className="lc-agent-state" />
: <span className="lc-fleet-dot-idle" />}
<span className="lc-fleet-row-label" title={node.label}>{node.label}</span>
<span className="lc-fleet-row-meta">{meta.join(' · ')}</span>
{pct !== null ? <span className="lc-fleet-row-pct">{pct}%</span> : null}
<div
key={node.id}
className={'lc-member-card' + (props.pinnedId === node.id ? ' lc-member-pinned' : '')}
style={memberStyle(familyHue(node.family))}
role="button"
tabIndex={0}
onClick={() => { props.onPin(props.pinnedId === node.id ? null : node.id) }}
onKeyDown={(ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return
ev.preventDefault()
props.onPin(props.pinnedId === node.id ? null : node.id)
}}
onMouseEnter={() => { props.onHover(node.id) }}
onMouseLeave={() => { props.onHover(null) }}
>
<div className="lc-member-head">
<span className="lc-member-icon">
<IconUserOutlineRegular size={15} />
</span>
{/* A subagent's prompt title IS its identity, and it runs long: two clamped
lines where the roster name stays one. */}
<b className="lc-member-name lc-member-name-wrap" title={node.label}>{node.label}</b>
<StateDot
state={node.running ? 'ongoing' : node.completed ? 'done' : 'idle'}
size={9}
className="lc-member-dot"
/>
</div>
<div className="lc-member-status">
{node.running ? t('agents.running') : node.completed ? t('fleet.filter.done') : t('fleet.memberInactive')}
{node.model !== undefined ? <span className="lc-member-model">{node.model}</span> : null}
</div>
{detail?.lastReply !== undefined ? (
<div className="lc-member-reply" title={detail.lastReply.text}>
{t('fleet.members.reply')} {detail.lastReply.text}
</div>
) : null}
{memberMeta(node, t, fmt)}
<div className="lc-member-foot">
<button
type="button"
className="lc-inspector-btn lc-fleet-row-open"
className="lc-inspector-btn lc-member-open"
title={t('fleet.open')}
onClick={(ev) => {
ev.stopPropagation()
@@ -314,10 +348,10 @@ export function MemberBoard(props: MemberBoardProps): ReactElement {
→
</button>
</div>
</li>
</div>
)
})}
</ul>
</div>
</>
) : null}
+11 -15
View File
@@ -29,9 +29,11 @@
.lc-members-note { margin: 2px 0 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
/* ---- Members board ---- */
.lc-members-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); gap: 8px; }
/* The 200px floor keeps the two-column read the common case: the unified cards are compact
enough that two of them fit the boards column down to a narrow pane. */
.lc-members-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); gap: 8px; }
.lc-member-card {
display: flex; flex-direction: column; gap: 5px; padding: 10px 12px; border-radius: 10px;
display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border-radius: 10px;
background: var(--dsw-alias-bg-layer-2);
/* The member's roster hue (golden-angled, shared with the graph's family links) tints the icon ring. */
border: 1px solid var(--dsw-alias-border-l2);
@@ -53,13 +55,19 @@
border: 1px solid color-mix(in srgb, var(--lc-member-hue, var(--dsw-alias-border-l2)) 35%, transparent);
}
.lc-member-name { flex: 1; min-width: 0; font-size: 12.5px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Other agents wear their prompt title as the name: two clamped lines beat a harsher ellipsis. */
.lc-member-name-wrap { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; line-height: 1.25; }
.lc-member-dot { flex: none; }
.lc-member-status { display: flex; align-items: baseline; gap: 8px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-member-model { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-member-desc { font-size: 11.5px; line-height: 1.4; color: var(--dsw-alias-label-secondary); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
.lc-member-reply { font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-style: italic; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-member-reply::before { content: none; }
.lc-member-foot { display: flex; align-items: center; gap: 6px; margin-top: auto; padding-top: 3px; }
/* The run's activity line (the graph cards' meta readout): figures left, occupancy right. */
.lc-member-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; font-size: 10.5px; line-height: 1.3; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-member-meta-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-member-meta-pct { flex: none; font-weight: 600; }
.lc-member-foot { display: flex; align-items: center; gap: 6px; margin-top: auto; padding-top: 2px; }
.lc-member-tasks { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-brand-primary, var(--color-blue-500)); font: inherit; font-size: 10.5px; padding: 1px 8px; border-radius: 999px; cursor: pointer; }
.lc-member-tasks:hover { border-color: var(--dsw-alias-brand-primary, var(--color-blue-500)); }
.lc-member-tasks-on { background: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 14%, transparent); border-color: var(--dsw-alias-brand-primary, var(--color-blue-500)); }
@@ -98,18 +106,6 @@
.lc-team-task-warning { margin-top: 5px; font-size: 10.5px; color: var(--color-amber-500); }
.lc-team-task-meta .lc-inspector-btn { margin-left: auto; }
.lc-fleet-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.lc-fleet-row { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 8px; border: 1px solid transparent; cursor: pointer; font-size: 12px; }
.lc-fleet-row:hover { background: var(--dsw-alias-bg-layer-2); }
.lc-fleet-row:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, var(--color-blue-500)); outline-offset: 1px; }
.lc-fleet-row-pinned { border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 55%, transparent); background: var(--dsw-alias-bg-layer-2); }
.lc-fleet-dot-idle { flex: none; width: 10px; height: 10px; border-radius: 50%; background: color-mix(in srgb, var(--dsw-alias-label-secondary) 30%, transparent); }
.lc-fleet-row-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
.lc-fleet-row-team { color: var(--dsw-alias-brand-primary, var(--color-blue-500)); }
.lc-fleet-row-meta { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-fleet-row-pct { flex: none; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-fleet-row-open { flex: none; padding: 1px 7px; }
/* ---- Comms timeline ---- */
.lc-comms-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.lc-comms-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 8px; padding: 6px 2px; font-size: 11.5px; border-bottom: 1px dashed var(--dsw-alias-border-l2); border-left: 2px solid transparent; padding-left: 8px; }
@@ -84,7 +84,7 @@ function makeView(ctx: TestClientCtx, useKit = kit) {
}
describe('FleetView — the panels over one family', () => {
test('the other-agents row open button jumps to the session', async () => {
test('the other-agents card open button jumps to the session', async () => {
stubFleetRoute()
const opened = new Array<string>()
const ctx = new TestClientCtx({
@@ -92,7 +92,8 @@ describe('FleetView — the panels over one family', () => {
})
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
await click(query(query(m.container, '.lc-fleet-row'), '.lc-fleet-row-open'))
const cards = queryAll(m.container, '.lc-member-card')
await click(query(cards[cards.length - 1], '.lc-member-open'))
assert.deepEqual([...opened], ['w2'])
await m.unmount()
ctx.dispose()
@@ -112,11 +113,12 @@ describe('FleetView — the panels over one family', () => {
assert.ok(text(summary).includes('0/1'))
// The network card header carries the team chip.
assert.ok(text(query(m.container, '.lc-agents')).includes('team · 3 members · 1 tasks'))
// The members board: team cards + the other-agents section (w2, the plain subagent).
// The members board: team cards (the ghost's roster row included) + the other-agents
// section (w2, the plain subagent) in the same card form.
const board = query(m.container, '.lc-members')
assert.ok(text(board).includes('worker-bee'))
assert.ok(text(board).includes(DICT_EN['fleet.board.others']))
assert.deepEqual(queryAll(board, '.lc-fleet-row').length, 1)
assert.equal(queryAll(board, '.lc-member-card').length, 4)
assert.ok(text(query(m.container, '.lc-tasks')).includes('schema parsing'))
// The task board's completion bar.
assert.ok(text(query(m.container, '.lc-tasks-progress')).includes('0/1 done'))
@@ -187,10 +189,6 @@ describe('FleetView — the panels over one family', () => {
assert.ok(query(m.container, '[data-agent="w1"]').className.includes('lc-agent-pinned'))
await click(query(m.container, '[data-agent="w1"]'))
assert.equal(queryAll(m.container, '.lc-agent-pinned').length, 0)
// The corner button still jumps.
await click(query(query(m.container, '[data-agent="w1"]'), '.lc-agent-open'))
assert.deepEqual([...opened], ['w1'])
await m.unmount()
ctx.dispose()
})
@@ -362,7 +360,7 @@ describe('FleetView — the panels over one family', () => {
assert.ok(text(progress).includes('1/2'))
// No team: the members board lists the subagents and the task board points at the team mode.
assert.ok(text(query(m.container, '.lc-members')).includes(DICT_EN['fleet.board.noTeam']))
assert.deepEqual(queryAll(m.container, '.lc-fleet-row').length, 2)
assert.equal(queryAll(m.container, '.lc-member-card').length, 2)
assert.ok(text(query(m.container, '.lc-tasks')).includes(DICT_EN['fleet.tasks.noTeam']))
await m.unmount()
ctx.dispose()
+61 -47
View File
@@ -1,6 +1,7 @@
// MemberBoard — every family agent in one panel: the team roster as full cards (identity, status,
// model, duty, latest reply, owner-filter interlock), the other agents as compact rows, and the
// toolbar (search / status / sort) steering both sections, down to the no-team and solo guidance.
// model, duty, latest reply, owner-filter interlock), the other agents as cards in the same form
// (label as the name, run meta in place of the duty line), and the toolbar (search / status / sort)
// steering both sections, down to the no-team and solo guidance.
import { act, createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
@@ -103,8 +104,9 @@ describe('MemberBoard — team cards', () => {
detailOf: id => details.get(id),
pinnedId: 'w1',
})))
// Four roster cards, then the three other-agents cards in the same form.
const cards = queryAll(m.container, '.lc-member-card')
assert.equal(cards.length, 4)
assert.equal(cards.length, 7)
const lead = cards[0]
assert.ok(text(lead).includes('lead'))
@@ -230,87 +232,101 @@ describe('MemberBoard — team cards', () => {
})
})
describe('MemberBoard — the other-agents rows', () => {
function rowLabels(container: HTMLElement): string[] {
return queryAll(container, '.lc-fleet-row-label').map(el => el.textContent ?? '')
describe('MemberBoard — the other-agents cards', () => {
/** The cards of the "other agents" section: the grid after that section's own head. */
function otherCards(container: HTMLElement): HTMLElement[] {
const head = queryAll(container, '.lc-board-section-head')
.find(h => text(h).includes(DICT_EN['fleet.board.others']))
return queryAll((head?.nextElementSibling ?? container) as HTMLElement, '.lc-member-card')
}
function otherNames(container: HTMLElement): string[] {
return otherCards(container).map(c => text(query(c, '.lc-member-name')))
}
test('non-member family agents list as compact rows with meta, pct, and the open jump', async () => {
test('non-member family agents render as member cards with status, meta, and the open jump', async () => {
const pins = new Array<string | null>()
const opened: string[] = []
const hovers: (string | null)[] = []
// A landed detail fills the card's reply line, like the roster cards'.
const details = new Map<string, { detail: FleetDetail | null }>([
['s2', { detail: { lastReply: { time: 100, text: 'archive sealed' }, messages: [], truncated: false } }],
])
const m = await mount(h(MemberBoard, propsOf({
detailOf: id => details.get(id),
onPin: id => { pins.push(id) },
onOpen: id => { opened.push(id) },
onHover: id => { hovers.push(id) },
})))
// The section head, then one row per plain subagent — the current session and team members stay out.
// The section head, then one card per plain subagent — the current session and team members stay out.
assert.ok(text(m.container).includes(DICT_EN['fleet.board.others']))
assert.deepEqual(rowLabels(m.container), ['scout', 'archivist', 'lounger'])
const rows = queryAll(m.container, '.lc-fleet-row')
assert.ok(text(rows[0]).includes('800 · deepseek-flash · 42s · 5 steps'))
assert.ok(text(rows[0]).includes('80%'))
assert.equal(query(rows[2], '.lc-fleet-row-meta').textContent, '', 'a stat-less row carries no meta')
assert.equal(queryAll(rows[2], '.lc-fleet-dot-idle').length, 1, 'the idle row wears the placeholder dot')
assert.deepEqual(otherNames(m.container), ['scout', 'archivist', 'lounger'])
const cards = otherCards(m.container)
// The run meta rides the card: consumption, duration, steps — and the occupancy figure.
assert.ok(text(cards[0]).includes('800 · 42s · 5 steps'))
assert.ok(text(cards[0]).includes('80%'))
assert.ok(text(cards[0]).includes('deepseek-flash'))
assert.ok(text(cards[0]).includes('running'))
assert.ok(text(cards[1]).includes('archive sealed'), 'the landed latest reply fills the card')
// A stat-less agent carries no meta line and reads as inactive.
assert.equal(queryAll(cards[2], '.lc-member-meta').length, 0)
assert.ok(text(cards[2]).includes(DICT_EN['fleet.memberInactive']))
await hover(rows[0])
await click(rows[0])
await unhover(rows[0])
await hover(cards[0])
await click(cards[0])
await unhover(cards[0])
assert.deepEqual([...pins], ['s1'])
assert.deepEqual(hovers, ['s1', null])
await click(query(rows[0], '.lc-fleet-row-open'))
await click(query(cards[0], '.lc-member-open'))
assert.deepEqual(opened, ['s1'])
assert.deepEqual([...pins], ['s1'], 'open never pins')
// Keyboard: Enter pins, Space releases, an unrelated key is inert.
await act(async () => { rows[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) })
await act(async () => { rows[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true })) })
// Keyboard: Enter pins, an unrelated key is inert.
await act(async () => { cards[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) })
await act(async () => { cards[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true })) })
assert.deepEqual([...pins], ['s1', 's1'])
await m.unmount()
// The pinned row rings; a re-click releases, and so does the Space key.
// The pinned card rings; a re-click releases, and so does the Space key.
const again = new Array<string | null>()
const m2 = await mount(h(MemberBoard, propsOf({ pinnedId: 's1', onPin: id => { again.push(id) } })))
const pinnedRow = queryAll(m2.container, '.lc-fleet-row')[0]
assert.ok(pinnedRow.className.includes('lc-fleet-row-pinned'))
await click(pinnedRow)
await act(async () => { pinnedRow.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })) })
const pinnedCard = otherCards(m2.container)[0]
assert.ok(pinnedCard.className.includes('lc-member-pinned'))
await click(pinnedCard)
await act(async () => { pinnedCard.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })) })
assert.deepEqual([...again], [null, null])
await m2.unmount()
})
test('the toolbar searches, filters by status, and sorts — across both sections', async () => {
const m = await mount(h(MemberBoard, propsOf()))
// Status filter: running keeps the running members' cards and the running subagent's row.
// Status filter: running keeps the running members' cards and the running subagent's card.
const filters = queryAll(m.container, '.lc-fleet-filters .lc-gran-btn')
await click(filters[1])
assert.deepEqual(queryAll(m.container, '.lc-member-card').map(c => text(query(c, '.lc-member-name'))), ['lead', 'worker-bee'])
assert.deepEqual(rowLabels(m.container), ['scout'])
assert.deepEqual(queryAll(m.container, '.lc-member-card').map(c => text(query(c, '.lc-member-name'))), ['lead', 'worker-bee', 'scout'])
await click(filters[2])
assert.deepEqual(rowLabels(m.container), ['archivist'])
assert.equal(queryAll(m.container, '.lc-member-card').length, 0, 'no member node is done')
assert.deepEqual(queryAll(m.container, '.lc-member-card').map(c => text(query(c, '.lc-member-name'))), ['archivist'])
await click(filters[3])
assert.deepEqual(rowLabels(m.container), ['lounger'])
assert.deepEqual(queryAll(m.container, '.lc-member-card').map(c => text(query(c, '.lc-member-name'))), ['flaky-one', 'new-joiner', 'lounger'])
await click(filters[0])
// Sort by context tokens: 800 ahead of 200 ahead of the headless idler.
await selectIn(query(m.container, '.lc-fleet-sort'), 'tokens')
assert.deepEqual(rowLabels(m.container), ['scout', 'archivist', 'lounger'])
assert.deepEqual(otherNames(m.container), ['scout', 'archivist', 'lounger'])
// (Spawn order coincides here; the reverse fixture order proves the sort.)
await m.unmount()
// A headless idler mid-list exercises the sorters' null-head arms on both sides.
const flipped: AgentForest = { ...FOREST, nodes: [FOREST.nodes[0], FOREST.nodes[1], FOREST.nodes[2], FOREST.nodes[3], FOREST.nodes[5], FOREST.nodes[4]] }
const m2 = await mount(h(MemberBoard, propsOf({ forest: flipped })))
assert.deepEqual(rowLabels(m2.container), ['scout', 'lounger', 'archivist'], 'spawn order follows the forest')
assert.deepEqual(otherNames(m2.container), ['scout', 'lounger', 'archivist'], 'spawn order follows the forest')
await selectIn(query(m2.container, '.lc-fleet-sort'), 'tokens')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
assert.deepEqual(otherNames(m2.container), ['scout', 'archivist', 'lounger'])
await selectIn(query(m2.container, '.lc-fleet-sort'), 'duration')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
assert.deepEqual(otherNames(m2.container), ['scout', 'archivist', 'lounger'])
await selectIn(query(m2.container, '.lc-fleet-sort'), 'steps')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
assert.deepEqual(otherNames(m2.container), ['scout', 'archivist', 'lounger'])
await selectIn(query(m2.container, '.lc-fleet-sort'), 'recent')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
assert.deepEqual(otherNames(m2.container), ['scout', 'archivist', 'lounger'])
await m2.unmount()
})
@@ -318,7 +334,6 @@ describe('MemberBoard — the other-agents rows', () => {
const m = await mount(h(MemberBoard, propsOf()))
await typeIn(query(m.container, '.lc-fleet-search'), 'worker')
assert.equal(queryAll(m.container, '.lc-member-card').length, 1)
assert.equal(queryAll(m.container, '.lc-fleet-row').length, 0)
await typeIn(query(m.container, '.lc-fleet-search'), 'zzz-no-match')
assert.equal(queryAll(m.container, '.lc-member-card').length, 0)
assert.ok(text(m.container).includes(DICT_EN['fleet.list.empty']))
@@ -327,13 +342,12 @@ describe('MemberBoard — the other-agents rows', () => {
})
describe('MemberBoard — guidance states', () => {
test('no team: the note names the mode and the family rows still list', async () => {
test('no team: the note names the mode and the family cards still list', async () => {
const m = await mount(h(MemberBoard, propsOf({ team: null })))
assert.ok(text(m.container).includes(DICT_EN['fleet.board.noTeam']))
assert.equal(queryAll(m.container, '.lc-member-card').length, 0)
// Without a roster every non-current node is an "other agent".
// Without a roster every non-current node is an "other agent" — in the same card form.
assert.deepEqual(
queryAll(m.container, '.lc-fleet-row-label').map(el => el.textContent),
queryAll(m.container, '.lc-member-card .lc-member-name').map(el => el.textContent),
['w1', 'w2', 'scout', 'archivist', 'lounger'],
)
await m.unmount()
@@ -344,7 +358,7 @@ describe('MemberBoard — guidance states', () => {
const m = await mount(h(MemberBoard, propsOf({ team: null, forest: solo })))
assert.ok(text(m.container).includes(DICT_EN['fleet.board.solo']))
assert.equal(queryAll(m.container, '.lc-fleet-toolbar').length, 0)
assert.equal(queryAll(m.container, '.lc-fleet-row').length, 0)
assert.equal(queryAll(m.container, '.lc-member-card').length, 0)
await m.unmount()
})
@@ -357,11 +371,11 @@ describe('MemberBoard — guidance states', () => {
await m.unmount()
})
test('a team whose members all filtered out hides the section but keeps the rows', async () => {
test('a team whose members all filtered out hides the section but keeps the other cards', async () => {
const m = await mount(h(MemberBoard, propsOf()))
await typeIn(query(m.container, '.lc-fleet-search'), 'scout')
assert.equal(queryAll(m.container, '.lc-member-card').length, 0)
assert.equal(queryAll(m.container, '.lc-fleet-row').length, 1)
assert.ok(text(m.container).includes('scout'))
assert.equal(queryAll(m.container, '.lc-member-card').length, 1)
await m.unmount()
})
})