feat(FN-2624): surface task token usage on task cards

- Render a compact token usage indicator in TaskCard footer with accessible labeling and token-aware styling
- Track token usage fields in the TaskCard memo comparator and expose a comparator test helper for regression coverage
- Add TaskCard tests for token usage rendering behavior and comparator invalidation on token usage updates
- Configure runtime plugin Vitest setups with an @fusion/engine source alias for reliable workspace test resolution
- Keep restart integration child_process spawn mocking aligned with execSync-driven merge verification behavior
This commit is contained in:
Fusion
2026-04-26 21:45:46 -07:00
committed by gsxdsm
parent 27e72190a6
commit b56571e082
7 changed files with 146 additions and 6 deletions

View File

@@ -478,6 +478,21 @@
opacity: 0.7;
}
.card-token-usage {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
line-height: 1;
white-space: nowrap;
}
.card-token-usage-value {
font-family: var(--font-mono);
font-weight: 600;
}
.card-time-indicator {
display: inline-flex;
align-items: center;
@@ -982,6 +997,10 @@
gap: var(--space-xs);
}
.card-token-usage {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
}
.card-time-indicator {
padding: var(--space-xs) var(--space-sm);
font-size: 10px;

View File

@@ -1,6 +1,6 @@
import "./TaskCard.css";
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2 } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, Zap } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
@@ -145,6 +145,24 @@ function formatElapsedDuration(elapsedMs: number): string {
return `${elapsedDays}d`;
}
function formatCompactTokenCount(value: number): string {
if (!Number.isFinite(value) || value < 0) {
return "0";
}
if (value < 1_000) {
return String(value);
}
const trimTrailingDecimal = (formatted: string) => formatted.replace(/\.0$/, "");
if (value < 1_000_000) {
return `${trimTrailingDecimal((value / 1_000).toFixed(1))}k`;
}
return `${trimTrailingDecimal((value / 1_000_000).toFixed(1))}M`;
}
interface TaskCardProps {
task: Task;
projectId?: string;
@@ -333,6 +351,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.missionId === nextTask.missionId &&
previousTask.assignedAgentId === nextTask.assignedAgentId &&
previousTask.mergeRetries === nextTask.mergeRetries &&
previousTask.tokenUsage?.totalTokens === nextTask.tokenUsage?.totalTokens &&
previousTask.tokenUsage?.lastUsedAt === nextTask.tokenUsage?.lastUsedAt &&
areAttachmentsEqual(previousTask.attachments, nextTask.attachments) &&
areCommentsEqual(previousTask.comments, nextTask.comments) &&
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
@@ -1037,6 +1057,18 @@ function TaskCardComponent({
return null;
})();
const tokenUsageIndicator = task.tokenUsage ? (
<span
className="card-token-usage"
title={`${task.tokenUsage.totalTokens.toLocaleString()} tokens`}
aria-label={`Token usage ${formatCompactTokenCount(task.tokenUsage.totalTokens)} tokens`}
>
<Zap size={12} />
<span className="card-token-usage-value">{formatCompactTokenCount(task.tokenUsage.totalTokens)}</span>
<span>tokens</span>
</span>
) : null;
if (isEditing) {
return (
<div
@@ -1309,9 +1341,10 @@ function TaskCardComponent({
</>
);
})()}
{(filesChangedButton || timeIndicator) && (
{(filesChangedButton || timeIndicator || tokenUsageIndicator) && (
<div className="card-footer-row">
{filesChangedButton}
{tokenUsageIndicator}
{timeIndicator && (
<span
className="card-time-indicator"
@@ -1384,5 +1417,10 @@ function truncate(s: string | undefined, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
/** @internal Test helper to verify TaskCard memo comparator behavior */
export function __test_areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): boolean {
return areTaskCardPropsEqual(previous, next);
}
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
TaskCard.displayName = "TaskCard";

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TaskCard } from "../TaskCard";
import { TaskCard, __test_areTaskCardPropsEqual } from "../TaskCard";
import type { Task } from "@fusion/core";
// Mock lucide-react to avoid SVG rendering issues in test env
@@ -16,6 +16,7 @@ vi.mock("lucide-react", () => ({
Target: () => null,
Bot: () => null,
Trash2: () => null,
Zap: () => null,
}));
vi.mock("../ProviderIcon", () => ({
@@ -47,6 +48,15 @@ function makeTask(overrides: Partial<Task> = {}): Task {
const noop = () => {};
const tokenUsageFixture = {
inputTokens: 50_000,
outputTokens: 30_000,
cachedTokens: 10_000,
totalTokens: 90_000,
firstUsedAt: "2026-04-26T10:00:00.000Z",
lastUsedAt: "2026-04-26T10:30:00.000Z",
} as const;
afterEach(() => {
vi.useRealTimers();
});
@@ -57,6 +67,33 @@ describe("TaskCard", () => {
expect(screen.getByText("FN-001")).toBeDefined();
});
it("renders token usage indicator when task has token usage", () => {
const { container } = render(
<TaskCard
task={makeTask({ tokenUsage: { ...tokenUsageFixture } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const tokenUsage = container.querySelector(".card-token-usage");
expect(tokenUsage).not.toBeNull();
expect(tokenUsage?.textContent).toContain("90k");
expect(tokenUsage?.textContent).toContain("tokens");
});
it("does not render token usage indicator when tokenUsage is undefined", () => {
const { container } = render(
<TaskCard
task={makeTask({ tokenUsage: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-token-usage")).toBeNull();
});
it("renders the status badge when task.status is set", () => {
render(
<TaskCard
@@ -703,6 +740,32 @@ describe("TaskCard", () => {
});
});
describe("TaskCard memo comparator", () => {
it("detects token usage changes", () => {
type ComparatorProps = Parameters<typeof __test_areTaskCardPropsEqual>[0];
const baseTask = makeTask({ tokenUsage: { ...tokenUsageFixture } });
const baseProps: ComparatorProps = {
task: baseTask,
onOpenDetail: noop,
addToast: noop,
};
const totalTokenChange: ComparatorProps = {
...baseProps,
task: makeTask({ tokenUsage: { ...tokenUsageFixture, totalTokens: 95_000 } }),
};
const lastUsedAtChange: ComparatorProps = {
...baseProps,
task: makeTask({ tokenUsage: { ...tokenUsageFixture, lastUsedAt: "2026-04-26T10:35:00.000Z" } }),
};
expect(__test_areTaskCardPropsEqual(baseProps, totalTokenChange)).toBe(false);
expect(__test_areTaskCardPropsEqual(baseProps, lastUsedAtChange)).toBe(false);
});
});
describe("TaskCard provider icons on agent row", () => {
it("renders provider icons when task has model overrides", () => {
render(

View File

@@ -49,8 +49,8 @@ vi.mock("../agent-session-helpers.js", async () => {
};
});
vi.mock("node:child_process", () => {
const { promisify } = require("node:util");
const { EventEmitter } = require("node:events");
const { promisify } = require("node:util");
const execSyncFn = vi.fn().mockReturnValue(Buffer.from(""));
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
@@ -78,18 +78,19 @@ vi.mock("node:child_process", () => {
}
});
});
// spawn() is used by the merger's verification runner. Route it through the
// same execSyncFn mock so a single mockedExecSync.mockImplementation controls
// both git calls (execSync) and verification commands (spawn). Throwing from
// execSyncFn ⇒ child emits non-zero close; returning normally ⇒ exit 0.
const spawnFn: any = vi.fn((cmd: any, _opts: any) => {
const spawnFn: any = vi.fn((cmd: any, opts: any) => {
const child: any = new EventEmitter();
child.pid = 1234;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, _opts);
const out = execSyncFn(cmd, opts);
if (out !== undefined && out !== null) {
child.stdout.emit("data", Buffer.from(out.toString()));
}
@@ -102,6 +103,7 @@ vi.mock("node:child_process", () => {
});
return child;
});
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({