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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
Reference in New Issue
Block a user