diff --git a/src/client/agentTree.ts b/src/client/agentTree.ts index e1b1ea3..244292d 100644 --- a/src/client/agentTree.ts +++ b/src/client/agentTree.ts @@ -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 { diff --git a/src/client/components/agentGraph.tsx b/src/client/components/agentGraph.tsx index 9d4a6ea..4747733 100644 --- a/src/client/components/agentGraph.tsx +++ b/src/client/components/agentGraph.tsx @@ -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(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 = ( {t('agents.title')} - {t('agents.sub')} + {t(select !== undefined ? 'agents.subSelect' : 'agents.sub')}
@@ -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(
- {inspector} + {props.inspector === false ? null : inspector} ) @@ -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): (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 (
{ 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 ? {props.t('agents.self')} : null} + {selectable && !node.isCurrent ? ( + + ) : null}
{metricBits.length > 0 ? metricBits.join(' · ') : '—'} diff --git a/src/client/components/fleetList.tsx b/src/client/components/fleetList.tsx deleted file mode 100644 index 2281816..0000000 --- a/src/client/components/fleetList.tsx +++ /dev/null @@ -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 { - if (node.running) return 'running' - return node.completed ? 'done' : 'idle' -} - -const SORTERS: Record, (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('all') - const [sort, setSort] = useState('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 ( -
-
- {t('fleet.list.title')} - {t('fleet.list.sub')} -
- -
- { setQuery(ev.target.value) }} - /> -
- {(['all', 'running', 'done', 'idle'] as const).map(key => ( - - ))} -
- -
- - {rows.length === 0 ? ( -
{t('fleet.list.empty')}
- ) : ( -
    - {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 ( -
  • -
    { 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 - ? - : } - {node.label} - {node.isCurrent ? {t('agents.self')} : null} - {member !== null && member.role === 'teammate' - ? {member.name} - : null} - {member !== null && member.role === 'lead' - ? {t('fleet.role.lead')} - : null} - {meta.join(' · ')} - {pct !== null - ? {pct}% - : null} - {!node.isCurrent ? ( - - ) : null} -
    -
  • - ) - })} -
- )} -
- ) -} diff --git a/src/client/components/fleetSummary.tsx b/src/client/components/fleetSummary.tsx new file mode 100644 index 0000000..a604db9 --- /dev/null +++ b/src/client/components/fleetSummary.tsx @@ -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 ( +
+
+ {t('agents.chip.count', { n: nodes.length + forest.overflow })} + 0 ? ' lc-agents-chip-on' : '')}> + {t('agents.chip.running', { n: running })} + + {subs.length > 0 + ? {t('fleet.summary.done', { n: subsDone })} + : null} + {tokens > 0 + ? {t('agents.chip.tokens', { n: fmt(tokens) })} + : null} + {costKnown + ? {t('fleet.summary.cost', { n: formatCost(cost, props.currency) })} + : null} +
+ {progress !== null ? ( +
+ {progress.label} + + + + {progress.done}/{progress.total} +
+ ) : null} +
+ ) +} diff --git a/src/client/components/fleetView.tsx b/src/client/components/fleetView.tsx index a35b9a6..2c3dcdf 100644 --- a/src/client/components/fleetView.tsx +++ b/src/client/components/fleetView.tsx @@ -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(null) const [pinnedId, setPinnedId] = useState(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(null) const [focusTaskId, setFocusTaskId] = useState(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() - 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 (
@@ -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 = ( + + ) + // 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 + ? ( +
+ details.landed.get(id)} + pinnedId={pinnedId} + onPin={setPinnedId} + onHover={setHoverId} + onOpen={open} + ownerFilter={ownerFilter} + onOwnerFilter={setOwnerFilter} + t={t} + fmt={kit.fmt} + /> + {taskBoard} +
+ ) + : team !== null + ?
{taskBoard}
+ : null return (
- details.landed.get(id), - commsOf: id => commsByAgent.get(id) ?? [], - labelOf, - roster: leadDetail?.roster ?? [], - onFocusTask: setFocusTaskId, - }} - /> - {team !== null + {forest !== null + ? + : null} + {forest !== null || boardsColumn !== null ? (
{forest !== null ? ( - details.landed.get(id)} - pinnedId={pinnedId} - onPin={setPinnedId} - onHover={setHoverId} - onOpen={open} - ownerFilter={ownerFilter} - onOwnerFilter={setOwnerFilter} - t={t} - /> +
+ +
) : null} - + {boardsColumn}
) : null} - {forest !== null && !forest.solo - ? ( - - ) - : null} {forest !== null && !forest.solo ? ( 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 { + if (node.running) return 'running' + return node.completed ? 'done' : 'idle' +} + +const SORTERS: Record, (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('all') + const [sort, setSort] = useState('spawn') + + const nodeById = useMemo(() => new Map(forest.nodes.map(n => [n.id, n])), [forest]) const roster = new Map() for (const info of props.leadDetail?.roster ?? []) roster.set(info.name, info) - return ( -
- - - {t('fleet.members.title')} - - {t('fleet.members.count', { n: team.members.length })} - {team.failure !== undefined ? ( - {t('fleet.team.failureShort')} - ) : null} -
+ // 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]) -
- {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 ( -
{ 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) }} - > -
- - {member.role === 'lead' ? : } - - {member.name} - {isCurrent ? {t('agents.self')} : null} - {t(member.role === 'lead' ? 'fleet.role.lead' : 'fleet.role.teammate')} - -
-
- {member.phase === 'failed' - ? t('fleet.phase.failed') - : member.phase === 'provisioning' - ? t('fleet.phase.provisioning') - : running ? t('agents.running') : t('fleet.memberInactive')} - {model !== undefined ? {model} : null} -
- {member.error !== undefined ?
{member.error}
: null} - {info !== undefined && info.description !== '' ? ( -
{info.description}
- ) : null} - {detail?.lastReply !== undefined ? ( -
- {t('fleet.members.reply')} {detail.lastReply.text} -
- ) : null} -
- {activeTasks.length > 0 ? ( - - ) : {t('fleet.members.noTasks')}} - {!isCurrent && node !== undefined ? ( - - ) : null} -
+ // 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 ( +
+
+ + + {t('fleet.board.title')} + + {t('agents.chip.count', { n: forest.nodes.length + forest.overflow })} + {team?.failure !== undefined ? ( + {t('fleet.team.failureShort')} + ) : null} +
+ + {listTotal > 1 || !bare ? ( +
+ { setQuery(ev.target.value) }} + /> +
+ {(['all', 'running', 'done', 'idle'] as const).map(key => ( + + ))}
- ) - })} -
+ +
+ ) : null} + + {team !== null && members.length > 0 ? ( + <> +
+ {t('fleet.members.title')} + {fmt(members.length)} +
+
+ {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 ( +
{ 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) }} + > +
+ + {member.role === 'lead' ? : } + + {member.name} + {isCurrent ? {t('agents.self')} : null} + {t(member.role === 'lead' ? 'fleet.role.lead' : 'fleet.role.teammate')} + +
+
+ {member.phase === 'failed' + ? t('fleet.phase.failed') + : member.phase === 'provisioning' + ? t('fleet.phase.provisioning') + : running ? t('agents.running') : t('fleet.memberInactive')} + {model !== undefined ? {model} : null} +
+ {member.error !== undefined ?
{member.error}
: null} + {info !== undefined && info.description !== '' ? ( +
{info.description}
+ ) : null} + {detail?.lastReply !== undefined ? ( +
+ {t('fleet.members.reply')} {detail.lastReply.text} +
+ ) : null} +
+ {activeTasks.length > 0 ? ( + + ) : {t('fleet.members.noTasks')}} + {!isCurrent && node !== undefined ? ( + + ) : null} +
+
+ ) + })} +
+ + ) : null} + + {team === null && forest.solo ? ( +
{t('fleet.board.solo')}
+ ) : null} + {team === null && !forest.solo ? ( +
{t('fleet.board.noTeam')}
+ ) : null} + + {others.length > 0 ? ( + <> +
+ {t('fleet.board.others')} + {fmt(others.length)} +
+
    + {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 ( +
  • +
    { 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 + ? + : } + {node.label} + {meta.join(' · ')} + {pct !== null ? {pct}% : null} + +
    +
  • + ) + })} +
