feat(tui): card windowing, log G-jump, header tiny mode, expand-log width
- BoardView columns now window their card list against the available
rows so the cursor stays visible. Cards beyond the viewport scroll
in via "↑ N more" / "↓ N more" indicators. Fixes the "can't reach
cards in the Done column" bug — selectedIndex was advancing but the
cards were clipped.
- Logs section: G (vim-style, in addition to End) jumps to newest
entry. Lowercase g stays as the global Settings shortcut.
- MainHeader gains two new collapse tiers: cols < 50 renders just
FUSION + the active tab pills (no inactive tabs, no dividers); rows
< 10 hides the header entirely so the row isn't wasted.
- ExpandedLog stretches to width="100%" so the panel keeps its width
when an entry is expanded — was collapsing to content width before.
- Lighter blue palette throughout (cyanBright / cyan); status-mode
grid widened to 5:6 so Stats panel gets ~45% of cols.
- Stats: heap limit always on its own continuation row; bytes
formatted with a space ("450 MB") so a forced wrap, if it ever
happens, breaks after the unit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -779,6 +779,7 @@ function MainHeader({ state }: { state: DashboardState }) {
|
||||
const interactiveView = state.interactiveView;
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const interactiveTabs: Array<{ key: string; label: string; view: InteractiveView }> = [
|
||||
{ key: "b", label: "Board", view: "board" },
|
||||
{ key: "a", label: "Agents", view: "agents" },
|
||||
@@ -791,20 +792,44 @@ function MainHeader({ state }: { state: DashboardState }) {
|
||||
// * Help/quit (~20 chars) drops first.
|
||||
// * Inactive interactive tabs collapse to a `[k]` glyph.
|
||||
// * Inactive section tabs collapse to `[N]`.
|
||||
// * Final fallback shows just the active tab — but never abbreviates the
|
||||
// FUSION mark itself.
|
||||
// * Active-only mode hides every inactive tab.
|
||||
// * Tiny mode drops the whole header — the row is too precious to spend.
|
||||
if (rows < 10) return null;
|
||||
const showHelpHint = cols >= 110;
|
||||
const compactInteractive = cols < 100;
|
||||
const compactSections = cols < 90;
|
||||
const minimal = cols < 70;
|
||||
const tiny = cols < 50;
|
||||
if (tiny) {
|
||||
// Just FUSION + the single active tab. No dividers, no other tabs.
|
||||
const activeSectionIdx = inInteractive
|
||||
? -1
|
||||
: SECTION_ORDER.indexOf(focused);
|
||||
const activeSectionLabel = activeSectionIdx >= 0
|
||||
? SECTION_ORDER[activeSectionIdx].charAt(0).toUpperCase() + SECTION_ORDER[activeSectionIdx].slice(1)
|
||||
: null;
|
||||
const activeInteractive = inInteractive
|
||||
? interactiveTabs.find((t) => t.view === interactiveView) ?? null
|
||||
: null;
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} paddingX={1}>
|
||||
<MiniLogo />
|
||||
{activeSectionLabel && (
|
||||
<Text backgroundColor="cyan" color="black" bold>{` ${activeSectionIdx + 1} ${activeSectionLabel} `}</Text>
|
||||
)}
|
||||
{activeInteractive && (
|
||||
<Text backgroundColor="cyan" color="black" bold>{` ${activeInteractive.key} ${activeInteractive.label} `}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} paddingX={1} paddingY={0}>
|
||||
<MiniLogo />
|
||||
<Text dimColor>│</Text>
|
||||
{!minimal && <Text dimColor>│</Text>}
|
||||
{SECTION_ORDER.map((section, i) => {
|
||||
const isActive = !inInteractive && section === focused;
|
||||
const label = section.charAt(0).toUpperCase() + section.slice(1);
|
||||
// In minimal mode only show the active section tab.
|
||||
if (minimal && !isActive) return null;
|
||||
return (
|
||||
<Box key={section} marginRight={1}>
|
||||
@@ -902,12 +927,14 @@ function KanbanColumnView({
|
||||
isFocused,
|
||||
selectedIndex,
|
||||
width,
|
||||
availableRows,
|
||||
}: {
|
||||
column: KanbanColumn;
|
||||
tasks: TaskItem[];
|
||||
isFocused: boolean;
|
||||
selectedIndex: number;
|
||||
width: number;
|
||||
availableRows: number;
|
||||
}) {
|
||||
const accent = COLUMN_COLORS[column];
|
||||
const headerColor = isFocused ? "whiteBright" : accent;
|
||||
@@ -917,6 +944,21 @@ function KanbanColumnView({
|
||||
const headerText = ` ${label} `.length > innerHeaderWidth
|
||||
? ` ${label} `.slice(0, innerHeaderWidth)
|
||||
: ` ${label} `.padEnd(innerHeaderWidth, " ");
|
||||
|
||||
// Each TaskCard takes ~4 rows (top border + id row + title row + bottom
|
||||
// border + sometimes a wrapped title row). Reserve 2 rows for header +
|
||||
// header spacer + 2 rows for "↑/↓ N more" hints, then floor(remaining/4).
|
||||
const cardRowsBudget = Math.max(0, availableRows - 4);
|
||||
const visibleCount = Math.max(1, Math.floor(cardRowsBudget / 4));
|
||||
// Slide the window so the selected card is centered when possible.
|
||||
const halfWindow = Math.floor(visibleCount / 2);
|
||||
const maxStart = Math.max(0, tasks.length - visibleCount);
|
||||
const windowStart = Math.max(0, Math.min(selectedIndex - halfWindow, maxStart));
|
||||
const windowEnd = Math.min(tasks.length, windowStart + visibleCount);
|
||||
const visibleTasks = tasks.slice(windowStart, windowEnd);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = tasks.length - windowEnd;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -936,14 +978,20 @@ function KanbanColumnView({
|
||||
<Text dimColor>—</Text>
|
||||
) : (
|
||||
<Box flexDirection="column" gap={0} flexShrink={1} overflow="hidden">
|
||||
{tasks.map((task, i) => (
|
||||
{hiddenAbove > 0 && (
|
||||
<Text dimColor>↑ {hiddenAbove} more</Text>
|
||||
)}
|
||||
{visibleTasks.map((task, i) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
selected={isFocused && i === selectedIndex}
|
||||
selected={isFocused && (windowStart + i) === selectedIndex}
|
||||
width={cardWidth}
|
||||
/>
|
||||
))}
|
||||
{hiddenBelow > 0 && (
|
||||
<Text dimColor>↓ {hiddenBelow} more</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1357,12 +1405,17 @@ function groupTasksByColumn(tasks: TaskItem[]): Record<KanbanColumn, TaskItem[]>
|
||||
function BoardView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const rows = stdout?.rows ?? 24;
|
||||
// Narrow mode: collapse 4-column kanban to a single full-width column so
|
||||
// columns don't overflow or overlap when the terminal is too slim.
|
||||
const isNarrow = cols < NARROW_THRESHOLD;
|
||||
const columnWidth = isNarrow
|
||||
? Math.max(20, cols - 2)
|
||||
: Math.max(20, Math.floor((cols - 2) / KANBAN_COLUMNS.length));
|
||||
// Reserve rows for header + project bar + spacer + footer hints. The
|
||||
// kanban column then windows its cards based on what remains so cards
|
||||
// beyond the visible area still scroll into view as the cursor moves.
|
||||
const availableCardRows = Math.max(8, rows - 8);
|
||||
|
||||
const [subView, setSubView] = useState<BoardSubView>("board");
|
||||
const [projectIndex, setProjectIndex] = useState(0);
|
||||
@@ -1608,6 +1661,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
|
||||
isFocused={true}
|
||||
selectedIndex={focusedRow}
|
||||
width={columnWidth}
|
||||
availableRows={availableCardRows}
|
||||
/>
|
||||
) : (
|
||||
KANBAN_COLUMNS.map((col, i) => (
|
||||
@@ -1618,6 +1672,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
|
||||
isFocused={i === colIndex}
|
||||
selectedIndex={rowByColumn[col] ?? 0}
|
||||
width={columnWidth}
|
||||
availableRows={availableCardRows}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -3527,7 +3582,9 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.end) {
|
||||
// Vim-style "G" jumps to the newest log entry. "g" stays as the
|
||||
// global Settings shortcut, so we don't bind it here.
|
||||
if (key.end || input === "G") {
|
||||
controller.setSelectedLogIndex(Math.max(0, filteredEntries.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
128
packages/dashboard/app/components/AgentTokenStatsPanel.css
Normal file
128
packages/dashboard/app/components/AgentTokenStatsPanel.css
Normal file
@@ -0,0 +1,128 @@
|
||||
.agent-token-stats-panel {
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__header {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__title {
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-md) + var(--space-xs));
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 4), 1fr));
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__total-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__total-label {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: calc(var(--space-xs) / 8);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__total-value {
|
||||
color: var(--text);
|
||||
font-size: calc(var(--space-lg) + var(--space-xs));
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__table th,
|
||||
.agent-token-stats-panel__table td {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__table thead th {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: calc(var(--space-xs) / 8);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__table th:first-child,
|
||||
.agent-token-stats-panel__table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__agent-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__agent-name {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__agent-id {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__total-cell {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__empty {
|
||||
padding: var(--space-md);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-token-stats-panel {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__totals {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-token-stats-panel__table th,
|
||||
.agent-token-stats-panel__table td {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
}
|
||||
105
packages/dashboard/app/components/AgentTokenStatsPanel.tsx
Normal file
105
packages/dashboard/app/components/AgentTokenStatsPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Agent } from "../api";
|
||||
import "./AgentTokenStatsPanel.css";
|
||||
|
||||
interface AgentTokenStatsPanelProps {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
interface AgentTokenRow {
|
||||
id: string;
|
||||
name: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
function normalizeTokenCount(value: number | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
|
||||
const { rows, totalInputTokens, totalOutputTokens, totalTokens } = useMemo(() => {
|
||||
const computedRows = agents
|
||||
.map((agent): AgentTokenRow => {
|
||||
const inputTokens = normalizeTokenCount(agent.totalInputTokens);
|
||||
const outputTokens = normalizeTokenCount(agent.totalOutputTokens);
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens || a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
|
||||
return {
|
||||
rows: computedRows,
|
||||
totalInputTokens: computedRows.reduce((sum, row) => sum + row.inputTokens, 0),
|
||||
totalOutputTokens: computedRows.reduce((sum, row) => sum + row.outputTokens, 0),
|
||||
totalTokens: computedRows.reduce((sum, row) => sum + row.totalTokens, 0),
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
const hasUsageData = totalTokens > 0;
|
||||
|
||||
return (
|
||||
<section className="agent-token-stats-panel" aria-label="Agent token usage statistics">
|
||||
<header className="agent-token-stats-panel__header">
|
||||
<h3 className="agent-token-stats-panel__title">Token Usage by Agent</h3>
|
||||
</header>
|
||||
|
||||
<div className="agent-token-stats-panel__totals" role="list" aria-label="Token usage totals">
|
||||
<div className="agent-token-stats-panel__total-card" role="listitem">
|
||||
<span className="agent-token-stats-panel__total-label">Input Tokens</span>
|
||||
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalInputTokens)}</span>
|
||||
</div>
|
||||
<div className="agent-token-stats-panel__total-card" role="listitem">
|
||||
<span className="agent-token-stats-panel__total-label">Output Tokens</span>
|
||||
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalOutputTokens)}</span>
|
||||
</div>
|
||||
<div className="agent-token-stats-panel__total-card" role="listitem">
|
||||
<span className="agent-token-stats-panel__total-label">Combined Tokens</span>
|
||||
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasUsageData ? (
|
||||
<div className="agent-token-stats-panel__table-wrapper">
|
||||
<table className="agent-token-stats-panel__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Agent</th>
|
||||
<th scope="col">Input</th>
|
||||
<th scope="col">Output</th>
|
||||
<th scope="col">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<th scope="row" className="agent-token-stats-panel__agent-cell">
|
||||
<span className="agent-token-stats-panel__agent-name">{row.name}</span>
|
||||
<span className="agent-token-stats-panel__agent-id">{row.id}</span>
|
||||
</th>
|
||||
<td>{formatTokenCount(row.inputTokens)}</td>
|
||||
<td>{formatTokenCount(row.outputTokens)}</td>
|
||||
<td className="agent-token-stats-panel__total-cell">{formatTokenCount(row.totalTokens)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-token-stats-panel__empty" role="status">
|
||||
No token usage recorded yet. Token totals appear here once agents run.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree
|
||||
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
|
||||
import { AgentEmptyState } from "./AgentEmptyState";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
||||
@@ -1266,6 +1267,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
|
||||
{/* Secondary sections after the main collection */}
|
||||
<AgentMetricsBar stats={stats} />
|
||||
<AgentTokenStatsPanel agents={displayAgents} />
|
||||
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { AgentTokenStatsPanel } from "../AgentTokenStatsPanel";
|
||||
import type { Agent } from "../../api";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-default",
|
||||
name: "Default Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentTokenStatsPanel", () => {
|
||||
it("renders aggregate totals from agent cumulative token fields", () => {
|
||||
render(
|
||||
<AgentTokenStatsPanel
|
||||
agents={[
|
||||
makeAgent({ id: "a-1", name: "Alpha", totalInputTokens: 100, totalOutputTokens: 40 }),
|
||||
makeAgent({ id: "a-2", name: "Beta", totalInputTokens: 10, totalOutputTokens: 5 }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Input Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("110")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("45")).toBeInTheDocument();
|
||||
expect(screen.getByText("Combined Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("155")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts per-agent rows by total usage descending", () => {
|
||||
render(
|
||||
<AgentTokenStatsPanel
|
||||
agents={[
|
||||
makeAgent({ id: "a-1", name: "Alpha", totalInputTokens: 50, totalOutputTokens: 20 }),
|
||||
makeAgent({ id: "a-2", name: "Beta", totalInputTokens: 5, totalOutputTokens: 5 }),
|
||||
makeAgent({ id: "a-3", name: "Gamma", totalInputTokens: 20, totalOutputTokens: 40 }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("Alpha")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("Gamma")).toBeInTheDocument();
|
||||
expect(within(rows[2]).getByText("Beta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats missing token fields as zero and shows empty state when there is no usage", () => {
|
||||
render(
|
||||
<AgentTokenStatsPanel
|
||||
agents={[
|
||||
makeAgent({ id: "a-1", name: "Zero One", totalInputTokens: undefined, totalOutputTokens: undefined }),
|
||||
makeAgent({ id: "a-2", name: "Zero Two" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No token usage recorded yet. Token totals appear here once agents run.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,8 @@ describe("AgentsView", () => {
|
||||
name: "Test Agent 1",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "idle" as AgentState,
|
||||
totalInputTokens: 100,
|
||||
totalOutputTokens: 20,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
@@ -56,6 +58,8 @@ describe("AgentsView", () => {
|
||||
role: "triage" as AgentCapability,
|
||||
state: "active" as AgentState,
|
||||
taskId: "FN-001",
|
||||
totalInputTokens: 10,
|
||||
totalOutputTokens: 5,
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 30000 },
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
@@ -76,6 +80,8 @@ describe("AgentsView", () => {
|
||||
name: "Test Agent 4",
|
||||
role: "reviewer" as AgentCapability,
|
||||
state: "terminated" as AgentState,
|
||||
totalInputTokens: 1,
|
||||
totalOutputTokens: 1,
|
||||
createdAt: new Date(Date.now() - 259200000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
@@ -170,15 +176,18 @@ describe("AgentsView", () => {
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".agent-list")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-metrics-bar")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-token-stats-panel")).toBeTruthy();
|
||||
expect(container.querySelector(".active-agents-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
const list = container.querySelector(".agent-list");
|
||||
const metrics = container.querySelector(".agent-metrics-bar");
|
||||
const tokenPanel = container.querySelector(".agent-token-stats-panel");
|
||||
const activePanel = container.querySelector(".active-agents-panel");
|
||||
expect(list && metrics && activePanel).toBeTruthy();
|
||||
expect(list && metrics && tokenPanel && activePanel).toBeTruthy();
|
||||
expect(list!.compareDocumentPosition(metrics!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(metrics!.compareDocumentPosition(activePanel!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(metrics!.compareDocumentPosition(tokenPanel!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(tokenPanel!.compareDocumentPosition(activePanel!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => {
|
||||
@@ -193,6 +202,25 @@ describe("AgentsView", () => {
|
||||
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders token stats derived from the currently displayed agents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Token Usage by Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Input Tokens")).toBeTruthy();
|
||||
expect(screen.getByText("111")).toBeTruthy();
|
||||
expect(screen.getByText("Output Tokens")).toBeTruthy();
|
||||
expect(screen.getByText("26")).toBeTruthy();
|
||||
expect(screen.getByText("Combined Tokens")).toBeTruthy();
|
||||
expect(screen.getByText("137")).toBeTruthy();
|
||||
|
||||
const tokenRows = screen.getAllByRole("row");
|
||||
expect(tokenRows[1]).toHaveTextContent("Test Agent 1");
|
||||
expect(tokenRows[2]).toHaveTextContent("Test Agent 2");
|
||||
});
|
||||
|
||||
it("passes projectId to agent fetches", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
await waitFor(() => {
|
||||
@@ -280,8 +308,13 @@ describe("AgentsView", () => {
|
||||
it("keeps clickable identity area behavior for opening detail view", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
const agentName = await screen.findByText("Test Agent 1");
|
||||
const clickableIdentity = agentName.closest(".agent-info--clickable") as HTMLElement | null;
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const clickableIdentity = Array.from(document.querySelectorAll(".agent-info--clickable")).find((element) =>
|
||||
element.textContent?.includes("Test Agent 1"),
|
||||
) as HTMLElement | undefined;
|
||||
expect(clickableIdentity).toBeTruthy();
|
||||
|
||||
fireEvent.click(clickableIdentity!);
|
||||
@@ -912,7 +945,7 @@ describe("AgentsView", () => {
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent 1")).toBeTruthy();
|
||||
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
|
||||
@@ -1220,7 +1253,7 @@ describe("AgentsView", () => {
|
||||
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Test Agent 4")).toBeTruthy();
|
||||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows Delete button for terminated agents when explicitly filtered", async () => {
|
||||
@@ -1232,7 +1265,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent 4")).toBeTruthy();
|
||||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||||
// Now we should see the Delete button for terminated agent
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
@@ -1268,7 +1301,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent 4")).toBeTruthy();
|
||||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||||
// Click the delete button for the terminated agent (agent-004)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let terminatedCard: Element | null = null;
|
||||
@@ -1299,7 +1332,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent 4")).toBeTruthy();
|
||||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find the delete button for terminated agent (agent-004)
|
||||
|
||||
@@ -65,6 +65,8 @@ const mockAgents: Agent[] = [
|
||||
role: "executor" as AgentCapability,
|
||||
state: "active" as AgentState,
|
||||
taskId: "FN-101",
|
||||
totalInputTokens: 60,
|
||||
totalOutputTokens: 20,
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -75,6 +77,8 @@ const mockAgents: Agent[] = [
|
||||
name: "Mobile Reviewer",
|
||||
role: "reviewer" as AgentCapability,
|
||||
state: "idle" as AgentState,
|
||||
totalInputTokens: 15,
|
||||
totalOutputTokens: 5,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
@@ -132,6 +136,9 @@ describe("AgentsView mobile adaptations", () => {
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".agent-list")).toBeTruthy();
|
||||
expect(container.querySelectorAll(".agent-card").length).toBeGreaterThan(0);
|
||||
expect(container.querySelector(".agent-token-stats-panel")).toBeTruthy();
|
||||
expect(screen.getByText("Combined Tokens")).toBeTruthy();
|
||||
expect(screen.getByText("100")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user