feat(client): rebuild the Fleet tab around one stable three-shape layout

- Overview strip: family stats (agents/running/done/tokens/est. cost) with an
  adaptive progress bar — shared tasks when a team serves any, else subagent
  completion, and no bar for a solo session
- Agent network: taller link channel (level pitch 144→168), node cap 25→40,
  and a card click now selects (ring + the task board's owner filter) instead
  of jumping sessions; the jump moves to a corner button on each card
- The graph's cursor-following inspector leaves the Fleet tab (the boards
  carry that detail); the sidebar mount keeps it
- Members board absorbs the roster: team cards section plus a compact
  other-agents section, one search/status/sort toolbar steering both; the
  standalone FleetList card is gone
- Task board: completion bar, status/owner grouping switch, owner pinning and
  hover lineage from task rows, transitive prerequisite-chain highlighting,
  and a guidance empty state without a team
- Both boards stay mounted with guidance notes in team-less and solo
  sessions, so the page never reshapes between session kinds
- Hoist displayCurrencyOf into cost.ts (was duplicated per mount)
This commit is contained in:
bowenliang123 committed 2026-10-11 23:35:29 +08:00
1 parent 2c8095c34d
commit 928abdac97
19 files changed
+1520 -867

No files matched your search

+4 -3
View File
@@ -84,7 +84,7 @@ export interface AgentForest {
}
/** The card stays readable up to this many nodes; the rest folds into an overflow note. */
export const AGENT_TREE_LIMIT = 25
export const AGENT_TREE_LIMIT = 40
/** Fixed card height — mirrored by `.lc-agent-card`'s `height` in agentGraph.css, so a link's
* exit point (card bottom) and entry point (card top) always land on the card's edge. */
@@ -95,8 +95,9 @@ export const AGENT_CARD_H = 104
const SLOT_MAX = 276
const SLOT_MIN = 150
const CARD_GUTTER = 12
/** Vertical pitch between depth levels: card height + a dedicated 40px link channel below it. */
const LEVEL_H = AGENT_CARD_H + 40
/** Vertical pitch between depth levels: card height + a dedicated 64px link channel below it,
* tall enough for the S-curves to read as separate strands even in a wide fan-out. */
const LEVEL_H = AGENT_CARD_H + 64
const PAD_Y = 48
export function agentRowOf(value: unknown): AgentRow | null {
+66 -58
View File
@@ -2,19 +2,19 @@
* The Agent network card — the head of the Fleet tab, and (compact) the right Sidebar's Context
* panel. The current agent's whole family (ancestors, siblings, subagents) as a node graph, where
* every node is a live card of that session's own context — title, occupancy, and a composition
* bar — and a click jumps to that agent's session. Links are bezier curves fanned out from the
* parent's foot, hued per level-1 family; hovering a card lights its whole lineage (ancestors and
* subtree).
* bar. Links are bezier curves fanned out from the parent's foot, hued per level-1 family; hovering
* a card lights its whole lineage (ancestors and subtree). A card click follows the mount: the
* sidebar jumps straight to that agent's session; the Fleet tab selects instead (`onSelect` — ring
* + cross-panel linkage), with the jump on the card's corner button.
*
* The card splits in two on wide-enough panes (the `lc-card` container query folds it to stacked
* on the narrow Sidebar): the graph on the left, the inspector (agentInspector.tsx) on the right,
* following the hovered card, the pinned pick, or the current session.
* The sidebar mount pairs the graph with the inspector (agentInspector.tsx) on its right — the
* `lc-card` container query folds that split to stacked on the narrow pane — following the hovered
* card, the pinned pick, or the current session. The Fleet tab's mount drops the inspector
* (`inspector={false}`): its own members and task boards carry that detail beside the graph.
*
* The forest derivation lives in fleetModel.ts (`useFleetModel`): the Fleet tab derives it once
* and passes it in (`forest`/`team`), so every panel reads the same family; the sidebar mount
* leaves both undefined and the card derives its own. Fleet-only extras (pinning, team membership,
* fleet-detail prompts and comms) arrive through `extras`; the sidebar mount passes none and the
* inspector renders its stats-and-composition core alone.
* The forest derivation lives in fleetModel.ts (`useFleetModel`): the Fleet tab derives the family
* once and passes both halves in (`forest`/`team`), so every panel reads the same family; the
* sidebar mount leaves them undefined and the card derives its own.
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactElement } from 'react'
@@ -22,13 +22,12 @@ import { IconWorkspaceTreeOutlineRegular, StateDot } from '@deepseek-ai/dsh-clie
import { CATS } from '../categories'
import type { AgentHeads } from '../agentHeads'
import { makeAgentHeads } from '../agentHeads'
import { billedTokensOf, estimateSessionCost, formatCost, type CostCurrency, type ModelBook } from '../cost'
import { billedTokensOf, displayCurrencyOf, estimateSessionCost, formatCost, type CostCurrency, type ModelBook } from '../cost'
import { useModelPrices } from '../modelPrices'
import { containHorizontalOverscroll } from '../overscroll'
import { openSessionVia, type ClientCtx } from '../services'
import type { ViewKit } from '../viewkit'
import type { FleetDetail, FleetMemberInfo } from '../../shared/types'
import type { CommsRow } from '../fleetDetail'
import type { FleetDetail } from '../../shared/types'
import { useFleetModel } from '../fleetModel'
import type { FleetTeam } from '../fleetTeam'
import type { AgentForest, AgentNode, AgentSelfStats } from '../agentTree'
@@ -41,33 +40,24 @@ import {
} from '../agentTree'
import { AgentInspector } from './agentInspector'
/** The Fleet tab's enrichment of the inspector: pin state, team roster, and the fleet-detail reads. */
export interface AgentGraphExtras {
team: FleetTeam | null
pinnedId: string | null
onPin: (id: string | null) => void
/** The landed fleet-detail read of one session; undefined while in flight. */
detailOf: (id: string) => { detail: FleetDetail | null } | undefined
/** Merged comms rows involving one session. */
commsOf: (id: string) => CommsRow[]
labelOf: (id: string) => string
/** The lead's roster facts (duty descriptions) off its landed fleet detail. */
roster: readonly FleetMemberInfo[]
/** An inspector task chip flashes the task on the task board. */
onFocusTask: (id: string | null) => void
}
export interface AgentGraphProps {
sessionId?: string
/** Live stats of the current session from the tab's own projections (internal derivation only). */
self?: AgentSelfStats
/** The Fleet tab's shared derivation; undefined = the card derives its own (the sidebar mount). */
forest?: AgentForest | null
/** The family's team projection for the header chip; undefined = the internal derivation's own. */
team?: FleetTeam | null
/** The pinned session (the Fleet tab's list/board picks); its card rings. */
pinnedId?: string | null
/** Controlled hover (the Fleet tab lifts it so the list rows light graph nodes too). */
hoverId?: string | null
onHover?: (id: string | null) => void
/** The Fleet tab's enrichment; its `team` (possibly null) is the family's team projection then. */
extras?: AgentGraphExtras
/** When set (the Fleet tab), a card click selects the node — ring + cross-panel linkage —
* instead of jumping to its session; a small corner button keeps the jump. */
onSelect?: (id: string) => void
/** The preview pane beside the graph: the sidebar's compact mount keeps it, the Fleet tab paints the graph alone. */
inspector?: boolean
}
/** Entrance stagger cap for the cards and their bar segments (the stackedBar idiom). */
@@ -87,19 +77,13 @@ export function makeAgentGraph(
): (props: AgentGraphProps) => ReactElement | null {
const { t, fmt, catLabel } = kit
/** The display currency follows the active locale — read per render: the slot outlet re-renders on a locale switch. */
function activeCurrency(): CostCurrency {
const locale = ctx.locale
return typeof locale.getLocale === 'function' && locale.getLocale().active === 'zh' ? 'cny' : 'usd'
}
function AgentGraph(props: AgentGraphProps): ReactElement | null {
const sessionId = props.sessionId
// The internal derivation runs only when the caller passes no forest in; the flag is a
// mount-stable prop split (the Fleet tab always passes one, the sidebar never does).
const internal = useFleetModel(ctx, heads, sessionId, props.self, props.forest === undefined)
const forest = props.forest !== undefined ? props.forest : internal.forest
const team = props.extras !== undefined ? props.extras.team : internal.team
const team = props.team !== undefined ? props.team : internal.team
const [internalHover, setInternalHover] = useState<string | null>(null)
const hoverId = props.hoverId !== undefined ? props.hoverId : internalHover
@@ -107,7 +91,7 @@ export function makeAgentGraph(
// The shared price book (the stats board's own store): a card's cost estimate
// appears once the book lands, and never blocks the rest of the card.
const { book } = useModelPrices()
const currency = activeCurrency()
const currency = displayCurrencyOf(ctx.locale)
// The layout is fully responsive: re-run it whenever the stage's visible
// width changes (sidebar toggles, window resizes, split views).
@@ -141,7 +125,7 @@ export function makeAgentGraph(
const byId = new Map(forest.nodes.map(n => [n.id, n]))
/** v8 ignore next 1 -- agentForestOf anchors the forest at the current session, so a current node always exists. */
const current = forest.nodes.find(n => n.isCurrent) ?? forest.nodes[0]
const pinnedId = props.extras?.pinnedId ?? null
const pinnedId = props.pinnedId ?? null
const pinnedNode = pinnedId !== null ? byId.get(pinnedId) : undefined
const hoverNode = hoverId !== null ? byId.get(hoverId) : undefined
const inspected = pinnedNode ?? hoverNode ?? current
@@ -156,25 +140,29 @@ export function makeAgentGraph(
if (id === current.id) return
openSessionVia(ctx, id)
}
// Select mode (the Fleet tab): a card click rings the node and steers the sibling panels,
// and even the current card is pickable; the jump lives on the card's corner button.
const select = props.onSelect
const keyOpen = (id: string) => (ev: KeyboardEvent) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return
ev.preventDefault()
open(id)
if (select !== undefined) select(id)
else open(id)
}
const extras = props.extras
// The sidebar's preview follows the hover, the pin, or the current session.
const inspector = (
<AgentInspector
node={inspected}
pinned={pinnedNode !== undefined}
team={team ?? null}
detail={extras !== undefined ? extras.detailOf(inspected.id) : nullDetail}
roster={extras?.roster ?? []}
comms={extras !== undefined ? extras.commsOf(inspected.id) : []}
labelOf={extras?.labelOf ?? fallbackLabel(byId)}
detail={nullDetail}
roster={[]}
comms={[]}
labelOf={fallbackLabel(byId)}
onOpen={open}
onPin={extras?.onPin ?? noopPin}
onFocusTask={extras?.onFocusTask ?? noopPin}
onPin={noopPin}
onFocusTask={noopPin}
t={t}
fmt={fmt}
catLabel={catLabel}
@@ -188,7 +176,7 @@ export function makeAgentGraph(
<IconWorkspaceTreeOutlineRegular size={14} />
{t('agents.title')}
</span>
<span className="lc-card-sub">{t('agents.sub')}</span>
<span className="lc-card-sub">{t(select !== undefined ? 'agents.subSelect' : 'agents.sub')}</span>
</div>
<div className="lc-agents-chips">
@@ -257,6 +245,7 @@ export function makeAgentGraph(
pinned={pinnedId === node.id}
onHover={onHover}
onOpen={open}
onSelect={select}
onKeyOpen={keyOpen(node.id)}
book={book}
currency={currency}
@@ -288,7 +277,7 @@ export function makeAgentGraph(
</div>
</div>
{inspector}
{props.inspector === false ? null : inspector}
</div>
</div>
)
@@ -299,11 +288,11 @@ export function makeAgentGraph(
/** The sidebar mount's inspector carries no fleet-detail read: a settled-empty detail hides those sections. */
const nullDetail: { detail: FleetDetail | null } = { detail: null }
/* v8 ignore next -- the unpin/focus callbacks exist only with the Fleet tab's extras. */
/* v8 ignore next -- the sidebar's inspector never pins, so unpin/focus never fire. */
const noopPin = (): void => {}
/** Without the Fleet tab's label resolver, sender labels fall back to the forest's own rows. */
/* v8 ignore next 2 -- without extras the inspector's comms list is always empty, so this never fires. */
/** The sidebar's inspector never lists comms, so sender labels fall back to the forest's own rows. */
/* v8 ignore next 2 -- unreachable while the comms list is always empty. */
function fallbackLabel(byId: ReadonlyMap<string, AgentNode>): (id: string) => string {
return id => byId.get(id)?.label ?? id
}
@@ -321,6 +310,8 @@ interface CardProps {
pinned: boolean
onHover: (id: string | null) => void
onOpen: (id: string) => void
/** Set on the Fleet tab: card click selects (ring + panel linkage) instead of opening. */
onSelect?: (id: string) => void
onKeyOpen: (ev: KeyboardEvent) => void
/** The price book and display currency for the cost estimate (null book → no estimate shown). */
book: ModelBook | null
@@ -346,21 +337,25 @@ function AgentCard(props: CardProps): ReactElement {
const meta: string[] = []
if (node.durationMs !== null) meta.push(fmtDurationCompact(node.durationMs))
if (node.requests > 0) meta.push(props.t('agents.steps', { n: node.requests }))
const selectable = props.onSelect !== undefined
const cls = 'lc-agent-card animate-lc-agent-in motion-reduce:animate-none'
+ (node.isCurrent ? ' lc-agent-self' : '')
// lc-agent-hover carries no rule of its own — the hover/focus wash rides
// :hover/:focus-visible; the class stays as the specs' state anchor.
+ (props.hovered ? ' lc-agent-hover' : '')
+ (props.pinned ? ' lc-agent-pinned' : '')
+ (node.isCurrent ? '' : ' lc-agent-clickable')
+ (selectable || !node.isCurrent ? ' lc-agent-clickable' : '')
return (
<div
className={cls}
style={{ left: x - width / 2, top: y, width, '--lc-i': Math.min(props.index, STAGGER_CAP) } as CSSProperties}
data-agent={node.id}
role={node.isCurrent ? undefined : 'button'}
tabIndex={node.isCurrent ? undefined : 0}
onClick={() => { props.onOpen(node.id) }}
role={selectable || !node.isCurrent ? 'button' : undefined}
tabIndex={selectable || !node.isCurrent ? 0 : undefined}
onClick={() => {
if (props.onSelect !== undefined) props.onSelect(node.id)
else props.onOpen(node.id)
}}
onKeyDown={props.onKeyOpen}
onMouseEnter={() => { props.onHover(node.id) }}
onMouseLeave={() => { props.onHover(null) }}
@@ -376,6 +371,19 @@ function AgentCard(props: CardProps): ReactElement {
whenever a long title claims both lines. The lc-agent-self-badge class carries no
rule of its own — it stays as the specs' anchor for the self marker. */}
{node.isCurrent ? <span className="lc-agents-badge lc-agent-self-badge">{props.t('agents.self')}</span> : null}
{selectable && !node.isCurrent ? (
<button
type="button"
className="lc-agent-open"
title={props.t('fleet.open')}
onClick={(ev) => {
ev.stopPropagation()
props.onOpen(node.id)
}}
>
→
</button>
) : null}
</div>
<div className="lc-agent-metric">
<b className="lc-agent-tokens">{metricBits.length > 0 ? metricBits.join(' · ') : '—'}</b>
-170
View File
@@ -1,170 +0,0 @@
/**
* The Fleet roster — every agent of the family as a filterable, sortable list. A row click pins
* the agent into the network card's inspector (and rings its graph node); the tail button opens
* its session. Rows hover-feed the graph's lineage focus through the shared `onHover`.
*/
import { useMemo, useState, type ReactElement } from 'react'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentForest, AgentNode } from '../agentTree'
import { fmtDurationCompact } from '../agentTree'
import { teamMemberOf, type FleetTeam } from '../fleetTeam'
import type { ViewKit } from '../viewkit'
export interface FleetListProps {
forest: AgentForest
team: FleetTeam | null
pinnedId: string | null
onPin: (id: string | null) => void
onHover: (id: string | null) => void
onOpen: (id: string) => void
t: ViewKit['t']
fmt: ViewKit['fmt']
}
type StatusFilter = 'all' | 'running' | 'done' | 'idle'
type SortKey = 'spawn' | 'tokens' | 'duration' | 'steps' | 'recent'
function statusOf(node: AgentNode): Exclude<StatusFilter, 'all'> {
if (node.running) return 'running'
return node.completed ? 'done' : 'idle'
}
const SORTERS: Record<Exclude<SortKey, 'spawn'>, (a: AgentNode, b: AgentNode) => number> = {
tokens: (a, b) => (b.head?.tokens ?? 0) - (a.head?.tokens ?? 0),
duration: (a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0),
steps: (a, b) => b.requests - a.requests,
recent: (a, b) => b.updatedAt - a.updatedAt,
}
export function FleetList(props: FleetListProps): ReactElement {
const { forest, team, t, fmt } = props
const [query, setQuery] = useState('')
const [status, setStatus] = useState<StatusFilter>('all')
const [sort, setSort] = useState<SortKey>('spawn')
const rows = useMemo(() => {
const needle = query.trim().toLowerCase()
const out = forest.nodes.filter((node) => {
if (status !== 'all' && statusOf(node) !== status) return false
if (needle !== '' && !node.label.toLowerCase().includes(needle) && !node.id.toLowerCase().includes(needle)) return false
return true
})
// Spawn order is the forest's own DFS order — the parent catalog's own sibling sequence.
return sort === 'spawn' ? out : [...out].sort(SORTERS[sort])
}, [forest, query, status, sort])
const counts = useMemo(() => {
const c = { all: forest.nodes.length, running: 0, done: 0, idle: 0 }
for (const n of forest.nodes) c[statusOf(n)]++
return c
}, [forest])
return (
<div className="lc-card lc-fleet-list">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('fleet.list.title')}</span>
<span className="lc-card-sub">{t('fleet.list.sub')}</span>
</div>
<div className="lc-fleet-toolbar">
<input
type="search"
className="lc-fleet-search"
placeholder={t('fleet.list.search')}
value={query}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
<div className="lc-fleet-filters" role="group" aria-label={t('fleet.list.filter')}>
{(['all', 'running', 'done', 'idle'] as const).map(key => (
<button
key={key}
type="button"
className={'lc-gran-btn' + (status === key ? ' lc-gran-on' : '')}
onClick={() => { setStatus(key) }}
>
{t(`fleet.filter.${key}`)}
<span className="lc-kind-n">{fmt(counts[key])}</span>
</button>
))}
</div>
<select
className="lc-fleet-sort"
value={sort}
aria-label={t('fleet.list.sort')}
onChange={(ev) => { setSort(ev.target.value as SortKey) }}
>
{(['spawn', 'tokens', 'duration', 'steps', 'recent'] as const).map(key => (
<option key={key} value={key}>{t(`fleet.sort.${key}`)}</option>
))}
</select>
</div>
{rows.length === 0 ? (
<div className="lc-empty">{t('fleet.list.empty')}</div>
) : (
<ul className="lc-fleet-rows">
{rows.map((node) => {
const member = teamMemberOf(team, node.id)
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 }))
return (
<li key={node.id}>
<div
className={
'lc-fleet-row'
+ (props.pinnedId === node.id ? ' lc-fleet-row-pinned' : '')
+ (node.isCurrent ? ' lc-fleet-row-self' : '')
}
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>
{node.isCurrent ? <span className="lc-agents-badge">{t('agents.self')}</span> : null}
{member !== null && member.role === 'teammate'
? <span className="lc-agents-badge lc-fleet-row-team">{member.name}</span>
: null}
{member !== null && member.role === 'lead'
? <span className="lc-agents-badge lc-fleet-row-team">{t('fleet.role.lead')}</span>
: null}
<span className="lc-fleet-row-meta">{meta.join(' · ')}</span>
{pct !== null
? <span className="lc-fleet-row-pct">{pct}%</span>
: null}
{!node.isCurrent ? (
<button
type="button"
className="lc-inspector-btn lc-fleet-row-open"
title={t('fleet.open')}
onClick={(ev) => {
ev.stopPropagation()
props.onOpen(node.id)
}}
>
→
</button>
) : null}
</div>
</li>
)
})}
</ul>
)}
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
/**
* The Fleet tab's overview strip — family-wide stats (agents, running, done, context tokens, cost
* estimate) with one progress bar whose semantics follow the session shape: the team's shared
* tasks when they exist, else the subagents' completion, and no bar at all for a solo session.
*/
import type { ReactElement } from 'react'
import type { AgentForest } from '../agentTree'
import { estimateSessionCost, formatCost, type CostCurrency, type ModelBook } from '../cost'
import type { FleetTeam } from '../fleetTeam'
import type { ViewKit } from '../viewkit'
export interface FleetSummaryProps {
forest: AgentForest
team: FleetTeam | null
book: ModelBook | null
currency: CostCurrency
t: ViewKit['t']
fmt: ViewKit['fmt']
}
export function FleetSummary(props: FleetSummaryProps): ReactElement {
const { forest, team, t, fmt } = props
const nodes = forest.nodes
const running = nodes.filter(n => n.running).length
const subs = nodes.filter(n => !n.isCurrent)
const subsDone = subs.filter(n => n.completed).length
let tokens = 0
for (const n of nodes) tokens += n.head?.tokens ?? 0
let cost = 0
let costKnown = false
for (const n of nodes) {
const priced = estimateSessionCost(n.costUsage, props.book, props.currency)
if (priced !== null && priced > 0) {
cost += priced
costKnown = true
}
}
const tasks = team?.tasks ?? []
const tasksDone = tasks.filter(task => task.status === 'completed').length
const progress = tasks.length > 0
? { label: t('fleet.summary.tasks'), done: tasksDone, total: tasks.length }
: subs.length > 0
? { label: t('fleet.summary.subs'), done: subsDone, total: subs.length }
: null
return (
<div className="lc-card lc-summary">
<div className="lc-summary-chips">
<span className="lc-agents-chip">{t('agents.chip.count', { n: nodes.length + forest.overflow })}</span>
<span className={'lc-agents-chip' + (running > 0 ? ' lc-agents-chip-on' : '')}>
{t('agents.chip.running', { n: running })}
</span>
{subs.length > 0
? <span className="lc-agents-chip">{t('fleet.summary.done', { n: subsDone })}</span>
: null}
{tokens > 0
? <span className="lc-agents-chip">{t('agents.chip.tokens', { n: fmt(tokens) })}</span>
: null}
{costKnown
? <span className="lc-agents-chip">{t('fleet.summary.cost', { n: formatCost(cost, props.currency) })}</span>
: null}
</div>
{progress !== null ? (
<div className="lc-summary-progress">
<span className="lc-summary-progress-label">{progress.label}</span>
<span className="lc-tasks-progress-track">
<i style={{ width: `${Math.round((progress.done / progress.total) * 100)}%` }} />
</span>
<span className="lc-summary-progress-n">{progress.done}/{progress.total}</span>
</div>
) : null}
</div>
)
}
+87 -93
View File
@@ -1,18 +1,21 @@
/**
* The Fleet tab — the agent family's standalone home. Five panels over ONE shared derivation
* (fleetModel.ts): the network card (graph + cursor-following inspector), the members board and
* the task board (the harness's Agent Teams projection, when the family leads one), then the
* roster list and the comms timeline as a two-up row. Hover, pin, owner filter, and task focus are
* all lifted here, so a roster row, a member card, a task owner chip, a blocker chip, and a comms
* party all steer the same inspector and each other.
* The Fleet tab — the agent family's standalone home. One shared derivation (fleetModel.ts) feeds
* an overview strip (family stats + the progress bar: shared tasks when a team exists, subagent
* completion otherwise), then a two-up cockpit row — the network card left, the members board
* over the task board right — then the comms timeline. Both boards stay up with guidance notes
* when the session never formed a team, so the page's shape never jumps between session kinds.
* Hover, pin, owner filter, and task focus are all lifted here, so a graph node, a member card, an
* agent row, a task owner chip, a blocker chip, and a comms party all steer each other.
*/
import { createElement as h, useEffect, useMemo, useState, type ReactElement } from 'react'
import type { AgentHeads } from '../agentHeads'
import { agentSelfOf } from '../agentTree'
import { mergeComms, type CommsRow } from '../fleetDetail'
import { displayCurrencyOf } from '../cost'
import { mergeComms } from '../fleetDetail'
import { useFleetDetails, useFleetModel } from '../fleetModel'
import { teamOf } from '../fleetTeam'
import { teamMemberOf, teamOf } from '../fleetTeam'
import { useModelPrices } from '../modelPrices'
import type { ClientCtx, SessionStandardProps } from '../services'
import { openSessionVia, projectionOf, unsupportedOf } from '../services'
import { useContextSession } from '../sessionData'
@@ -22,7 +25,7 @@ import { makeAgentGraph } from './agentGraph'
import { CommsPanel } from './commsPanel'
import { makeDetailNote } from './detailNote'
import { makeErrorBoundary } from './errorBoundary'
import { FleetList } from './fleetList'
import { FleetSummary } from './fleetSummary'
import { MemberBoard } from './memberBoard'
import { TaskBoard } from './taskBoard'
import { makeUpgradeGate } from './upgradeGate'
@@ -52,35 +55,25 @@ export function makeFleetView(
const details = useFleetDetails()
const [hoverId, setHoverId] = useState<string | null>(null)
const [pinnedId, setPinnedId] = useState<string | null>(null)
/** The task board's owner filter and focus flash, shared with the members board and inspector. */
/** The task board's owner filter and focus flash, shared with the members board. */
const [ownerFilter, setOwnerFilter] = useState<string | null>(null)
const [focusTaskId, setFocusTaskId] = useState<string | null>(null)
const { book } = useModelPrices()
const currency = displayCurrencyOf(ctx.locale)
const byId = useMemo(
() => new Map((forest?.nodes ?? []).map(n => [n.id, n])),
[forest],
)
const currentId = forest?.nodes.find(n => n.isCurrent)?.id
// A pinned agent that left the family releases the pin instead of haunting the inspector.
// A pinned agent that left the family releases the pin instead of ringing a ghost.
useEffect(() => {
if (pinnedId !== null && !byId.has(pinnedId)) setPinnedId(null)
}, [pinnedId, byId])
const focusId = pinnedId ?? hoverId ?? currentId ?? null
const focusNode = focusId !== null ? byId.get(focusId) : undefined
// The inspector's fleet-detail read follows the focus (hover or pin), fresh-stamped by the
// row's updatedAt: a focused agent that keeps working re-reads on each list-snapshot tick.
const focusUpdatedAt = focusNode?.updatedAt
useEffect(() => {
if (focusId === null || focusUpdatedAt === undefined) return
details.request(focusId, focusUpdatedAt)
}, [focusId, focusUpdatedAt])
// The lead's own detail feeds the members board's roster facts; the read dedupes with the
// inspector's whenever the lead IS the focus. Every member's detail rides too, so the member
// cards' model and latest reply fill in on first paint instead of after a hover.
// The lead's own detail feeds the members board's roster facts. Every member's detail rides
// too, so the member cards' model and latest reply fill in on first paint instead of after a
// hover, and the comms merge sees the family's relay rows.
const leadId = team !== null ? team.members[0]?.id : undefined
const memberStamp = team === null ? ''
: team.members.map(m => `${m.id}@${byId.get(m.id)?.updatedAt ?? 0}`).join(',')
@@ -100,20 +93,6 @@ export function makeFleetView(
return map
}, [details.landed])
const comms = useMemo(() => mergeComms(landedDetails), [landedDetails])
const commsByAgent = useMemo(() => {
const map = new Map<string, CommsRow[]>()
for (const row of comms) {
if (row.from !== undefined) {
const list = map.get(row.from) ?? []
list.push(row)
map.set(row.from, list)
}
const list = map.get(row.to) ?? []
if (row.from !== row.to) list.push(row)
map.set(row.to, list)
}
return map
}, [comms])
const labelOf = (id: string): string => {
const node = byId.get(id)
@@ -126,6 +105,16 @@ export function makeFleetView(
openSessionVia(ctx, id)
}
/** A graph-node pick rings the card and, for a team member, narrows the task board to their
* owned tasks; picking again releases both. */
const select = (id: string): void => {
const next = pinnedId === id ? null : id
setPinnedId(next)
const member = teamMemberOf(team, id)
if (next !== null) setOwnerFilter(member?.name ?? null)
else if (member !== null && ownerFilter === member.name) setOwnerFilter(null)
}
if (data === null) {
return (
<div className="lc-root">
@@ -140,70 +129,75 @@ export function makeFleetView(
// session, so one dismissal holds across the Context tab and this one.
const gate = unsupportedOf(data.unsupported)
const leadDetail = leadId !== undefined ? landedDetails.get(leadId) ?? null : null
/** A task-board owner pick toggles the pin, so a second pick on the same owner releases it. */
const pinToggle = (id: string | null): void => {
setPinnedId(prev => (prev === id ? null : id))
}
const taskBoard = (
<TaskBoard
team={team}
ownerFilter={ownerFilter}
onOwnerFilter={setOwnerFilter}
focusTaskId={focusTaskId}
onFocusTask={setFocusTaskId}
onPinMember={pinToggle}
onHoverMember={setHoverId}
t={t}
/>
)
// The boards column's two cards stack; the network column is the graph alone, and either seat
// may be absent (no snapshot at all, or a team whose forest has not landed yet).
const boardsColumn = forest !== null
? (
<div className="lc-col flex-1 min-w-[min(360px,100%)]">
<MemberBoard
team={team}
forest={forest}
leadDetail={leadDetail}
detailOf={id => details.landed.get(id)}
pinnedId={pinnedId}
onPin={setPinnedId}
onHover={setHoverId}
onOpen={open}
ownerFilter={ownerFilter}
onOwnerFilter={setOwnerFilter}
t={t}
fmt={kit.fmt}
/>
{taskBoard}
</div>
)
: team !== null
? <div className="lc-col flex-1 min-w-[min(360px,100%)]">{taskBoard}</div>
: null
return (
<div className="lc-root">
<AgentGraph
sessionId={sessionId}
forest={forest}
hoverId={hoverId}
onHover={setHoverId}
extras={{
team,
pinnedId,
onPin: setPinnedId,
detailOf: id => details.landed.get(id),
commsOf: id => commsByAgent.get(id) ?? [],
labelOf,
roster: leadDetail?.roster ?? [],
onFocusTask: setFocusTaskId,
}}
/>
{team !== null
{forest !== null
? <FleetSummary forest={forest} team={team} book={book} currency={currency} t={t} fmt={kit.fmt} />
: null}
{forest !== null || boardsColumn !== null
? (
<div className="lc-cols">
{forest !== null
? (
<MemberBoard
team={team}
forest={forest}
leadDetail={leadDetail}
detailOf={id => details.landed.get(id)}
pinnedId={pinnedId}
onPin={setPinnedId}
onHover={setHoverId}
onOpen={open}
ownerFilter={ownerFilter}
onOwnerFilter={setOwnerFilter}
t={t}
/>
<div className="lc-col flex-[2] min-w-[min(420px,100%)]">
<AgentGraph
sessionId={sessionId}
forest={forest}
team={team}
pinnedId={pinnedId}
hoverId={hoverId}
onHover={setHoverId}
onSelect={select}
inspector={false}
/>
</div>
)
: null}
<TaskBoard
team={team}
ownerFilter={ownerFilter}
onOwnerFilter={setOwnerFilter}
focusTaskId={focusTaskId}
onFocusTask={setFocusTaskId}
onPinMember={setPinnedId}
t={t}
/>
{boardsColumn}
</div>
)
: null}
{forest !== null && !forest.solo
? (
<FleetList
forest={forest}
team={team}
pinnedId={pinnedId}
onPin={setPinnedId}
onHover={setHoverId}
onOpen={open}
t={t}
fmt={kit.fmt}
/>
)
: null}
{forest !== null && !forest.solo
? (
<CommsPanel
+287 -114
View File
@@ -1,21 +1,22 @@
/**
* The Fleet members board — the team's roster as full cards, one per member: an identity block
* (icon keyed to the member's family hue, name, role/phase badges), the spawn-time duty
* description off the lead's durable roster records, the model it runs, its latest reply, and its
* live task count. A card click pins the member into the network inspector; the task chip filters
* the task board to that owner; the open button jumps to the member's session.
* 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.
*/
import type { CSSProperties, ReactElement } from 'react'
import { useMemo, useState, type CSSProperties, type ReactElement } from 'react'
import { IconUserOutlineRegular, IconUsersOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentForest } from '../agentTree'
import { familyHue } from '../agentTree'
import { tasksOfOwner, type FleetTeam } from '../fleetTeam'
import type { AgentForest, AgentNode } from '../agentTree'
import { familyHue, fmtDurationCompact } from '../agentTree'
import { tasksOfOwner, teamMemberOf, type FleetTeam } from '../fleetTeam'
import type { FleetDetail, FleetMemberInfo } from '../../shared/types'
import type { ViewKit } from '../viewkit'
export interface MemberBoardProps {
team: FleetTeam
/** Null when the session never composed the agent-team plugin — the panel still lists the family. */
team: FleetTeam | null
forest: AgentForest
/** The lead session's landed fleet detail — the roster facts (duty descriptions) live there. */
leadDetail: FleetDetail | null
@@ -29,6 +30,7 @@ export interface MemberBoardProps {
ownerFilter: string | null
onOwnerFilter: (name: string | null) => void
t: ViewKit['t']
fmt: ViewKit['fmt']
}
/** One member's card hue: the lead carries the brand color, teammates their golden-angled roster
@@ -39,116 +41,287 @@ function memberStyle(index: number, lead: boolean): CSSProperties {
} as CSSProperties
}
type StatusFilter = 'all' | 'running' | 'done' | 'idle'
type SortKey = 'spawn' | 'tokens' | 'duration' | 'steps' | 'recent'
function statusOf(node: AgentNode): Exclude<StatusFilter, 'all'> {
if (node.running) return 'running'
return node.completed ? 'done' : 'idle'
}
const SORTERS: Record<Exclude<SortKey, 'spawn'>, (a: AgentNode, b: AgentNode) => number> = {
tokens: (a, b) => (b.head?.tokens ?? 0) - (a.head?.tokens ?? 0),
duration: (a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0),
steps: (a, b) => b.requests - a.requests,
recent: (a, b) => b.updatedAt - a.updatedAt,
}
export function MemberBoard(props: MemberBoardProps): ReactElement {
const { team, forest, t } = props
const nodeById = new Map(forest.nodes.map(n => [n.id, n]))
const { team, forest, t, fmt } = props
const [query, setQuery] = useState('')
const [status, setStatus] = useState<StatusFilter>('all')
const [sort, setSort] = useState<SortKey>('spawn')
const nodeById = useMemo(() => new Map(forest.nodes.map(n => [n.id, n])), [forest])
const roster = new Map<string, FleetMemberInfo>()
for (const info of props.leadDetail?.roster ?? []) roster.set(info.name, info)
return (
<div className="lc-card lc-members flex-1 min-w-[min(360px,100%)]"> <div className="lc-card-title">
<span className="lc-card-title-text">
<IconUsersOutlineRegular size={14} />
{t('fleet.members.title')}
</span>
<span className="lc-agents-chip">{t('fleet.members.count', { n: team.members.length })}</span>
{team.failure !== undefined ? (
<span className="lc-agents-badge lc-agents-badge-err" title={team.failure}>{t('fleet.team.failureShort')}</span>
) : null}
</div>
// The other-agents section: family nodes outside the team roster, minus the current session
// (its own card already headlines the network graph).
const othersAll = useMemo(
() => forest.nodes.filter(n => !n.isCurrent && teamMemberOf(team, n.id) === null),
[forest, team],
)
const others = useMemo(() => {
const needle = query.trim().toLowerCase()
const out = othersAll.filter((node) => {
if (status !== 'all' && statusOf(node) !== status) return false
if (needle !== '' && !node.label.toLowerCase().includes(needle) && !node.id.toLowerCase().includes(needle)) return false
return true
})
// Spawn order is the forest's own DFS order — the parent catalog's own sibling sequence.
return sort === 'spawn' ? out : [...out].sort(SORTERS[sort])
}, [othersAll, query, status, sort])
<div className="lc-members-grid">
{team.members.map((member, index) => {
const node = nodeById.get(member.id)
const running = node?.running === true
const info = roster.get(member.name)
const detail = props.detailOf(member.id)?.detail ?? null
const model = node?.model ?? detail?.descriptor?.agentModel
const owned = tasksOfOwner(team, member.name)
const activeTasks = owned.filter(task => task.status !== 'completed')
const isCurrent = node?.isCurrent === true
return (
<div
key={member.id}
className={
'lc-member-card'
+ (props.pinnedId === member.id ? ' lc-member-pinned' : '')
+ (member.phase === 'failed' ? ' lc-member-failed' : '')
}
style={memberStyle(index, member.role === 'lead')}
role="button"
tabIndex={0}
onClick={() => { props.onPin(props.pinnedId === member.id ? null : member.id) }}
onKeyDown={(ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return
ev.preventDefault()
props.onPin(props.pinnedId === member.id ? null : member.id)
}}
onMouseEnter={() => { props.onHover(member.id) }}
onMouseLeave={() => { props.onHover(null) }}
>
<div className="lc-member-head">
<span className="lc-member-icon">
{member.role === 'lead' ? <IconUsersOutlineRegular size={15} /> : <IconUserOutlineRegular size={15} />}
</span>
<b className="lc-member-name">{member.name}</b>
{isCurrent ? <span className="lc-agents-badge">{t('agents.self')}</span> : null}
<span className="lc-agents-badge">{t(member.role === 'lead' ? 'fleet.role.lead' : 'fleet.role.teammate')}</span>
<StateDot
state={member.phase === 'failed' ? 'error' : member.phase === 'provisioning' || running ? 'ongoing' : 'idle'}
size={9}
className="lc-member-dot"
/>
</div>
<div className="lc-member-status">
{member.phase === 'failed'
? t('fleet.phase.failed')
: member.phase === 'provisioning'
? t('fleet.phase.provisioning')
: running ? t('agents.running') : t('fleet.memberInactive')}
{model !== undefined ? <span className="lc-member-model">{model}</span> : null}
</div>
{member.error !== undefined ? <div className="lc-inspector-error">{member.error}</div> : null}
{info !== undefined && info.description !== '' ? (
<div className="lc-member-desc" title={info.description}>{info.description}</div>
) : null}
{detail?.lastReply !== undefined ? (
<div className="lc-member-reply" title={detail.lastReply.text}>
{t('fleet.members.reply')} {detail.lastReply.text}
</div>
) : null}
<div className="lc-member-foot">
{activeTasks.length > 0 ? (
<button
type="button"
className={'lc-member-tasks' + (props.ownerFilter === member.name ? ' lc-member-tasks-on' : '')}
title={t('fleet.members.tasksTip')}
onClick={(ev) => {
ev.stopPropagation()
props.onOwnerFilter(props.ownerFilter === member.name ? null : member.name)
}}
>
{t('fleet.members.tasks', { n: activeTasks.length })}
</button>
) : <span className="lc-member-tasks-none">{t('fleet.members.noTasks')}</span>}
{!isCurrent && node !== undefined ? (
<button
type="button"
className="lc-inspector-btn lc-member-open"
title={t('fleet.open')}
onClick={(ev) => {
ev.stopPropagation()
props.onOpen(member.id)
}}
>
→
</button>
) : null}
</div>
// The toolbar steers the member cards too: search by member name, status by their node's state
// (a member whose session left the list reads as idle).
const members = useMemo(() => {
if (team === null) return []
const needle = query.trim().toLowerCase()
return team.members.filter((member) => {
if (needle !== '' && !member.name.toLowerCase().includes(needle)) return false
if (status === 'all') return true
const node = nodeById.get(member.id)
return node === undefined ? status === 'idle' : statusOf(node) === status
})
}, [team, query, status, nodeById])
const counts = useMemo(() => {
const c = { all: 0, running: 0, done: 0, idle: 0 }
for (const n of forest.nodes) {
if (n.isCurrent) continue
c.all++
c[statusOf(n)]++
}
return c
}, [forest])
// The toolbar earns its keep only with at least two entries to sift; a filtered-empty board
// keeps it so the reader can back out of the filter.
const listTotal = (team?.members.length ?? 0) + othersAll.length
const bare = query === '' && status === 'all'
return (
<div className="lc-card lc-members">
<div className="lc-card-title">
<span className="lc-card-title-text">
<IconUsersOutlineRegular size={14} />
{t('fleet.board.title')}
</span>
<span className="lc-agents-chip">{t('agents.chip.count', { n: forest.nodes.length + forest.overflow })}</span>
{team?.failure !== undefined ? (
<span className="lc-agents-badge lc-agents-badge-err" title={team.failure}>{t('fleet.team.failureShort')}</span>
) : null}
</div>
{listTotal > 1 || !bare ? (
<div className="lc-fleet-toolbar">
<input
type="search"
className="lc-fleet-search"
placeholder={t('fleet.list.search')}
value={query}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
<div className="lc-fleet-filters" role="group" aria-label={t('fleet.list.filter')}>
{(['all', 'running', 'done', 'idle'] as const).map(key => (
<button
key={key}
type="button"
className={'lc-gran-btn' + (status === key ? ' lc-gran-on' : '')}
onClick={() => { setStatus(key) }}
>
{t(`fleet.filter.${key}`)}
<span className="lc-kind-n">{fmt(counts[key])}</span>
</button>
))}
</div>
)
})}
</div>
<select
className="lc-fleet-sort"
value={sort}
aria-label={t('fleet.list.sort')}
onChange={(ev) => { setSort(ev.target.value as SortKey) }}
>
{(['spawn', 'tokens', 'duration', 'steps', 'recent'] as const).map(key => (
<option key={key} value={key}>{t(`fleet.sort.${key}`)}</option>
))}
</select>
</div>
) : null}
{team !== null && members.length > 0 ? (
<>
<div className="lc-board-section-head">
{t('fleet.members.title')}
<span className="lc-kind-n">{fmt(members.length)}</span>
</div>
<div className="lc-members-grid">
{members.map((member, index) => {
const node = nodeById.get(member.id)
const running = node?.running === true
const info = roster.get(member.name)
const detail = props.detailOf(member.id)?.detail ?? null
const model = node?.model ?? detail?.descriptor?.agentModel
const owned = tasksOfOwner(team, member.name)
const activeTasks = owned.filter(task => task.status !== 'completed')
const isCurrent = node?.isCurrent === true
return (
<div
key={member.id}
className={
'lc-member-card'
+ (props.pinnedId === member.id ? ' lc-member-pinned' : '')
+ (member.phase === 'failed' ? ' lc-member-failed' : '')
}
style={memberStyle(index, member.role === 'lead')}
role="button"
tabIndex={0}
onClick={() => { props.onPin(props.pinnedId === member.id ? null : member.id) }}
onKeyDown={(ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return
ev.preventDefault()
props.onPin(props.pinnedId === member.id ? null : member.id)
}}
onMouseEnter={() => { props.onHover(member.id) }}
onMouseLeave={() => { props.onHover(null) }}
>
<div className="lc-member-head">
<span className="lc-member-icon">
{member.role === 'lead' ? <IconUsersOutlineRegular size={15} /> : <IconUserOutlineRegular size={15} />}
</span>
<b className="lc-member-name">{member.name}</b>
{isCurrent ? <span className="lc-agents-badge">{t('agents.self')}</span> : null}
<span className="lc-agents-badge">{t(member.role === 'lead' ? 'fleet.role.lead' : 'fleet.role.teammate')}</span>
<StateDot
state={member.phase === 'failed' ? 'error' : member.phase === 'provisioning' || running ? 'ongoing' : 'idle'}
size={9}
className="lc-member-dot"
/>
</div>
<div className="lc-member-status">
{member.phase === 'failed'
? t('fleet.phase.failed')
: member.phase === 'provisioning'
? t('fleet.phase.provisioning')
: running ? t('agents.running') : t('fleet.memberInactive')}
{model !== undefined ? <span className="lc-member-model">{model}</span> : null}
</div>
{member.error !== undefined ? <div className="lc-inspector-error">{member.error}</div> : null}
{info !== undefined && info.description !== '' ? (
<div className="lc-member-desc" title={info.description}>{info.description}</div>
) : null}
{detail?.lastReply !== undefined ? (
<div className="lc-member-reply" title={detail.lastReply.text}>
{t('fleet.members.reply')} {detail.lastReply.text}
</div>
) : null}
<div className="lc-member-foot">
{activeTasks.length > 0 ? (
<button
type="button"
className={'lc-member-tasks' + (props.ownerFilter === member.name ? ' lc-member-tasks-on' : '')}
title={t('fleet.members.tasksTip')}
onClick={(ev) => {
ev.stopPropagation()
props.onOwnerFilter(props.ownerFilter === member.name ? null : member.name)
}}
>
{t('fleet.members.tasks', { n: activeTasks.length })}
</button>
) : <span className="lc-member-tasks-none">{t('fleet.members.noTasks')}</span>}
{!isCurrent && node !== undefined ? (
<button
type="button"
className="lc-inspector-btn lc-member-open"
title={t('fleet.open')}
onClick={(ev) => {
ev.stopPropagation()
props.onOpen(member.id)
}}
>
→
</button>
) : null}
</div>
</div>
)
})}
</div>
</>
) : null}
{team === null && forest.solo ? (
<div className="lc-empty">{t('fleet.board.solo')}</div>
) : null}
{team === null && !forest.solo ? (
<div className="lc-members-note">{t('fleet.board.noTeam')}</div>
) : null}
{others.length > 0 ? (
<>
<div className="lc-board-section-head">
{t('fleet.board.others')}
<span className="lc-kind-n">{fmt(others.length)}</span>
</div>
<ul className="lc-fleet-rows">
{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 }))
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}
<button
type="button"
className="lc-inspector-btn lc-fleet-row-open"
title={t('fleet.open')}
onClick={(ev) => {
ev.stopPropagation()
props.onOpen(node.id)
}}
>
→
</button>
</div>
</li>
)
})}
</ul>
</>
) : null}
{!bare && members.length === 0 && others.length === 0 ? <div className="lc-empty">{t('fleet.list.empty')}</div> : null}
</div>
)
}
+224 -91
View File
@@ -1,26 +1,30 @@
/**
* The Fleet task board — the team's shared task DAG as one grouped list: in-flight work first,
* then the pending pool (ready ahead of blocked), then done. Dependencies read inline: every
* blocker chip carries the blocker's own status dot, so "who waits on whom, and is it done yet"
* never needs a second look; a chip click flashes the blocker's row. The owner chip pins the
* member into the network inspector, and the owner filter interlocks with the members board's
* task counts.
* The Fleet task board — the team's shared task DAG, grouped by status or by owner, with a
* completion bar in the head. Dependencies read inline: every blocker chip carries the blocker's
* own status dot, and a chip click focuses that blocker — its whole prerequisite chain lights
* along, so "what still gates this" never needs a second look. A row pick pins the owner (ringing
* their graph node and member card), a row hover lights the owner's lineage in the graph, and the
* owner filter interlocks with the members board's task counts. Without a team the card stays up
* with a pointer to the team mode.
*/
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactElement } from 'react'
import { IconChecklistOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { memberIdByName, type FleetTeam, type FleetTeamTask } from '../fleetTeam'
import type { ViewKit } from '../viewkit'
export interface TaskBoardProps {
team: FleetTeam
/** Null when the session never composed the agent-team plugin — the card shows a pointer instead. */
team: FleetTeam | null
/** The members board's owner filter (null = every owner). */
ownerFilter: string | null
onOwnerFilter: (name: string | null) => void
/** The flashed task (blocker picks and inspector task chips land here). */
/** The focused task (blocker picks land here); its prerequisite chain lights with it. */
focusTaskId: string | null
onFocusTask: (id: string | null) => void
onPinMember: (id: string | null) => void
/** Row hovers steer the graph's lineage focus toward the task's owner. */
onHoverMember: (id: string | null) => void
t: ViewKit['t']
}
@@ -34,14 +38,30 @@ function taskDot(task: FleetTeamTask): 'done' | 'ongoing' | 'idle' | 'warning' {
return task.ready ? 'idle' : 'warning'
}
/** Board order within a group: in-flight first, then the pending pool ready-first, then done. */
function orderTasks(tasks: FleetTeamTask[]): FleetTeamTask[] {
return GROUPS.flatMap(key =>
key === 'pending'
? tasks.filter(task => task.status === 'pending').sort((a, b) => Number(b.ready) - Number(a.ready))
: tasks.filter(task => task.status === key))
}
/** A group of tasks under one owner; `owner: null` is the unowned pool. */
interface OwnerGroup {
owner: string | null
tasks: FleetTeamTask[]
}
export function TaskBoard(props: TaskBoardProps): ReactElement {
const { team, t } = props
/** Task ids with their description unfolded. */
const [openTasks, setOpenTasks] = useState<ReadonlySet<string>>(new Set())
const byId = useMemo(() => new Map(team.tasks.map(task => [task.id, task])), [team])
const [view, setView] = useState<'status' | 'owner'>('status')
const tasks = useMemo(() => team?.tasks ?? [], [team])
const byId = useMemo(() => new Map(tasks.map(task => [task.id, task])), [tasks])
const boardRef = useRef<HTMLDivElement | null>(null)
// A blocker pick (or an inspector task chip) flashes the row and scrolls it into view.
// A blocker pick flashes the row and scrolls it into view.
const focused = props.focusTaskId
useEffect(() => {
if (focused === null) return
@@ -50,9 +70,29 @@ export function TaskBoard(props: TaskBoardProps): ReactElement {
if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'nearest' })
}, [focused])
const visible = props.ownerFilter === null
? team.tasks
: team.tasks.filter(task => task.ownerName === props.ownerFilter)
// The focused task's whole prerequisite chain (transitive blockedBy) lights along with it.
const chain = useMemo(() => {
if (focused === null) return null
const out = new Set<string>()
const queue = [focused]
while (queue.length > 0) {
/* v8 ignore next 1 -- the pop is guarded by the length check. */
const id = queue.pop() ?? ''
for (const dep of byId.get(id)?.blockedBy ?? []) {
if (!out.has(dep)) {
out.add(dep)
queue.push(dep)
}
}
}
out.delete(focused)
return out
}, [focused, byId])
const visible = useMemo(
() => props.ownerFilter === null ? tasks : tasks.filter(task => task.ownerName === props.ownerFilter),
[tasks, props.ownerFilter],
)
const grouped = GROUPS.map(key => ({
key,
// The pending pool reads ready-first: what can start now outranks what waits.
@@ -60,6 +100,24 @@ export function TaskBoard(props: TaskBoardProps): ReactElement {
? visible.filter(task => task.status === 'pending').sort((a, b) => Number(b.ready) - Number(a.ready))
: visible.filter(task => task.status === key),
}))
const byOwner = useMemo((): OwnerGroup[] => {
if (team === null) return []
const names = team.members.map(m => m.name)
// Roster order first, then owners off the roster, and the unowned pool always last.
const stray = new Set<string>()
let unowned = false
for (const task of visible) {
if (task.ownerName === undefined) unowned = true
else if (!names.includes(task.ownerName)) stray.add(task.ownerName)
}
const keys: (string | null)[] = [...names, ...stray, ...(unowned ? [null] : [])]
return keys
.map(name => ({ owner: name, tasks: orderTasks(visible.filter(task => (task.ownerName ?? null) === name)) }))
.filter(group => group.tasks.length > 0)
}, [team, visible])
const doneCount = tasks.filter(task => task.status === 'completed').length
const activeCount = tasks.filter(task => task.status === 'in_progress').length
const toggleOpen = (id: string): void => {
setOpenTasks((prev) => {
@@ -75,6 +133,97 @@ export function TaskBoard(props: TaskBoardProps): ReactElement {
props.onFocusTask(props.focusTaskId === id ? null : id)
}
const pickOwner = (task: FleetTeamTask): void => {
const owner = memberIdByName(team, task.ownerName)
if (owner !== undefined) props.onPinMember(owner)
}
const hoverOwner = (task: FleetTeamTask | null): void => {
props.onHoverMember(task === null ? null : memberIdByName(team, task.ownerName) ?? null)
}
/** A row pick pins the owner; picks landing on the row's own buttons keep their behavior. */
const rowClick = (task: FleetTeamTask) => (ev: MouseEvent) => {
if ((ev.target as HTMLElement).closest('button') !== null) return
pickOwner(task)
}
const renderTask = (task: FleetTeamTask): ReactElement => (
<li
key={task.id}
className={
'lc-team-task'
+ (props.focusTaskId === task.id ? ' lc-team-task-focus' : '')
+ (chain?.has(task.id) === true ? ' lc-team-task-chain' : '')
}
data-task={task.id}
onClick={rowClick(task)}
onMouseEnter={() => { hoverOwner(task) }}
onMouseLeave={() => { hoverOwner(null) }}
>
<div className="lc-team-task-head">
<StateDot state={taskDot(task)} size={9} />
<b className="lc-team-task-subject">{task.subject}</b>
{task.status === 'pending'
? (
<span className={'lc-agents-badge' + (task.ready ? ' lc-agents-badge-on' : '')}>
{t(task.ready ? 'fleet.taskReady' : 'fleet.taskBlocked')}
</span>
)
: null}
{task.ownerName !== undefined ? (
<button
type="button"
className="lc-team-task-owner"
title={t('fleet.tasks.ownerTip')}
onClick={() => { pickOwner(task) }}
>
{task.ownerName}
</button>
) : <span className="lc-agents-badge">{t('fleet.taskUnowned')}</span>}
</div>
{task.description !== '' ? (
<div className={'lc-team-task-desc' + (openTasks.has(task.id) ? ' lc-team-task-desc-open' : '')}>
{task.description}
</div>
) : null}
{task.blockedBy.length > 0 ? (
<div className="lc-team-task-deps">
{t('fleet.tasks.dependsOn')}
{task.blockedBy.map((dep) => {
const blocker = byId.get(dep)
return (
<button
key={dep}
type="button"
className="lc-team-task-dep"
title={t('fleet.tasks.depTip', { id: dep })}
onClick={() => { flashTask(dep) }}
>
{blocker !== undefined ? <StateDot state={taskDot(blocker)} size={7} /> : null}
{dep}
</button>
)
})}
</div>
) : null}
<div className="lc-team-task-meta">
<span className="lc-team-task-id">{task.id}</span>
{task.writeScopes.length > 0 ? (
<span className="lc-team-task-scopes">{t('fleet.taskScopes')} {task.writeScopes.join(', ')}</span>
) : null}
{task.description !== '' ? (
<button type="button" className="lc-inspector-btn" onClick={() => { toggleOpen(task.id) }}>
{t(openTasks.has(task.id) ? 'fleet.collapse' : 'fleet.expand')}
</button>
) : null}
</div>
{task.warnings.length > 0 ? (
<div className="lc-team-task-warning">{task.warnings.join(' · ')}</div>
) : null}
</li>
)
return (
<div className="lc-card lc-tasks flex-1 min-w-[min(360px,100%)]" ref={boardRef}>
<div className="lc-card-title">
@@ -83,6 +232,20 @@ export function TaskBoard(props: TaskBoardProps): ReactElement {
{t('fleet.tasks.title')}
</span>
<span className="lc-card-sub">{t('fleet.tasks.sub')}</span>
{tasks.length > 0 ? (
<span className="lc-fleet-filters lc-tasks-view" role="group" aria-label={t('fleet.tasks.view')}>
{(['status', 'owner'] as const).map(key => (
<button
key={key}
type="button"
className={'lc-gran-btn' + (view === key ? ' lc-gran-on' : '')}
onClick={() => { setView(key) }}
>
{t(`fleet.tasks.group.${key}`)}
</button>
))}
</span>
) : null}
{props.ownerFilter !== null ? (
<button type="button" className="lc-agents-badge lc-tasks-filter" onClick={() => { props.onOwnerFilter(null) }}>
{props.ownerFilter} ×
@@ -90,95 +253,65 @@ export function TaskBoard(props: TaskBoardProps): ReactElement {
) : null}
</div>
{team.tasks.length === 0 ? (
{tasks.length > 0 ? (
<div className="lc-tasks-progress">
<span className="lc-tasks-progress-track">
<i style={{ width: `${Math.round((doneCount / tasks.length) * 100)}%` }} />
</span>
<span className="lc-tasks-progress-text">
{t('fleet.tasks.progress', { done: doneCount, total: tasks.length })}
{activeCount > 0 ? ` · ${t('fleet.tasks.active', { n: activeCount })}` : ''}
</span>
</div>
) : null}
{team === null ? (
<div className="lc-empty">{t('fleet.tasks.noTeam')}</div>
) : tasks.length === 0 ? (
<div className="lc-empty">{t('fleet.team.empty')}</div>
) : (
grouped.map(({ key, tasks }) => {
if (tasks.length === 0) return null
) : view === 'status' ? (
grouped.map(({ key, tasks: groupTasks }) => {
if (groupTasks.length === 0) return null
return (
<div key={key} className={`lc-tasks-group lc-tasks-group-${key}`}>
<div className="lc-tasks-group-head">
{t(`fleet.taskCol.${key}`)}
<span className="lc-kind-n">{tasks.length}</span>
<span className="lc-kind-n">{groupTasks.length}</span>
</div>
<ul className="lc-tasks-rows">
{tasks.map(task => (
<li
key={task.id}
className={'lc-team-task' + (props.focusTaskId === task.id ? ' lc-team-task-focus' : '')}
data-task={task.id}
>
<div className="lc-team-task-head">
<StateDot state={taskDot(task)} size={9} />
<b className="lc-team-task-subject">{task.subject}</b>
{task.status === 'pending'
? (
<span className={'lc-agents-badge' + (task.ready ? ' lc-agents-badge-on' : '')}>
{t(task.ready ? 'fleet.taskReady' : 'fleet.taskBlocked')}
</span>
)
: null}
{task.ownerName !== undefined ? (
<button
type="button"
className="lc-team-task-owner"
title={t('fleet.tasks.ownerTip')}
onClick={() => {
const owner = memberIdByName(team, task.ownerName)
if (owner !== undefined) props.onPinMember(owner)
}}
>
{task.ownerName}
</button>
) : <span className="lc-agents-badge">{t('fleet.taskUnowned')}</span>}
</div>
{task.description !== '' ? (
<div className={'lc-team-task-desc' + (openTasks.has(task.id) ? ' lc-team-task-desc-open' : '')}>
{task.description}
</div>
) : null}
{task.blockedBy.length > 0 ? (
<div className="lc-team-task-deps">
{t('fleet.tasks.dependsOn')}
{task.blockedBy.map((dep) => {
const blocker = byId.get(dep)
return (
<button
key={dep}
type="button"
className="lc-team-task-dep"
title={t('fleet.tasks.depTip', { id: dep })}
onClick={() => { flashTask(dep) }}
>
{blocker !== undefined ? <StateDot state={taskDot(blocker)} size={7} /> : null}
{dep}
</button>
)
})}
</div>
) : null}
<div className="lc-team-task-meta">
<span className="lc-team-task-id">{task.id}</span>
{task.writeScopes.length > 0 ? (
<span className="lc-team-task-scopes">{t('fleet.taskScopes')} {task.writeScopes.join(', ')}</span>
) : null}
{task.description !== '' ? (
<button type="button" className="lc-inspector-btn" onClick={() => { toggleOpen(task.id) }}>
{t(openTasks.has(task.id) ? 'fleet.collapse' : 'fleet.expand')}
</button>
) : null}
</div>
{task.warnings.length > 0 ? (
<div className="lc-team-task-warning">{task.warnings.join(' · ')}</div>
) : null}
</li>
))}
{groupTasks.map(renderTask)}
</ul>
</div>
)
})
) : (
byOwner.map(({ owner, tasks: groupTasks }) => (
<div key={owner ?? 'unowned'} className="lc-tasks-group lc-tasks-group-owner">
<div className="lc-tasks-group-head">
{owner === null
? t('fleet.taskUnowned')
: (
<button
type="button"
className="lc-team-task-owner"
title={t('fleet.tasks.ownerTip')}
onClick={() => {
const memberId = memberIdByName(team, owner)
if (memberId !== undefined) props.onPinMember(memberId)
}}
>
{owner}
</button>
)}
<span className="lc-kind-n">{groupTasks.length}</span>
</div>
<ul className="lc-tasks-rows">
{groupTasks.map(renderTask)}
</ul>
</div>
))
)}
{team.tasks.length > 0 && visible.length === 0 ? (
{tasks.length > 0 && visible.length === 0 ? (
<div className="lc-empty">{t('fleet.tasks.noMatch')}</div>
) : null}
</div>
+6 -1
View File
@@ -10,7 +10,7 @@
import type { SessionCostUsage } from '../shared/types'
import { isDeepSeekProvider, modelsDevProviderOf } from '../shared/providers'
import { asRecord, numOf } from './services'
import { asRecord, numOf, type LocaleService } from './services'
export type CostCurrency = 'usd' | 'cny'
@@ -287,6 +287,11 @@ export function formatCost(amount: number, currency: CostCurrency): string {
return symbol + (amount >= 1 ? amount.toFixed(2) : amount.toPrecision(2))
}
/** The display currency follows the active locale — read per render: slot outlets re-render on a locale switch. */
export function displayCurrencyOf(locale: LocaleService): CostCurrency {
return typeof locale.getLocale === 'function' && locale.getLocale().active === 'zh' ? 'cny' : 'usd'
}
/** Price-list figure: always two decimals (the tooltip's `¥ XX.XX` rate format). */
export function formatPriceRate(amount: number, currency: CostCurrency): string {
return (currency === 'cny' ? '¥' : '$') + amount.toFixed(2)
+17 -5
View File
@@ -212,6 +212,7 @@ export const DICT_EN = {
'files.glyph.lang.html': 'HTML',
'agents.title': 'Agent Network',
'agents.sub': 'Live context of this agent and its subagents · click a node to open its session',
'agents.subSelect': 'Live context of this agent and its subagents · click a node to highlight it, → opens its session',
'agents.chip.count': '{n} agents',
'agents.chip.running': '{n} running',
'agents.chip.tokens': '{n} tokens in context',
@@ -250,18 +251,31 @@ export const DICT_EN = {
'fleet.taskUnowned': 'unowned',
'fleet.taskScopes': 'Write scopes:',
'fleet.members.title': 'Team Members',
'fleet.members.count': '{n} members',
'fleet.members.reply': 'Latest reply:',
'fleet.members.tasks': '{n} active tasks',
'fleet.members.noTasks': 'no active tasks',
'fleet.members.tasksTip': 'Show only this member in the shared tasks',
'fleet.board.title': 'Fleet Members',
'fleet.board.others': 'Other agents',
'fleet.board.noTeam': 'Team mode is off for this session',
'fleet.board.solo': 'Nothing to list yet — delegated subagents appear here',
'fleet.summary.done': '{n} done',
'fleet.summary.cost': 'est. {n} total',
'fleet.summary.tasks': 'Tasks',
'fleet.summary.subs': 'Subagents',
'fleet.tasks.title': 'Shared tasks',
'fleet.tasks.sub': 'The agent team\'s shared task list',
'fleet.tasks.ownerTip': 'Pin this member in the inspector',
'fleet.tasks.depTip': 'depends on {id} · click to locate',
'fleet.tasks.ownerTip': 'Highlight this member',
'fleet.tasks.depTip': 'depends on {id} · click to focus its chain',
'fleet.tasks.focusTip': 'Locate in the shared tasks',
'fleet.tasks.dependsOn': 'depends on',
'fleet.tasks.noMatch': 'No tasks for this member',
'fleet.tasks.progress': '{done}/{total} done',
'fleet.tasks.active': '{n} in progress',
'fleet.tasks.view': 'Group tasks',
'fleet.tasks.group.status': 'By status',
'fleet.tasks.group.owner': 'By owner',
'fleet.tasks.noTeam': 'Shared tasks need the agent-team mode — start a team in conversation',
'fleet.taskCol.pending': 'Pending',
'fleet.taskCol.in_progress': 'In progress',
'fleet.taskCol.completed': 'Done',
@@ -273,8 +287,6 @@ export const DICT_EN = {
'fleet.settled.outOfRoom': 'ran out of context',
'fleet.settled.declined': 'declined the task',
'fleet.settled.failed': 'failed before finishing',
'fleet.list.title': 'Fleet Roster',
'fleet.list.sub': 'Every agent of the family · click a row to pin it in the inspector',
'fleet.list.search': 'Search by name…',
'fleet.list.filter': 'Status filter',
'fleet.list.sort': 'Sort',
+17 -5
View File
@@ -210,6 +210,7 @@ export const DICT_ZH = {
'files.glyph.lang.html': 'HTML',
'agents.title': 'Agent 网络',
'agents.sub': '当前 Agent 与子 Agent 的实时上下文 · 点击节点跳转会话',
'agents.subSelect': '当前 Agent 与子 Agent 的实时上下文 · 点击节点高亮联动,→ 打开会话',
'agents.chip.count': '{n} 个 Agent',
'agents.chip.running': '{n} 个运行中',
'agents.chip.tokens': '上下文合计 {n}',
@@ -248,18 +249,31 @@ export const DICT_ZH = {
'fleet.taskUnowned': '未分配',
'fleet.taskScopes': '写入范围',
'fleet.members.title': '团队成员',
'fleet.members.count': '{n} 名成员',
'fleet.members.reply': '最新回报',
'fleet.members.tasks': '{n} 项进行中任务',
'fleet.members.noTasks': '无进行中任务',
'fleet.members.tasksTip': '在共享任务中只看该成员',
'fleet.board.title': '编队成员',
'fleet.board.others': '其他 Agent',
'fleet.board.noTeam': '未启用智能体团队模式',
'fleet.board.solo': '还没有可列出的成员 — 通过 subagent 工具委派任务后,它们会在这里列出',
'fleet.summary.done': '{n} 已完成',
'fleet.summary.cost': '预估合计 {n}',
'fleet.summary.tasks': '任务进度',
'fleet.summary.subs': '子智能体进度',
'fleet.tasks.title': '共享任务',
'fleet.tasks.sub': '智能体团队共享任务清单',
'fleet.tasks.ownerTip': '在右侧详情中固定该成员',
'fleet.tasks.depTip': '依赖 {id} · 点击定位',
'fleet.tasks.ownerTip': '高亮该成员',
'fleet.tasks.depTip': '依赖 {id} · 点击聚焦其前置链',
'fleet.tasks.focusTip': '在共享任务中定位',
'fleet.tasks.dependsOn': '依赖',
'fleet.tasks.noMatch': '该成员当前没有任务',
'fleet.tasks.progress': '已完成 {done}/{total}',
'fleet.tasks.active': '{n} 进行中',
'fleet.tasks.view': '分组方式',
'fleet.tasks.group.status': '按状态',
'fleet.tasks.group.owner': '按成员',
'fleet.tasks.noTeam': '共享任务在智能体团队模式下可用 — 可以通过对话创建团队',
'fleet.taskCol.pending': '待处理',
'fleet.taskCol.in_progress': '进行中',
'fleet.taskCol.completed': '已完成',
@@ -271,8 +285,6 @@ export const DICT_ZH = {
'fleet.settled.outOfRoom': '上下文耗尽,未完成',
'fleet.settled.declined': '拒绝了任务',
'fleet.settled.failed': '执行失败',
'fleet.list.title': '编队名录',
'fleet.list.sub': '家族内所有 Agent · 点击行固定到右侧详情',
'fleet.list.search': '搜索名称…',
'fleet.list.filter': '状态过滤',
'fleet.list.sort': '排序',
+12 -2
View File
@@ -50,6 +50,13 @@
border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 55%, var(--dsw-alias-border-l2));
}
.lc-agent-card-head { display: flex; align-items: flex-start; gap: 6px; }
/* Select mode (the Fleet tab): the card body highlights, this corner button keeps the jump. */
.lc-agent-open {
flex: none; align-self: center; border: 0; background: transparent; cursor: pointer;
font: inherit; font-size: 12px; line-height: 1; padding: 2px 4px; border-radius: 6px;
color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));
}
.lc-agent-open:hover { color: var(--dsw-alias-brand-primary, var(--color-blue-500)); background: var(--dsw-alias-bg-layer-1); }
/* The harness's own StateDot (the header popup's glyph), nudged onto the title's first line. */
.lc-agent-state { margin-top: 2px; }
/* Fixed two-line title box: every card's metric and bar sit at the same height,
@@ -67,13 +74,16 @@
.lc-agents-solo { padding: 4px 0 10px; }
/* The network card's split: graph left, inspector right; the `lc-card` container query folds it
to stacked on narrow panes (the sidebar mount's own shape). */
to stacked on narrow panes (the sidebar mount's own shape, and now the Fleet tab's narrower
network column). */
.lc-agents-split { display: flex; gap: 10px; align-items: flex-start; }
.lc-agents-main { flex: 1 1 0; min-width: 0; }
/* Both classes sit on the same element: the compound selector beats the strip's margin-top. */
.lc-agents-inspector.lc-inspector { flex: 0 0 300px; width: 300px; margin-top: 0; position: sticky; top: 8px; max-height: min(560px, 80vh); overflow-y: auto; }
@container lc-card (max-width: 720px) {
.lc-agents-split { flex-direction: column; }
/* Stacked, the row's `flex-start` would size each item to its content and let the graph's
canvas spill out of the card: the folded column stretches its items instead. */
.lc-agents-split { flex-direction: column; align-items: stretch; }
.lc-agents-inspector.lc-inspector { flex: none; width: 100%; position: static; max-height: none; }
}
.lc-agent-pinned { border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 70%, var(--dsw-alias-border-l2)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 35%, transparent); }
+23 -1
View File
@@ -10,6 +10,24 @@
.lc-fleet-search:focus-visible { border-color: var(--dsw-alias-brand-primary, var(--color-blue-500)); }
.lc-fleet-sort { font: inherit; font-size: 11.5px; padding: 3px 6px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
/* ---- Overview strip ---- */
.lc-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
.lc-summary-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 1; min-width: 0; }
.lc-summary-progress { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; font-size: 11px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-summary-progress-label { color: var(--dsw-alias-label-primary); font-weight: 600; }
.lc-summary-progress-n { font-weight: 600; }
/* The shared progress track: the strip's wide variant and the task board's header line. */
.lc-tasks-progress-track { display: inline-block; width: 140px; height: 6px; border-radius: 4px; overflow: hidden; background: color-mix(in srgb, var(--dsw-alias-label-secondary) 16%, transparent); }
.lc-tasks-progress-track i { display: block; height: 100%; border-radius: 4px; background: var(--dsw-alias-brand-primary, var(--color-blue-500)); transition: width var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-tasks-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.lc-tasks-progress .lc-tasks-progress-track { flex: 1; }
.lc-tasks-progress-text { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
/* Section heads inside the members board (team cards / other agents). */
.lc-board-section-head { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 650; letter-spacing: 0.02em; color: var(--dsw-alias-label-secondary); margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px dashed var(--dsw-alias-border-l2); }
.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; }
.lc-member-card {
@@ -58,8 +76,12 @@
.lc-tasks-group-completed .lc-tasks-group-head { color: var(--dsw-alias-state-success-primary, var(--color-green-500)); border-bottom-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, var(--color-green-500)) 45%, transparent); }
.lc-tasks-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.lc-team-task { padding: 8px 10px; border-radius: 9px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-team-task { padding: 8px 10px; border-radius: 9px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-team-task:hover { border-color: var(--dsw-alias-label-tertiary, var(--dsw-alias-border-l2)); }
.lc-team-task-focus { border-color: var(--dsw-alias-brand-primary, var(--color-blue-500)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 30%, transparent); }
/* A prerequisite of the focused task: a softer wash under the anchor's ring. */
.lc-team-task-chain { border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 45%, transparent); background: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 6%, var(--dsw-alias-bg-layer-2)); }
.lc-tasks-view { margin-left: auto; }
.lc-team-task-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
.lc-team-task-subject { flex: 1; min-width: 0; font-size: 12px; color: var(--dsw-alias-label-primary); overflow-wrap: anywhere; }
.lc-team-task-owner { border: 0; background: transparent; padding: 0; font: inherit; font-size: 11px; font-weight: 600; color: var(--dsw-alias-brand-primary, var(--color-blue-500)); cursor: pointer; }
+19 -19
View File
@@ -697,8 +697,8 @@ describe('layoutForest', () => {
assert.equal(pointOf.get('root')?.x, SLOT)
// One row per depth level.
assert.equal(pointOf.get('root')?.y, 48)
assert.equal(pointOf.get('a')?.y, 48 + 144)
assert.equal(pointOf.get('g')?.y, 48 + 2 * 144)
assert.equal(pointOf.get('a')?.y, 48 + 168)
assert.equal(pointOf.get('g')?.y, 48 + 2 * 168)
// Links exit the parent card's bottom edge and enter the child card's top edge.
const linkG = layout.links.find(l => l.to === 'g')
assert.ok(linkG !== undefined)
@@ -713,7 +713,7 @@ describe('layoutForest', () => {
assert.equal(layout.links.find(l => l.to === 'b')?.color, familyHue(1))
assert.equal(layout.links.length, 3)
assert.equal(layout.width, 2 * SLOT)
assert.equal(layout.height, 48 + 2 * 144 + 104 + 24)
assert.equal(layout.height, 48 + 2 * 168 + 104 + 24)
})
test('responsive: a wide stage keeps the natural pitch', () => {
@@ -747,9 +747,9 @@ describe('layoutForest', () => {
assert.equal(pointOf.get('a')?.x, 90)
assert.equal(pointOf.get('b')?.x, 90)
assert.equal(pointOf.get('root')?.y, 48)
assert.equal(pointOf.get('a')?.y, 48 + 144)
assert.equal(pointOf.get('b')?.y, 48 + 2 * 144)
assert.equal(layout.height, 48 + 2 * 144 + 104 + 24)
assert.equal(pointOf.get('a')?.y, 48 + 168)
assert.equal(pointOf.get('b')?.y, 48 + 2 * 168)
assert.equal(layout.height, 48 + 2 * 168 + 104 + 24)
})
test('responsive: a level too wide even at the minimum pitch wraps into bands', () => {
@@ -763,17 +763,17 @@ describe('layoutForest', () => {
assert.equal(pointOf.get('c')?.x, 150)
// The wrapped band is a row of its own.
assert.equal(pointOf.get('root')?.y, 48)
assert.equal(pointOf.get('a')?.y, 48 + 144)
assert.equal(pointOf.get('c')?.y, 48 + 2 * 144)
assert.equal(pointOf.get('a')?.y, 48 + 168)
assert.equal(pointOf.get('c')?.y, 48 + 2 * 168)
// Links still join every child, the cross-band one included.
const linkC = layout.links.find(l => l.to === 'c')
assert.ok(linkC !== undefined)
assert.equal(linkC.y1, 48 + 104)
assert.equal(linkC.y2, 48 + 2 * 144)
assert.equal(linkC.y2, 48 + 2 * 168)
assert.equal(linkC.color, familyHue(2))
assert.equal(layout.links.find(l => l.to === 'a')?.color, familyHue(0))
assert.equal(layout.width, 300)
assert.equal(layout.height, 48 + 2 * 144 + 104 + 24)
assert.equal(layout.height, 48 + 2 * 168 + 104 + 24)
assert.equal(layout.cardW, 150 - 12)
})
@@ -786,7 +786,7 @@ describe('layoutForest', () => {
const pointOf = new Map(layout.points.map(p => [p.id, p]))
assert.equal(pointOf.get('a')?.x, slot / 2)
assert.equal(pointOf.get('c')?.x, slot / 2)
assert.equal(pointOf.get('d')?.y, 48 + 2 * 144)
assert.equal(pointOf.get('d')?.y, 48 + 2 * 168)
assert.equal(layout.width, 400)
assert.equal(layout.links.length, 6)
assert.equal(layout.cardW, slot - 12)
@@ -805,16 +805,16 @@ describe('layoutForest', () => {
]), 320)
const pointOf = new Map(layout.points.map(p => [p.id, p]))
assert.equal(pointOf.get('root')?.y, 48)
assert.equal(pointOf.get('p1')?.y, 48 + 144)
assert.equal(pointOf.get('k1')?.y, 48 + 2 * 144)
assert.equal(pointOf.get('p3')?.y, 48 + 3 * 144)
assert.equal(pointOf.get('p1')?.y, 48 + 168)
assert.equal(pointOf.get('k1')?.y, 48 + 2 * 168)
assert.equal(pointOf.get('p3')?.y, 48 + 3 * 168)
// Kinship interleave: p1's grandchildren sit between p1's band and p3's band.
assert.ok((pointOf.get('k1')?.y as number) < (pointOf.get('p3')?.y as number))
// Links keep their family hue through the interleave: p1's subtree is family 0, p3 is family 2.
assert.equal(layout.links.find(l => l.to === 'k1')?.color, familyHue(0))
assert.equal(layout.links.find(l => l.to === 'p3')?.color, familyHue(2))
assert.equal(layout.width, 320)
assert.equal(layout.height, 48 + 3 * 144 + 104 + 24)
assert.equal(layout.height, 48 + 3 * 168 + 104 + 24)
})
test('responsive: a partially filled band flushes before an overflowing sibling group', () => {
@@ -829,10 +829,10 @@ describe('layoutForest', () => {
['k2b', 'p2'],
]), 300)
const pointOf = new Map(layout.points.map(p => [p.id, p]))
assert.equal(pointOf.get('p1')?.y, 48 + 144)
assert.equal(pointOf.get('k1')?.y, 48 + 2 * 144)
assert.equal(pointOf.get('k2a')?.y, 48 + 3 * 144)
assert.equal(pointOf.get('k2b')?.y, 48 + 3 * 144)
assert.equal(pointOf.get('p1')?.y, 48 + 168)
assert.equal(pointOf.get('k1')?.y, 48 + 2 * 168)
assert.equal(pointOf.get('k2a')?.y, 48 + 3 * 168)
assert.equal(pointOf.get('k2b')?.y, 48 + 3 * 168)
assert.equal(layout.links.length, 5)
// k2a/k2b inherit p2's family hue (1), k1 inherits p1's (0).
assert.equal(layout.links.find(l => l.to === 'k2a')?.color, familyHue(1))
+70 -21
View File
@@ -354,11 +354,11 @@ describe('AgentGraph — the family tree', () => {
test('overflow chip when the family exceeds the cap', async () => {
const byId: Record<string, unknown> = { root: { displayTitle: 'Main', running: false, updatedAt: 1 } }
for (let i = 0; i < 30; i++) byId['kid' + i] = { parentId: 'root', updatedAt: i }
for (let i = 0; i < 45; i++) byId['kid' + i] = { parentId: 'root', updatedAt: i }
const View = makeView(new FakeSessions(byId))
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
assert.ok(text(m.container).includes('6 more not shown'))
assert.ok(text(m.container).includes('31 agents'))
assert.ok(text(m.container).includes('46 agents'))
await m.unmount()
})
@@ -503,39 +503,88 @@ describe('AgentGraph — the family tree', () => {
})
})
describe('AgentGraph — Fleet extras', () => {
describe('AgentGraph — the Fleet seat', () => {
const TEAM = {
members: [{ id: 'root', name: 'lead', role: 'lead' as const, phase: 'active' as const }],
tasks: [],
}
function extras(team: import('../../../src/client/fleetTeam').FleetTeam | null) {
return {
team,
pinnedId: 'worker',
onPin: () => {},
detailOf: () => ({ detail: null }),
commsOf: () => [],
labelOf: (id: string) => id,
roster: [],
onFocusTask: () => {},
}
}
test('the extras team surfaces the chip when the team prop is absent; an explicit null wins', async () => {
test('the team prop surfaces the chip while an explicit null wins; the pin rings its card', async () => {
const face = new FakeSessions(family())
const View = makeView(face)
// extras alone: the chip reads the extras team; the pinned card rings.
const m = await mount(h(View, { sessionId: 'root', self: selfStats(), extras: extras(TEAM) }))
// The Fleet tab's own derivation seat: the chip reads the passed team, the pinned card rings.
const m = await mount(h(View, { sessionId: 'root', self: selfStats(), team: TEAM, pinnedId: 'worker', inspector: false }))
assert.ok(text(m.container).includes('1 members'))
assert.ok(query(m.container, '[data-agent="worker"]').className.includes('lc-agent-pinned'))
assert.equal(queryAll(m.container, '.lc-inspector').length, 0, 'the Fleet seat paints the graph alone')
await m.unmount()
// extras with a null team hides the chip.
const m2 = await mount(h(View, { sessionId: 'root', self: selfStats(), extras: extras(null) }))
// An explicit null team hides the chip; the sidebar seat keeps its preview.
const m2 = await mount(h(View, { sessionId: 'root', self: selfStats(), team: null }))
assert.ok(!text(m2.container).includes('members ·'))
assert.ok(query(m2.container, '.lc-inspector') !== null)
await m2.unmount()
})
test('select mode: a card click highlights instead of jumping; the corner button keeps the jump', async () => {
const face = new FakeSessions(family())
const View = makeView(face)
const selected = new Array<string>()
const m = await mount(h(View, {
sessionId: 'root',
self: selfStats(),
team: TEAM,
inspector: false,
onSelect: (id: string) => { selected.push(id) },
}))
// The select-mode sub names the highlight; the open-mode copy stays with the sidebar.
assert.ok(text(m.container).includes(DICT_EN['agents.subSelect']))
// Every card is pickable in select mode — the current one included.
const self = query(m.container, '.lc-agent-card.lc-agent-self')
assert.equal(self.getAttribute('role'), 'button')
await click(self)
// The current card never carries the corner jump.
assert.equal(self.querySelector('.lc-agent-open'), null)
const worker = query(m.container, '[data-agent="worker"]')
await click(worker)
assert.deepEqual([...selected], ['root', 'worker'])
assert.deepEqual(face.opened, [], 'a pick never jumps')
// The corner button jumps without disturbing the selection.
await click(query(worker, '.lc-agent-open'))
assert.deepEqual(face.opened, ['worker'])
assert.deepEqual([...selected], ['root', 'worker'])
// Enter selects; an unrelated key is inert.
await act(async () => {
query(m.container, '[data-agent="done"]').dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
})
await act(async () => {
query(m.container, '[data-agent="done"]').dispatchEvent(
new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }),
)
})
assert.deepEqual([...selected], ['root', 'worker', 'done'])
assert.deepEqual(face.opened, ['worker'])
await m.unmount()
})
test('open mode (the sidebar seat) keeps the jump copy and the inert current card', async () => {
const face = new FakeSessions(family())
const View = makeView(face)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
assert.ok(text(m.container).includes(DICT_EN['agents.sub']))
assert.equal(query(m.container, '.lc-agent-card.lc-agent-self').getAttribute('role'), null)
assert.equal(queryAll(m.container, '.lc-agent-open').length, 0)
await click(query(m.container, '[data-agent="worker"]'))
assert.deepEqual(face.opened, ['worker'])
await m.unmount()
})
})
describe('AgentGraph — cold-relative composition fetch', () => {
-195
View File
@@ -1,195 +0,0 @@
// FleetList — the roster panel: text/status filtering, sorting, pin-on-click, hover-feed, and
// the per-row session jump.
import { createElement as h, act } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { FleetList, type FleetListProps } from '../../../src/client/components/fleetList'
import type { AgentForest, AgentNode } from '../../../src/client/agentTree'
import type { FleetTeam } from '../../../src/client/fleetTeam'
import { DICT_EN } from '../../../src/client/i18n'
import { click, hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
const kit = makeKit()
/** React's controlled input needs the native setter for the change to register. */
async function typeIn(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
/** A controlled select flips through the same native-setter idiom. */
async function selectIn(select: HTMLSelectElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(select, value)
select.dispatchEvent(new Event('change', { bubbles: true }))
})
}
function nodeOf(id: string, over: Partial<AgentNode> = {}): AgentNode {
return {
id, label: id, depth: 0, family: 0, isCurrent: false, running: false, completed: false,
subagent: true, updatedAt: 0, head: null, requests: 0, billed: null, costUsage: null,
durationMs: null, identity: null, ...over,
}
}
const TEAM: FleetTeam = {
members: [
{ id: 'root', name: 'lead', role: 'lead', phase: 'active' },
{ id: 'w1', name: 'worker-bee', role: 'teammate', phase: 'active' },
],
tasks: [],
}
function forestOf(nodes: AgentNode[]): AgentForest {
return { nodes, edges: [], overflow: 0, solo: false }
}
function familyForest(): AgentForest {
return forestOf([
nodeOf('root', { isCurrent: true, running: true, head: { tokens: 500, window: 1000, pct: 50, parts: [] }, requests: 3, durationMs: 60000, updatedAt: 10 }),
nodeOf('w1', { label: 'worker-bee', running: true, model: 'deepseek-flash', head: { tokens: 800, window: 1000, pct: 80, parts: [] }, requests: 5, durationMs: 42000, updatedAt: 8 }),
nodeOf('w2', { label: 'done-bot', completed: true, head: { tokens: 200, window: 1000, pct: 20, parts: [] }, requests: 1, durationMs: 9000, updatedAt: 6 }),
nodeOf('w3', { label: 'idle-one', updatedAt: 4 }),
])
}
function propsOf(over: Partial<FleetListProps> = {}): FleetListProps {
return {
forest: familyForest(),
team: null,
pinnedId: null,
onPin: () => {},
onHover: () => {},
onOpen: () => {},
t: kit.t,
fmt: kit.fmt,
...over,
}
}
function rowLabels(container: HTMLElement): string[] {
return queryAll(container, '.lc-fleet-row-label').map(el => el.textContent ?? '')
}
describe('FleetList — rows', () => {
test('every family agent lists with its meta, pct, and badges', async () => {
const m = await mount(h(FleetList, propsOf({ team: TEAM })))
assert.deepEqual(rowLabels(m.container), ['root', 'worker-bee', 'done-bot', 'idle-one'])
const root = query(m.container, '.lc-fleet-row-self')
assert.ok(text(root).includes('current'))
assert.ok(text(root).includes('500 · 1m00s · 3 steps'))
// The model rides the meta line when the row's timeline head serves one.
assert.ok(text(queryAll(m.container, '.lc-fleet-row')[1]).includes('deepseek-flash'))
assert.ok(text(root).includes('50%'))
// The current row never offers the open jump.
assert.equal(queryAll(root, '.lc-fleet-row-open').length, 0)
// Team badges: the member name for teammates, the role for the lead.
assert.ok(text(queryAll(m.container, '.lc-fleet-row')[1]).includes('worker-bee'))
assert.ok(text(root).includes('lead'))
// An idle node draws the placeholder dot, and a stat-less row no meta.
const idle = queryAll(m.container, '.lc-fleet-row')[3]
assert.equal(queryAll(idle, '.lc-fleet-dot-idle').length, 1)
assert.equal(query(idle, '.lc-fleet-row-meta').textContent, '')
await m.unmount()
})
test('a row click pins (and re-click unpins); the open button jumps without pinning', async () => {
const pins = new Array<string | null>()
const opened: string[] = []
const hovers: (string | null)[] = []
const m = await mount(h(FleetList, propsOf({
onPin: id => { pins.push(id) },
onOpen: id => { opened.push(id) },
onHover: id => { hovers.push(id) },
})))
const row = queryAll(m.container, '.lc-fleet-row')[1]
await hover(row)
await click(row)
await click(query(row, '.lc-fleet-row-open'))
await unhover(row)
assert.deepEqual([...pins], ['w1'])
assert.deepEqual(opened, ['w1'])
assert.deepEqual(hovers, ['w1', null])
// Pinned state rings the row; a second click releases it.
await m.unmount()
const m2 = await mount(h(FleetList, propsOf({ pinnedId: 'w1', onPin: (id: string | null) => { pins.push(id) } })))
const pinnedRow = queryAll(m2.container, '.lc-fleet-row')[1]
assert.ok(pinnedRow.className.includes('lc-fleet-row-pinned'))
// Keyboard on the pinned row releases the pin too.
pinnedRow.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
await click(pinnedRow)
assert.deepEqual([...pins], ['w1', null, null])
await m2.unmount()
})
test('keyboard: Enter and Space pin the focused row', async () => {
const pins = new Array<string | null>()
const m = await mount(h(FleetList, propsOf({ onPin: id => { pins.push(id) } })))
const row = queryAll(m.container, '.lc-fleet-row')[2]
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
row.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }))
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }))
assert.deepEqual([...pins], ['w2', 'w2'])
await m.unmount()
})
})
describe('FleetList — filtering and sorting', () => {
test('the text search matches labels and ids, case-insensitively', async () => {
const m = await mount(h(FleetList, propsOf()))
const search = query(m.container, '.lc-fleet-search') as HTMLInputElement
await typeIn(search, 'BOT')
assert.deepEqual(rowLabels(m.container), ['done-bot'])
await typeIn(search, 'zzz')
assert.ok(text(m.container).includes(DICT_EN['fleet.list.empty']))
await m.unmount()
})
test('the status chips filter with live counts', async () => {
const m = await mount(h(FleetList, propsOf()))
const chips = queryAll(m.container, '.lc-fleet-filters .lc-gran-btn')
assert.deepEqual(chips.map(c => text(c)), ['All4', 'Running2', 'Done1', 'Idle1'])
await click(chips[2])
assert.deepEqual(rowLabels(m.container), ['done-bot'])
await click(queryAll(m.container, '.lc-fleet-filters .lc-gran-btn')[3])
assert.deepEqual(rowLabels(m.container), ['idle-one'])
await click(queryAll(m.container, '.lc-fleet-filters .lc-gran-btn')[0])
assert.equal(rowLabels(m.container).length, 4)
await m.unmount()
})
test('each sort key reorders the rows', async () => {
const m = await mount(h(FleetList, propsOf()))
const select = query(m.container, '.lc-fleet-sort') as HTMLSelectElement
await selectIn(select, 'tokens')
assert.deepEqual(rowLabels(m.container), ['worker-bee', 'root', 'done-bot', 'idle-one'])
await selectIn(select, 'duration')
assert.deepEqual(rowLabels(m.container), ['root', 'worker-bee', 'done-bot', 'idle-one'])
await selectIn(select, 'steps')
assert.deepEqual(rowLabels(m.container), ['worker-bee', 'root', 'done-bot', 'idle-one'])
await selectIn(select, 'recent')
assert.deepEqual(rowLabels(m.container), ['root', 'worker-bee', 'done-bot', 'idle-one'])
await selectIn(select, 'spawn')
assert.deepEqual(rowLabels(m.container), ['root', 'worker-bee', 'done-bot', 'idle-one'])
await m.unmount()
})
test('sorting a bare pair covers the null arms of the comparators', async () => {
const bare = forestOf([
nodeOf('a', { head: null, durationMs: null, requests: 0, updatedAt: 1 }),
nodeOf('b', { head: { tokens: 10, window: 100, pct: 10, parts: [] }, durationMs: 5000, requests: 2, updatedAt: 2 }),
])
const m = await mount(h(FleetList, propsOf({ forest: bare })))
const select = query(m.container, '.lc-fleet-sort') as HTMLSelectElement
await selectIn(select, 'tokens')
assert.deepEqual(rowLabels(m.container), ['b', 'a'])
await selectIn(select, 'duration')
assert.deepEqual(rowLabels(m.container), ['b', 'a'])
await m.unmount()
})
})
@@ -0,0 +1,125 @@
// FleetSummary — the Fleet tab's overview strip: family-wide stat chips and the progress bar whose
// semantics follow the session shape (shared tasks, else subagent completion, else nothing).
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { FleetSummary, type FleetSummaryProps } from '../../../src/client/components/fleetSummary'
import type { AgentForest, AgentNode } from '../../../src/client/agentTree'
import type { FleetTeam } from '../../../src/client/fleetTeam'
import { pricesBookOf } from '../../../src/client/modelPrices'
import { DICT_EN } from '../../../src/client/i18n'
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
function nodeOf(id: string, over: Partial<AgentNode> = {}): AgentNode {
return {
id, label: id, depth: 0, family: 0, isCurrent: false, running: false, completed: false,
subagent: true, updatedAt: 0, head: null, requests: 0, billed: null, costUsage: null,
durationMs: null, identity: null, ...over,
}
}
const FOREST: AgentForest = {
nodes: [
nodeOf('root', { isCurrent: true, running: true, head: { tokens: 500, window: 1000, pct: 50, parts: [] } }),
nodeOf('w1', { running: true, head: { tokens: 300, window: 1000, pct: 30, parts: [] } }),
nodeOf('w2', { completed: true, head: { tokens: 200, window: 1000, pct: 20, parts: [] } }),
nodeOf('w3', { completed: true }),
],
edges: [],
overflow: 0,
solo: false,
}
function teamOf(tasks: FleetTeam['tasks']): FleetTeam {
return {
members: [{ id: 'root', name: 'lead', role: 'lead', phase: 'active' }],
tasks,
}
}
function taskOf(id: string, status: 'pending' | 'in_progress' | 'completed'): FleetTeam['tasks'][number] {
return { id, subject: id, description: '', status, blockedBy: [], writeScopes: [], ready: false, warnings: [] }
}
function propsOf(over: Partial<FleetSummaryProps> = {}): FleetSummaryProps {
return {
forest: FOREST,
team: null,
book: null,
currency: 'usd',
t: kit.t,
fmt: kit.fmt,
...over,
}
}
describe('FleetSummary', () => {
test('stat chips count the family; the subagent progress bar fills without a team', async () => {
const m = await mount(h(FleetSummary, propsOf()))
const rendered = text(m.container)
assert.ok(rendered.includes('4 agents'))
assert.ok(rendered.includes('2 running'))
assert.ok(rendered.includes('2 done'))
assert.ok(rendered.includes('1.0k tokens in context'))
const progress = query(m.container, '.lc-summary-progress')
assert.ok(text(progress).includes(DICT_EN['fleet.summary.subs']))
assert.ok(text(progress).includes('2/3'))
assert.equal(query(progress, '.lc-tasks-progress-track i')?.style.width, '67%')
await m.unmount()
})
test('shared tasks win the progress bar when the team serves any', async () => {
const team = teamOf([taskOf('task-1', 'completed'), taskOf('task-2', 'in_progress'), taskOf('task-3', 'pending')])
const m = await mount(h(FleetSummary, propsOf({ team })))
const progress = query(m.container, '.lc-summary-progress')
assert.ok(text(progress).includes(DICT_EN['fleet.summary.tasks']))
assert.ok(text(progress).includes('1/3'))
await m.unmount()
})
test('a solo session lists no progress bar and no done chip', async () => {
const solo: AgentForest = {
nodes: [nodeOf('root', { isCurrent: true, running: true })],
edges: [],
overflow: 0,
solo: true,
}
const m = await mount(h(FleetSummary, propsOf({ forest: solo })))
assert.equal(queryAll(m.container, '.lc-summary-progress').length, 0)
assert.ok(!text(m.container).includes(DICT_EN['fleet.summary.done'].replace('{n}', '')))
assert.equal(queryAll(m.container, '.lc-agents-chip').length, 2, 'count + running only — no tokens without heads')
await m.unmount()
})
test('the cost chip appears once a node prices against the book', async () => {
const book = pricesBookOf({
deepseek: { models: { 'deepseek-v4-flash': { cost: { input: 0.15, output: 0.6, cache_read: 0.003 } } } },
})
assert.ok(book !== null)
const ledger = { 'deepseek-official': { 'deepseek-v4-flash': { off: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 500_000 } } } }
const priced: AgentForest = {
...FOREST,
nodes: [...FOREST.nodes.slice(0, 1), nodeOf('w1', { running: true, costUsage: ledger })],
}
const m = await mount(h(FleetSummary, propsOf({ forest: priced, book })))
assert.ok(text(m.container).includes('est.'), 'the cost chip renders')
assert.ok(text(m.container).includes('$0.45'))
await m.unmount()
// A book without the model prices nothing — the chip stays out.
const bare = pricesBookOf({ other: { models: { 'x': { cost: { input: 1, output: 1, cache_read: 1 } } } } })
const m2 = await mount(h(FleetSummary, propsOf({ forest: priced, book: bare })))
assert.ok(!text(m2.container).includes('est.'))
await m2.unmount()
})
test('zh chrome and the overflow count ride along', async () => {
const zh = makeKit('zh')
const m = await mount(h(FleetSummary, propsOf({ forest: { ...FOREST, overflow: 2 }, t: zh.t, fmt: zh.fmt })))
assert.ok(text(m.container).includes('6 个 Agent'))
await m.unmount()
})
})
+203 -73
View File
@@ -1,13 +1,13 @@
// FleetView integration — the four panels over one family: the team projection (list-row and
// direct seats), pin/hover steering between the list and the inspector, and the comms scan off
// the stubbed fleet route.
// FleetView integration — the panels over one family: the overview strip, the team projection
// (list-row and direct seats), pin/hover/select steering across the graph and the boards, and the
// comms scan off the stubbed fleet route.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { makeFleetView } from '../../../src/client/components/fleetView'
import { makeAgentHeads } from '../../../src/client/agentHeads'
import { DICT_ZH } from '../../../src/client/i18n'
import { DICT_EN, DICT_ZH } from '../../../src/client/i18n'
import { resetTimelineDetailStores } from '../../../src/client/timelineSource'
import { TestClientCtx, asClientCtx } from '../helpers/harness'
import { click, hover, makeKit, mount, query, queryAll, text, until } from '../helpers/kit'
@@ -42,6 +42,11 @@ function familySessions() {
parentId: 'root', origin: 'subagent', running: true, updatedAt: 8,
projectionValues: { subagent: { mode: 'continuable', label: 'worker-bee' }, subagentTiming: { settledMs: 42000 } },
},
// A plain subagent outside the team roster: the members board's other-agents section.
w2: {
parentId: 'root', origin: 'subagent', running: false, updatedAt: 6,
projectionValues: { subagent: { mode: 'one-shot', label: 'side-quest' }, subagentTiming: { settledMs: 9000, lastTurnCompleted: true } },
},
},
}
return {
@@ -78,8 +83,8 @@ function makeView(ctx: TestClientCtx, useKit = kit) {
return makeFleetView(asClientCtx(ctx), useKit, makeAgentHeads())
}
describe('FleetView — the four panels over one family', () => {
test('the roster row open button jumps to the session', async () => {
describe('FleetView — the panels over one family', () => {
test('the other-agents row open button jumps to the session', async () => {
stubFleetRoute()
const opened = new Array<string>()
const ctx = new TestClientCtx({
@@ -87,62 +92,121 @@ describe('FleetView — the four panels over one family', () => {
})
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
await click(query(queryAll(m.container, '.lc-fleet-row')[1], '.lc-fleet-row-open'))
await click(query(query(m.container, '.lc-fleet-row'), '.lc-fleet-row-open'))
assert.deepEqual([...opened], ['w2'])
await m.unmount()
ctx.dispose()
})
test('overview strip, team chip, members board, task board, and comms all render off the one derivation', async () => {
stubFleetRoute()
const ctx = new TestClientCtx({ services: { sessions: familySessions() } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
// The overview strip: family stats plus the shared-tasks progress bar.
const summary = query(m.container, '.lc-summary')
assert.ok(text(summary).includes('3 agents'))
assert.ok(text(summary).includes('2 running'))
assert.ok(text(summary).includes(DICT_EN['fleet.summary.tasks']))
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).
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.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'))
// The comms panel opens collapsed.
assert.ok(text(query(m.container, '.lc-comms')).includes('Scan 3 sessions'))
// The network card stands alone: the boards beside it carry the agent detail.
assert.equal(queryAll(m.container, '.lc-inspector').length, 0)
await m.unmount()
ctx.dispose()
})
test('hovering a member card lights its graph card; pinning sticks and rings both', async () => {
stubFleetRoute()
const ctx = new TestClientCtx({ services: { sessions: familySessions() } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
const card = queryAll(m.container, '.lc-member-card')[1]
await hover(card)
assert.ok(query(m.container, '[data-agent="w1"]').className.includes('lc-agent-hover'))
await click(card)
// The pin survives the hover leaving, and the graph card rings.
await hover(queryAll(m.container, '.lc-member-card')[0])
const node = query(m.container, '[data-agent="w1"]')
assert.ok(node.className.includes('lc-agent-pinned'))
assert.ok(!node.className.includes('lc-agent-hover'))
assert.ok(query(m.container, '.lc-member-pinned') !== null, 'the member card echoes the pin')
// The member's landed detail fills its card's model, and its owned task its count.
await until(() => text(query(m.container, '.lc-members')).includes('deepseek-v4'), 'the descriptor never landed')
assert.ok(text(query(m.container, '.lc-members')).includes('1 active tasks'))
await m.unmount()
ctx.dispose()
})
test('a graph-node pick rings the card and narrows the task board to its tasks', async () => {
stubFleetRoute()
const opened = new Array<string>()
const ctx = new TestClientCtx({
services: { sessions: familySessions(), uiWorkspace: { openSession: (id: string) => { opened.push(id) } } },
})
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
// Picking a member node: the ring lands and the owner filter narrows the task board.
await click(query(m.container, '[data-agent="w1"]'))
assert.ok(query(m.container, '[data-agent="w1"]').className.includes('lc-agent-pinned'))
assert.ok(text(query(m.container, '.lc-tasks-filter')).includes('worker-bee'))
assert.deepEqual([...opened], [], 'a pick never jumps')
// Picking a non-member node moves the ring and releases the owner filter.
await click(query(m.container, '[data-agent="w2"]'))
assert.ok(query(m.container, '[data-agent="w2"]').className.includes('lc-agent-pinned'))
assert.equal(queryAll(m.container, '.lc-tasks-filter').length, 0)
// Picking the pinned node again releases both.
await click(query(m.container, '[data-agent="w2"]'))
assert.equal(queryAll(m.container, '.lc-agent-pinned').length, 0)
// Unpinning a member releases its owner filter when it still points at them…
await click(query(m.container, '[data-agent="w1"]'))
assert.ok(text(query(m.container, '.lc-tasks-filter')).includes('worker-bee'))
await click(query(m.container, '[data-agent="w1"]'))
assert.equal(queryAll(m.container, '.lc-tasks-filter').length, 0)
// …but a filter the reader moved on from is left alone.
await click(query(m.container, '[data-agent="w1"]'))
await click(query(m.container, '.lc-tasks-filter'))
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()
})
test('team chip, team panel, roster list, and comms panel all render off the one derivation', async () => {
test('a task-board owner pick toggles the pin on the graph and the member card', async () => {
stubFleetRoute()
const ctx = new TestClientCtx({ services: { sessions: familySessions() } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
// The network card header carries the team chip.
assert.ok(text(query(m.container, '.lc-agents')).includes('team · 3 members · 1 tasks'))
// The team panel: roster + task board.
const teamCard = query(m.container, '.lc-members')
assert.ok(text(teamCard).includes('worker-bee'))
assert.ok(text(query(m.container, '.lc-tasks')).includes('schema parsing'))
// The roster list.
assert.deepEqual(queryAll(m.container, '.lc-fleet-row').length, 2)
// The comms panel opens collapsed.
assert.ok(text(query(m.container, '.lc-comms')).includes('Scan 2 sessions'))
// The inspector follows the current session and lands its fleet detail.
await until(() => text(query(m.container, '.lc-inspector')).includes('Main Agent'), 'inspector never followed')
await m.unmount()
ctx.dispose()
})
test('hovering a list row steers the inspector; pinning sticks and rings the graph card', async () => {
stubFleetRoute()
const ctx = new TestClientCtx({ services: { sessions: familySessions() } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
const row = queryAll(m.container, '.lc-fleet-row')[1]
await hover(row)
assert.ok(text(query(m.container, '.lc-inspector')).includes('worker-bee'))
await click(row)
// The pin survives the hover leaving, and the graph card rings.
await hover(queryAll(m.container, '.lc-fleet-row')[0])
const inspector = query(m.container, '.lc-inspector')
assert.ok(text(inspector).includes('worker-bee'))
assert.ok(inspector.hasAttribute('data-pinned'))
await click(query(m.container, '.lc-team-task-owner'))
assert.ok(query(m.container, '[data-agent="w1"]').className.includes('lc-agent-pinned'))
// The landed detail fills the prompt and descriptor sections.
await until(() => text(query(m.container, '.lc-inspector')).includes('Review the schema parser.'), 'prompt never landed')
assert.ok(text(query(m.container, '.lc-inspector')).includes('spawn · deepseek-v4'))
// Team membership section with the owned task.
assert.ok(text(query(m.container, '.lc-inspector')).includes('schema parsing'))
// Unpin returns the inspector to the hover/current follow.
await click(query(m.container, '.lc-inspector-btn'))
assert.ok(!query(m.container, '.lc-inspector').hasAttribute('data-pinned'))
assert.ok(query(m.container, '.lc-member-pinned') !== null)
// A second pick on the same owner releases the pin.
await click(query(m.container, '.lc-team-task-owner'))
assert.equal(queryAll(m.container, '.lc-agent-pinned').length, 0)
await m.unmount()
ctx.dispose()
})
@@ -163,15 +227,43 @@ describe('FleetView — the four panels over one family', () => {
assert.ok(text(rows[1]).includes('ghost-nine'))
assert.ok(text(rows[2]).includes('stranger'))
// A comms peer click pins the sender into the inspector.
// A comms peer click pins the sender, ringing its graph card and its member card.
await click(queryAll(rows[0], '.lc-comms-peer')[0])
const inspector = query(m.container, '.lc-inspector')
assert.ok(text(inspector).includes('Main Agent'))
assert.ok(inspector.hasAttribute('data-pinned'))
assert.ok(query(m.container, '[data-agent="root"]').className.includes('lc-agent-pinned'))
assert.ok(query(m.container, '.lc-member-pinned') !== null)
// Pinning a sender outside the family releases immediately instead of haunting the inspector.
// Pinning a sender outside the family releases instead of ringing a member that has no graph card.
await click(queryAll(rows[1], '.lc-comms-peer')[0])
await until(() => !query(m.container, '.lc-inspector').hasAttribute('data-pinned'), 'the ghost pin never released')
await until(() => queryAll(m.container, '.lc-agent-pinned, .lc-member-pinned').length === 0, 'the ghost pin never released')
await m.unmount()
ctx.dispose()
})
test('a fleet-detail read that serves nothing leaves the boards standing', async () => {
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 503 } as Response))
// Fresh session ids: the page-scope read ledger carries no settled line for them.
const team = {
members: [
{ id: 'cold-root', name: 'cold-lead', role: 'lead', phase: 'active' },
{ id: 'cold-w1', name: 'cold-worker', role: 'teammate', phase: 'active' },
],
tasks: [],
}
const snapshot = {
byId: {
'cold-root': { displayTitle: 'Cold Lead', running: false, updatedAt: 3, projectionValues: { agentTeam: team } },
'cold-w1': { parentId: 'cold-root', origin: 'subagent', running: false, updatedAt: 2, projectionValues: { subagent: { mode: 'one-shot', label: 'cold-worker' } } },
},
}
const ctx = new TestClientCtx({ services: { sessions: { list: { getSnapshot: () => snapshot, subscribe: () => () => {} } } } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'cold-root', useProjection: projectionsFor(richTimeline()) }))
// The member cards stand on the projection's own roster, with no reply to join.
assert.ok(text(query(m.container, '.lc-members')).includes('cold-worker'))
// Every read lands as a null detail: the scan settles empty instead of crashing or spinning.
await click(query(m.container, '.lc-comms-scan'))
await until(() => text(query(m.container, '.lc-comms')).includes(DICT_EN['fleet.comms.empty']), 'the scan never settled')
await m.unmount()
ctx.dispose()
})
@@ -193,18 +285,20 @@ describe('FleetView — the four panels over one family', () => {
}))
assert.ok(query(m.container, '.lc-members') !== null)
assert.ok(query(m.container, '.lc-tasks') !== null)
// The duo row: both boards share the one `.lc-cols` wrapper — members left, tasks right.
const duo = query(m.container, '.lc-cols')
assert.ok(duo !== null)
assert.ok(duo.contains(query(m.container, '.lc-members')))
assert.ok(duo.contains(query(m.container, '.lc-tasks')))
// The lead badge lands on the roster row too.
assert.ok(text(query(m.container, '.lc-fleet-row-self')).includes('lead'))
// The cockpit row: the network on the left, the members board over the task board on the right.
const columns = queryAll(query(m.container, '.lc-cols'), '.lc-col')
assert.equal(columns.length, 2)
assert.ok(columns[0].contains(query(m.container, '.lc-agents')), 'the network owns the left column')
assert.ok(columns[1].contains(query(m.container, '.lc-members')), 'the members board heads the right column')
assert.ok(columns[1].contains(query(m.container, '.lc-tasks')), 'the task board sits under it')
// The lead badge lands on the member card.
const leadCard = queryAll(m.container, '.lc-member-card')[0]
assert.ok(text(leadCard).includes(DICT_EN['fleet.role.lead']))
await m.unmount()
ctx.dispose()
})
test('a team without a family forest serves the task board alone in the duo row', async () => {
test('a team without a family forest serves the task board alone in the cockpit row', async () => {
stubFleetRoute()
// No sessions service: the forest derivation stays null, so the members board has no roster
// to join — the viewed session's own projection still carries the team.
@@ -214,27 +308,62 @@ describe('FleetView — the four panels over one family', () => {
sessionId: 'root',
useProjection: projectionsFor(richTimeline(), { agentTeam: TEAM_PROJECTION }),
}))
const duo = query(m.container, '.lc-cols')
assert.ok(duo !== null)
const row = query(m.container, '.lc-cols')
assert.ok(row !== null)
assert.equal(queryAll(m.container, '.lc-agents').length, 0)
assert.equal(queryAll(m.container, '.lc-members').length, 0)
assert.equal(queryAll(m.container, '.lc-summary').length, 0, 'no strip without a family')
const tasks = query(m.container, '.lc-tasks')
assert.ok(tasks !== null)
assert.ok(duo.contains(tasks), 'the task board sits in the duo row')
assert.ok(row.contains(tasks), 'the task board sits alone in the row')
await m.unmount()
ctx.dispose()
})
test('a solo session keeps the graph alone — no list, comms, or team panels', async () => {
test('a solo session keeps the boards up with guidance — the graph, the strip, and pointers', async () => {
stubFleetRoute()
const snapshot = { byId: { only: { displayTitle: 'Solo', running: false, updatedAt: 1 } } }
const ctx = new TestClientCtx({ services: { sessions: { list: { getSnapshot: () => snapshot, subscribe: () => () => {} } } } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'only', useProjection: projectionsFor(richTimeline()) }))
assert.ok(query(m.container, '.lc-agents') !== null)
assert.equal(queryAll(m.container, '.lc-fleet-list').length, 0)
// The strip paints the stats but no progress bar.
assert.ok(query(m.container, '.lc-summary') !== null)
assert.equal(queryAll(m.container, '.lc-summary-progress').length, 0)
// The boards stay up with guidance instead of vanishing.
assert.ok(text(query(m.container, '.lc-members')).includes(DICT_EN['fleet.board.solo']))
assert.ok(text(query(m.container, '.lc-tasks')).includes(DICT_EN['fleet.tasks.noTeam']))
assert.equal(queryAll(m.container, '.lc-comms').length, 0)
assert.equal(queryAll(m.container, '.lc-members').length, 0)
assert.equal(queryAll(m.container, '.lc-tasks').length, 0)
await m.unmount()
ctx.dispose()
})
test('a plain subagents session: the subagent progress bar and the no-team pointer', async () => {
stubFleetRoute()
const snapshot = {
byId: {
root: { displayTitle: 'Main Agent', running: true, updatedAt: 10 },
w1: {
parentId: 'root', origin: 'subagent', running: false, updatedAt: 8,
projectionValues: { subagent: { mode: 'one-shot', label: 'worker-bee' }, subagentTiming: { settledMs: 42000, lastTurnCompleted: true } },
},
w2: {
parentId: 'root', origin: 'subagent', running: true, updatedAt: 6,
projectionValues: { subagent: { mode: 'continuable', label: 'side-quest' } },
},
},
}
const ctx = new TestClientCtx({ services: { sessions: { list: { getSnapshot: () => snapshot, subscribe: () => () => {} } } } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
// The strip's progress counts subagent completion (one of two settled).
const progress = query(m.container, '.lc-summary-progress')
assert.ok(text(progress).includes(DICT_EN['fleet.summary.subs']))
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.ok(text(query(m.container, '.lc-tasks')).includes(DICT_EN['fleet.tasks.noTeam']))
await m.unmount()
ctx.dispose()
})
@@ -244,9 +373,10 @@ describe('FleetView — the four panels over one family', () => {
const ctx = new TestClientCtx({ locale: 'zh', services: { sessions: familySessions() } })
const View = makeFleetView(asClientCtx(ctx), makeKit('zh'), makeAgentHeads())
const m = await mount(h(View, { sessionId: 'root', useProjection: projectionsFor(richTimeline()) }))
assert.ok(text(m.container).includes(DICT_ZH['fleet.board.title']))
assert.ok(text(m.container).includes(DICT_ZH['fleet.members.title']))
assert.ok(text(m.container).includes(DICT_ZH['fleet.list.title']))
assert.ok(text(m.container).includes(DICT_ZH['fleet.comms.title']))
assert.ok(text(m.container).includes(DICT_ZH['fleet.summary.tasks']))
await m.unmount()
ctx.dispose()
})
+160 -3
View File
@@ -1,5 +1,6 @@
// MemberBoard — the team roster as full cards: identity, status, model, duty description, latest
// reply, and the owner-filter interlock with the task board.
// 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.
import { act, createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
@@ -13,6 +14,22 @@ import { click, hover, makeKit, mount, query, queryAll, text, unhover } from '..
const kit = makeKit()
/** React's controlled input needs the native setter for the change to register. */
async function typeIn(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
/** A controlled select flips through the same native-setter idiom. */
async function selectIn(select: HTMLSelectElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(select, value)
select.dispatchEvent(new Event('change', { bubbles: true }))
})
}
function nodeOf(id: string, over: Partial<AgentNode> = {}): AgentNode {
return {
id, label: id, depth: 0, family: 0, isCurrent: false, running: false, completed: false,
@@ -26,6 +43,9 @@ const FOREST: AgentForest = {
nodeOf('root', { isCurrent: true, running: true, model: 'deepseek-v4' }),
nodeOf('w1', { running: true }),
nodeOf('w2'),
nodeOf('s1', { label: 'scout', running: true, model: 'deepseek-flash', head: { tokens: 800, window: 1000, pct: 80, parts: [] }, requests: 5, durationMs: 42000, updatedAt: 8 }),
nodeOf('s2', { label: 'archivist', completed: true, head: { tokens: 200, window: 1000, pct: 20, parts: [] }, requests: 1, durationMs: 9000, updatedAt: 6 }),
nodeOf('s3', { label: 'lounger', updatedAt: 4 }),
],
edges: [],
overflow: 0,
@@ -69,11 +89,12 @@ function propsOf(over: Partial<MemberBoardProps> = {}): MemberBoardProps {
ownerFilter: null,
onOwnerFilter: () => {},
t: kit.t,
fmt: kit.fmt,
...over,
}
}
describe('MemberBoard', () => {
describe('MemberBoard — team cards', () => {
test('cards render identity, status, duty, model, reply, and task counts', async () => {
const details = new Map<string, { detail: FleetDetail | null }>([
['w1', { detail: { lastReply: { time: 100, text: 'schema parser landed' }, messages: [], truncated: false } }],
@@ -208,3 +229,139 @@ describe('MemberBoard', () => {
await m.unmount()
})
})
describe('MemberBoard — the other-agents rows', () => {
function rowLabels(container: HTMLElement): string[] {
return queryAll(container, '.lc-fleet-row-label').map(el => el.textContent ?? '')
}
test('non-member family agents list as compact rows with meta, pct, and the open jump', async () => {
const pins = new Array<string | null>()
const opened: string[] = []
const hovers: (string | null)[] = []
const m = await mount(h(MemberBoard, propsOf({
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.
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')
await hover(rows[0])
await click(rows[0])
await unhover(rows[0])
assert.deepEqual([...pins], ['s1'])
assert.deepEqual(hovers, ['s1', null])
await click(query(rows[0], '.lc-fleet-row-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 })) })
assert.deepEqual([...pins], ['s1', 's1'])
await m.unmount()
// The pinned row 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 })) })
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.
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'])
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')
await click(filters[3])
assert.deepEqual(rowLabels(m.container), ['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'])
// (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')
await selectIn(query(m2.container, '.lc-fleet-sort'), 'tokens')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
await selectIn(query(m2.container, '.lc-fleet-sort'), 'duration')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
await selectIn(query(m2.container, '.lc-fleet-sort'), 'steps')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
await selectIn(query(m2.container, '.lc-fleet-sort'), 'recent')
assert.deepEqual(rowLabels(m2.container), ['scout', 'archivist', 'lounger'])
await m2.unmount()
})
test('search narrows both sections; a dead end names it', async () => {
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']))
await m.unmount()
})
})
describe('MemberBoard — guidance states', () => {
test('no team: the note names the mode and the family rows 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".
assert.deepEqual(
queryAll(m.container, '.lc-fleet-row-label').map(el => el.textContent),
['w1', 'w2', 'scout', 'archivist', 'lounger'],
)
await m.unmount()
})
test('solo without a team: the panel points at the subagent tool and skips the toolbar', async () => {
const solo: AgentForest = { nodes: [nodeOf('root', { isCurrent: true })], edges: [], overflow: 0, solo: true }
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)
await m.unmount()
})
test('a one-member team over a solo forest skips the toolbar too', async () => {
const solo: AgentForest = { nodes: [nodeOf('root', { isCurrent: true })], edges: [], overflow: 0, solo: true }
const team: FleetTeam = { members: [{ id: 'root', name: 'lead', role: 'lead', phase: 'active' }], tasks: [] }
const m = await mount(h(MemberBoard, propsOf({ team, forest: solo })))
assert.equal(queryAll(m.container, '.lc-member-card').length, 1, 'the lead card stands')
assert.equal(queryAll(m.container, '.lc-fleet-toolbar').length, 0)
await m.unmount()
})
test('a team whose members all filtered out hides the section but keeps the rows', 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)
await m.unmount()
})
})
+124 -13
View File
@@ -1,5 +1,6 @@
// TaskBoard — the shared task DAG as a status-grouped list: readiness ordering, inline dependency
// chips with the blocker's own dot, owner pinning, owner filtering, and the focus flash.
// TaskBoard — the shared task DAG: readiness ordering, inline dependency chips with the blocker's
// own dot and its whole prerequisite chain on focus, owner pinning/hovering, owner filtering, the
// completion bar, the status/owner view switch, and the no-team pointer.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
@@ -7,7 +8,7 @@ import { describe, test } from 'vitest'
import { TaskBoard, type TaskBoardProps } from '../../../src/client/components/taskBoard'
import type { FleetTeam } from '../../../src/client/fleetTeam'
import { DICT_EN } from '../../../src/client/i18n'
import { click, makeKit, mount, query, queryAll, text } from '../helpers/kit'
import { click, hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
const kit = makeKit()
@@ -18,9 +19,9 @@ function teamOf(over: Partial<FleetTeam> = {}): FleetTeam {
{ id: 'w1', name: 'worker-bee', role: 'teammate', phase: 'active' },
],
tasks: [
{ id: 'task-1', subject: 'schema parsing', description: 'Parse the spec into the internal shape.', status: 'completed', ownerName: 'worker-bee', blockedBy: [], writeScopes: ['src/api/'], ready: false, warnings: [] },
{ id: 'task-1', subject: 'schema parsing', description: 'Parse the spec into the internal shape.', status: 'completed', ownerName: 'worker-bee', blockedBy: [], writeScopes: ['src/'], ready: false, warnings: [] },
{ id: 'task-2', subject: 'wire the view', description: '', status: 'in_progress', blockedBy: ['task-1'], writeScopes: [], ready: false, warnings: ['overlapping scope'] },
{ id: 'task-3', subject: 'polish', description: '', status: 'pending', blockedBy: ['task-2'], writeScopes: [], ready: false, warnings: [] },
{ id: 'task-3', subject: 'polish', description: '', status: 'pending', blockedBy: ['task-2', 'task-1'], writeScopes: [], ready: false, warnings: [] },
{ id: 'task-4', subject: 'docs', description: '', status: 'pending', ownerName: 'ghost', blockedBy: [], writeScopes: [], ready: true, warnings: [] },
// A dependency on a task the board does not serve (deleted/hostile) renders the bare chip.
{ id: 'task-5', subject: 'orphan dep', description: '', status: 'pending', blockedBy: ['task-99'], writeScopes: [], ready: false, warnings: [] },
@@ -37,6 +38,7 @@ function propsOf(over: Partial<TaskBoardProps> = {}): TaskBoardProps {
focusTaskId: null,
onFocusTask: () => {},
onPinMember: () => {},
onHoverMember: () => {},
t: kit.t,
...over,
}
@@ -60,11 +62,28 @@ describe('TaskBoard', () => {
// Status badges, scopes, warning, id.
assert.ok(text(m.container).includes('ready'))
assert.ok(text(m.container).includes('blocked'))
assert.ok(text(m.container).includes('Write scopes: src/api/'))
assert.ok(text(m.container).includes('Write scopes: src/'))
assert.ok(text(m.container).includes('overlapping scope'))
await m.unmount()
})
test('the head carries the completion bar and the view switch', async () => {
const m = await mount(h(TaskBoard, propsOf()))
const progress = query(m.container, '.lc-tasks-progress')
assert.ok(text(progress).includes('1/5 done'))
assert.ok(text(progress).includes('1 in progress'))
assert.equal(query(progress, '.lc-tasks-progress-track i')?.style.width, '20%')
await m.unmount()
// No in-flight work: the active count drops out of the text.
const calm = teamOf()
calm.tasks[1] = { ...calm.tasks[1], status: 'completed' }
const m2 = await mount(h(TaskBoard, propsOf({ team: calm })))
assert.ok(text(query(m2.container, '.lc-tasks-progress')).includes('2/5 done'))
assert.ok(!text(query(m2.container, '.lc-tasks-progress')).includes('in progress'))
await m2.unmount()
})
test('the owner chip pins the member; an owner off the roster is inert', async () => {
const pins = new Array<string | null>()
const m = await mount(h(TaskBoard, propsOf({ onPinMember: id => { pins.push(id) } })))
@@ -75,6 +94,38 @@ describe('TaskBoard', () => {
await m.unmount()
})
test('a row pick pins the owner and a row hover lights them; the row’s own buttons stay theirs', async () => {
const pins = new Array<string | null>()
const hovers = new Array<string | null>()
const focuses = new Array<string | null>()
const m = await mount(h(TaskBoard, propsOf({
onPinMember: id => { pins.push(id) },
onHoverMember: id => { hovers.push(id) },
onFocusTask: id => { focuses.push(id) },
})))
const row = query(m.container, '[data-task="task-1"]')
await hover(row)
await click(row)
await unhover(row)
assert.deepEqual([...pins], ['w1'])
assert.deepEqual([...hovers], ['w1', null])
// A pick landing on a dep chip flashes the blocker instead of pinning the owner.
const blocked = query(m.container, '[data-task="task-2"]')
await click(query(blocked, '.lc-team-task-dep'))
assert.deepEqual([...pins], ['w1'], 'the dep chip never pins')
assert.deepEqual([...focuses], ['task-1'])
// An unowned task pins and hovers nothing.
const ghost = query(m.container, '[data-task="task-4"]')
await hover(ghost)
await click(ghost)
await unhover(ghost)
assert.deepEqual([...pins], ['w1'])
assert.deepEqual([...hovers], ['w1', null, null, null], 'an off-roster owner hovers null')
await m.unmount()
})
test('the unowned badge stands in when a task has no owner', async () => {
const bare = teamOf()
bare.tasks[1] = { ...bare.tasks[1], ownerName: undefined } as never
@@ -83,7 +134,7 @@ describe('TaskBoard', () => {
await m.unmount()
})
test('a blocker chip flashes its row and toggles off; the focused row scrolls into view', async () => {
test('a blocker chip flashes its row and lights its whole prerequisite chain; a re-click releases', async () => {
const focuses = new Array<string | null>()
const m = await mount(h(TaskBoard, propsOf({ onFocusTask: id => { focuses.push(id) } })))
const dep = queryAll(m.container, '.lc-team-task-dep')[0]
@@ -91,12 +142,60 @@ describe('TaskBoard', () => {
assert.deepEqual([...focuses], ['task-1'])
await m.unmount()
// Focused on task-2: clicking task-3's blocker chip (task-2) toggles the ring off.
const focused = await mount(h(TaskBoard, propsOf({ focusTaskId: 'task-2', onFocusTask: id => { focuses.push(id) } })))
assert.ok(query(focused.container, '[data-task="task-2"]').className.includes('lc-team-task-focus'))
await click(query(focused.container, '[data-task="task-3"] .lc-team-task-dep'))
assert.deepEqual([...focuses].slice(-1), [null])
// Focused on task-3: its chain (task-2, then task-1 through it) lights along.
const focused = await mount(h(TaskBoard, propsOf({ focusTaskId: 'task-3', onFocusTask: id => { focuses.push(id) } })))
assert.ok(query(focused.container, '[data-task="task-3"]').className.includes('lc-team-task-focus'))
assert.ok(query(focused.container, '[data-task="task-2"]').className.includes('lc-team-task-chain'))
assert.ok(query(focused.container, '[data-task="task-1"]').className.includes('lc-team-task-chain'))
assert.ok(!query(focused.container, '[data-task="task-4"]').className.includes('lc-team-task-chain'))
// The unknown blocker (task-99) has no row to light.
await focused.unmount()
// Focused on task-2: clicking task-3's blocker chip (task-2) toggles the ring off.
const toggle = await mount(h(TaskBoard, propsOf({ focusTaskId: 'task-2', onFocusTask: id => { focuses.push(id) } })))
assert.ok(query(toggle.container, '[data-task="task-1"]').className.includes('lc-team-task-chain'))
await click(query(toggle.container, '[data-task="task-3"] .lc-team-task-dep'))
assert.deepEqual([...focuses].slice(-1), [null])
await toggle.unmount()
// A focused leaf with no blockers lights no chain.
const leaf = await mount(h(TaskBoard, propsOf({ focusTaskId: 'task-1' })))
assert.equal(queryAll(leaf.container, '.lc-team-task-chain').length, 0)
await leaf.unmount()
// A focused task whose blocker the board does not serve walks the chain without it.
const orphan = await mount(h(TaskBoard, propsOf({ focusTaskId: 'task-5' })))
assert.ok(query(orphan.container, '[data-task="task-5"]').className.includes('lc-team-task-focus'))
assert.equal(queryAll(orphan.container, '.lc-team-task-chain').length, 0)
await orphan.unmount()
})
test('the owner view regroups under each member with the unowned pool last', async () => {
const m = await mount(h(TaskBoard, propsOf()))
const toggle = queryAll(m.container, '.lc-tasks-view .lc-gran-btn')
assert.equal(toggle.length, 2)
await click(toggle[1])
const heads = queryAll(m.container, '.lc-tasks-group-head')
// worker-bee owns task-1; ghost (off-roster) owns task-4; the unowned pool carries the rest.
assert.deepEqual(heads.map(el => text(el)), ['worker-bee1', 'ghost1', `${DICT_EN['fleet.taskUnowned']}3`])
// Within a group the board order holds: status first, pending ready-first.
assert.deepEqual(
queryAll(m.container, '.lc-tasks-group')[2].querySelectorAll('.lc-team-task-subject').item(0).textContent,
'wire the view',
)
// An owner group head pins the member; an off-roster name is inert.
const pins = new Array<string | null>()
await m.unmount()
const m2 = await mount(h(TaskBoard, propsOf({ onPinMember: id => { pins.push(id) } })))
await click(queryAll(m2.container, '.lc-tasks-view .lc-gran-btn')[1])
const groupOwners = queryAll(m2.container, '.lc-tasks-group-owner .lc-tasks-group-head .lc-team-task-owner')
await click(groupOwners[0])
await click(groupOwners[1])
assert.deepEqual([...pins], ['w1'], 'the ghost owner never pins')
// Back to the status view.
await click(queryAll(m2.container, '.lc-tasks-view .lc-gran-btn')[0])
assert.ok(text(queryAll(m2.container, '.lc-tasks-group-head')[0]).includes('In progress'))
await m2.unmount()
})
test('the owner filter narrows the board and clears from the chip', async () => {
@@ -108,9 +207,11 @@ describe('TaskBoard', () => {
assert.deepEqual([...filters], [null])
await m.unmount()
// A filter matching nothing names it.
// A filter matching nothing names it — in either view.
const none = await mount(h(TaskBoard, propsOf({ ownerFilter: 'lead' })))
assert.ok(text(none.container).includes(DICT_EN['fleet.tasks.noMatch']))
await click(queryAll(none.container, '.lc-tasks-view .lc-gran-btn')[1])
assert.equal(queryAll(none.container, '.lc-tasks-group').length, 0)
await none.unmount()
})
@@ -125,6 +226,16 @@ describe('TaskBoard', () => {
const empty = await mount(h(TaskBoard, propsOf({ team: teamOf({ tasks: [] }) })))
assert.ok(text(empty.container).includes(DICT_EN['fleet.team.empty']))
assert.equal(queryAll(empty.container, '.lc-tasks-progress').length, 0, 'no bar without tasks')
assert.equal(queryAll(empty.container, '.lc-tasks-view').length, 0, 'no view switch without tasks')
await empty.unmount()
})
test('no team: the card stays up with a pointer to the team mode', async () => {
const m = await mount(h(TaskBoard, propsOf({ team: null })))
assert.ok(text(m.container).includes(DICT_EN['fleet.tasks.noTeam']))
assert.equal(queryAll(m.container, '.lc-team-task').length, 0)
assert.equal(queryAll(m.container, '.lc-tasks-progress').length, 0)
await m.unmount()
})
})