+ + ) : null} + + {!bare && members.length === 0 && others.length === 0 ?
{t('fleet.list.empty')}
: null}
) } diff --git a/src/client/components/taskBoard.tsx b/src/client/components/taskBoard.tsx index ecfdadc..af44421 100644 --- a/src/client/components/taskBoard.tsx +++ b/src/client/components/taskBoard.tsx @@ -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>(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(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() + 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() + 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 => ( +
  • { hoverOwner(task) }} + onMouseLeave={() => { hoverOwner(null) }} + > +
    + + {task.subject} + {task.status === 'pending' + ? ( + + {t(task.ready ? 'fleet.taskReady' : 'fleet.taskBlocked')} + + ) + : null} + {task.ownerName !== undefined ? ( + + ) : {t('fleet.taskUnowned')}} +
    + {task.description !== '' ? ( +
    + {task.description} +
    + ) : null} + {task.blockedBy.length > 0 ? ( +
    + {t('fleet.tasks.dependsOn')} + {task.blockedBy.map((dep) => { + const blocker = byId.get(dep) + return ( + + ) + })} +
    + ) : null} +
    + {task.id} + {task.writeScopes.length > 0 ? ( + {t('fleet.taskScopes')} {task.writeScopes.join(', ')} + ) : null} + {task.description !== '' ? ( + + ) : null} +
    + {task.warnings.length > 0 ? ( +
    {task.warnings.join(' · ')}
    + ) : null} +
  • + ) + return (
    @@ -83,6 +232,20 @@ export function TaskBoard(props: TaskBoardProps): ReactElement { {t('fleet.tasks.title')} {t('fleet.tasks.sub')} + {tasks.length > 0 ? ( + + {(['status', 'owner'] as const).map(key => ( + + ))} + + ) : null} {props.ownerFilter !== null ? (
    - {team.tasks.length === 0 ? ( + {tasks.length > 0 ? ( +
    + + + + + {t('fleet.tasks.progress', { done: doneCount, total: tasks.length })} + {activeCount > 0 ? ` · ${t('fleet.tasks.active', { n: activeCount })}` : ''} + +
    + ) : null} + + {team === null ? ( +
    {t('fleet.tasks.noTeam')}
    + ) : tasks.length === 0 ? (
    {t('fleet.team.empty')}
    - ) : ( - grouped.map(({ key, tasks }) => { - if (tasks.length === 0) return null + ) : view === 'status' ? ( + grouped.map(({ key, tasks: groupTasks }) => { + if (groupTasks.length === 0) return null return (
    {t(`fleet.taskCol.${key}`)} - {tasks.length} + {groupTasks.length}
      - {tasks.map(task => ( -
    • -
      - - {task.subject} - {task.status === 'pending' - ? ( - - {t(task.ready ? 'fleet.taskReady' : 'fleet.taskBlocked')} - - ) - : null} - {task.ownerName !== undefined ? ( - - ) : {t('fleet.taskUnowned')}} -
      - {task.description !== '' ? ( -
      - {task.description} -
      - ) : null} - {task.blockedBy.length > 0 ? ( -
      - {t('fleet.tasks.dependsOn')} - {task.blockedBy.map((dep) => { - const blocker = byId.get(dep) - return ( - - ) - })} -
      - ) : null} -
      - {task.id} - {task.writeScopes.length > 0 ? ( - {t('fleet.taskScopes')} {task.writeScopes.join(', ')} - ) : null} - {task.description !== '' ? ( - - ) : null} -
      - {task.warnings.length > 0 ? ( -
      {task.warnings.join(' · ')}
      - ) : null} -
    • - ))} + {groupTasks.map(renderTask)}
    ) }) + ) : ( + byOwner.map(({ owner, tasks: groupTasks }) => ( +
    +
    + {owner === null + ? t('fleet.taskUnowned') + : ( + + )} + {groupTasks.length} +
    +
      + {groupTasks.map(renderTask)} +
    +
    + )) )} - {team.tasks.length > 0 && visible.length === 0 ? ( + {tasks.length > 0 && visible.length === 0 ? (
    {t('fleet.tasks.noMatch')}
    ) : null}
    diff --git a/src/client/cost.ts b/src/client/cost.ts index 4de9b72..6012e35 100644 --- a/src/client/cost.ts +++ b/src/client/cost.ts @@ -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) diff --git a/src/client/i18nEn.ts b/src/client/i18nEn.ts index 987703f..6b6bdc2 100644 --- a/src/client/i18nEn.ts +++ b/src/client/i18nEn.ts @@ -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', diff --git a/src/client/i18nZh.ts b/src/client/i18nZh.ts index a4e2fad..a98243d 100644 --- a/src/client/i18nZh.ts +++ b/src/client/i18nZh.ts @@ -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': '排序', diff --git a/src/client/styles/agentGraph.css b/src/client/styles/agentGraph.css index 5fecdb8..92e98e3 100644 --- a/src/client/styles/agentGraph.css +++ b/src/client/styles/agentGraph.css @@ -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); } diff --git a/src/client/styles/fleet.css b/src/client/styles/fleet.css index 995aa38..92125a4 100644 --- a/src/client/styles/fleet.css +++ b/src/client/styles/fleet.css @@ -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; } diff --git a/tests/client/agentTree.spec.ts b/tests/client/agentTree.spec.ts index 0735ff9..ef324ef 100644 --- a/tests/client/agentTree.spec.ts +++ b/tests/client/agentTree.spec.ts @@ -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)) diff --git a/tests/client/components/agentGraph.spec.ts b/tests/client/components/agentGraph.spec.ts index 9a6331d..fdab8bd 100644 --- a/tests/client/components/agentGraph.spec.ts +++ b/tests/client/components/agentGraph.spec.ts @@ -354,11 +354,11 @@ describe('AgentGraph — the family tree', () => { test('overflow chip when the family exceeds the cap', async () => { const byId: Record = { 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() + 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', () => { diff --git a/tests/client/components/fleetList.spec.ts b/tests/client/components/fleetList.spec.ts deleted file mode 100644 index 43567c2..0000000 --- a/tests/client/components/fleetList.spec.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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() - 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() - 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() - }) -}) diff --git a/tests/client/components/fleetSummary.spec.ts b/tests/client/components/fleetSummary.spec.ts new file mode 100644 index 0000000..bda3e55 --- /dev/null +++ b/tests/client/components/fleetSummary.spec.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/tests/client/components/fleetView.panels.spec.ts b/tests/client/components/fleetView.panels.spec.ts index c8f3cd5..5671ce3 100644 --- a/tests/client/components/fleetView.panels.spec.ts +++ b/tests/client/components/fleetView.panels.spec.ts @@ -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() 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() + 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() }) diff --git a/tests/client/components/memberBoard.spec.ts b/tests/client/components/memberBoard.spec.ts index ba871cc..20fff00 100644 --- a/tests/client/components/memberBoard.spec.ts +++ b/tests/client/components/memberBoard.spec.ts @@ -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 { + 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 { + 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 { 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 { 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([ ['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() + 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() + 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() + }) +}) diff --git a/tests/client/components/taskBoard.spec.ts b/tests/client/components/taskBoard.spec.ts index 1655770..b83df8b 100644 --- a/tests/client/components/taskBoard.spec.ts +++ b/tests/client/components/taskBoard.spec.ts @@ -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 { { 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 { 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() 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() + const hovers = new Array() + const focuses = new Array() + 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() 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() + 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() + }) })