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:
1 parent
9d8b7b7bf0
commit
d648edec1e
4 files changed
+154
-112
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user