feat(FN-2458): merge fusion/fn-2458
This commit is contained in:
@@ -18,6 +18,7 @@ import { TaskChangesTab } from "./TaskChangesTab";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||
import { TaskDocumentsTab } from "./TaskDocumentsTab";
|
||||
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
@@ -1481,6 +1482,12 @@ export function TaskDetailModal({
|
||||
<div className="step-progress-empty">(no steps defined)</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<TaskTokenStatsPanel
|
||||
tokenUsage={workingTask.tokenUsage}
|
||||
loading={detailLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
{!isEditingSpec && (
|
||||
<div className="detail-spec-edit-trigger">
|
||||
|
||||
87
packages/dashboard/app/components/TaskTokenStatsPanel.css
Normal file
87
packages/dashboard/app/components/TaskTokenStatsPanel.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.task-token-stats-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 3), 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__metric {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__label {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: calc(var(--space-xs) / 8);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-lg) + var(--space-xs));
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-token-stats-panel__timestamps {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 4), 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__timestamp-row {
|
||||
margin: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__timestamp-row dt {
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: calc(var(--space-xs) / 8);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__timestamp-row dd {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.task-token-stats-panel__empty,
|
||||
.task-token-stats-panel__loading {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-token-stats-panel {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__timestamps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
81
packages/dashboard/app/components/TaskTokenStatsPanel.tsx
Normal file
81
packages/dashboard/app/components/TaskTokenStatsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { TaskTokenUsage } from "@fusion/core";
|
||||
import "./TaskTokenStatsPanel.css";
|
||||
|
||||
interface TaskTokenStatsPanelProps {
|
||||
tokenUsage?: TaskTokenUsage;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function TaskTokenStatsPanel({ tokenUsage, loading }: TaskTokenStatsPanelProps) {
|
||||
if (!tokenUsage && loading) {
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label="Task token usage">
|
||||
<h4>Token Usage</h4>
|
||||
<div className="task-token-stats-panel__loading" role="status" aria-live="polite">
|
||||
Loading token statistics…
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenUsage) {
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label="Task token usage">
|
||||
<h4>Token Usage</h4>
|
||||
<div className="task-token-stats-panel__empty" role="status">
|
||||
No token usage recorded for this task yet.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label="Task token usage">
|
||||
<h4>Token Usage</h4>
|
||||
<div className="task-token-stats-panel__grid" role="list" aria-label="Task token totals">
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Input</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.inputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Output</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.outputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Cached</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cachedTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Total</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="task-token-stats-panel__timestamps">
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>First used</dt>
|
||||
<dd>
|
||||
<time dateTime={tokenUsage.firstUsedAt}>{formatTimestamp(tokenUsage.firstUsedAt)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>Last used</dt>
|
||||
<dd>
|
||||
<time dateTime={tokenUsage.lastUsedAt}>{formatTimestamp(tokenUsage.lastUsedAt)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5393,6 +5393,7 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading specification…")).toBeDefined();
|
||||
expect(screen.getByText("Loading token statistics…")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows spec content after fetchTaskDetail resolves", async () => {
|
||||
@@ -5414,6 +5415,14 @@ describe("TaskDetailModal", () => {
|
||||
const fullDetail: TaskDetail = {
|
||||
...task,
|
||||
prompt: "# Async Spec\n\nThis is the loaded spec content.",
|
||||
tokenUsage: {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 450,
|
||||
cachedTokens: 210,
|
||||
totalTokens: 1860,
|
||||
firstUsedAt: "2026-04-24T09:00:00.000Z",
|
||||
lastUsedAt: "2026-04-24T10:15:00.000Z",
|
||||
},
|
||||
} as TaskDetail;
|
||||
|
||||
// Resolve with full detail
|
||||
@@ -5442,6 +5451,54 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// Loading indicator should be gone
|
||||
expect(screen.queryByText("Loading specification…")).toBeNull();
|
||||
expect(screen.queryByText("Loading token statistics…")).toBeNull();
|
||||
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
|
||||
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();
|
||||
expect(screen.getByText((210).toLocaleString())).toBeInTheDocument();
|
||||
expect(screen.getByText((1860).toLocaleString())).toBeInTheDocument();
|
||||
const firstUsed = container.querySelector('time[datetime="2026-04-24T09:00:00.000Z"]');
|
||||
const lastUsed = container.querySelector('time[datetime="2026-04-24T10:15:00.000Z"]');
|
||||
expect(firstUsed).toBeTruthy();
|
||||
expect(lastUsed).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows token stats empty state once detail is loaded without usage", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
|
||||
const task: Task = {
|
||||
id: "FN-205",
|
||||
description: "No token stats",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
} as Task;
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
...task,
|
||||
prompt: "# Async Spec\n\nSpec without usage.",
|
||||
tokenUsage: undefined,
|
||||
} as TaskDetail);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No token usage recorded for this task yet.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskTokenStatsPanel } from "../TaskTokenStatsPanel";
|
||||
|
||||
describe("TaskTokenStatsPanel", () => {
|
||||
it("renders loading state while task detail token usage is hydrating", () => {
|
||||
render(<TaskTokenStatsPanel loading tokenUsage={undefined} />);
|
||||
|
||||
expect(screen.getByText("Loading token statistics…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No token usage recorded for this task yet.")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders empty state when detail is loaded without usage", () => {
|
||||
render(<TaskTokenStatsPanel loading={false} tokenUsage={undefined} />);
|
||||
|
||||
expect(screen.getByText("No token usage recorded for this task yet.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading token statistics…")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all token totals and usage timestamps", () => {
|
||||
render(
|
||||
<TaskTokenStatsPanel
|
||||
loading={false}
|
||||
tokenUsage={{
|
||||
inputTokens: 1200,
|
||||
outputTokens: 450,
|
||||
cachedTokens: 210,
|
||||
totalTokens: 1860,
|
||||
firstUsedAt: "2026-04-24T09:00:00.000Z",
|
||||
lastUsedAt: "2026-04-24T10:15:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cached")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,200")).toBeInTheDocument();
|
||||
expect(screen.getByText("450")).toBeInTheDocument();
|
||||
expect(screen.getByText("210")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,860")).toBeInTheDocument();
|
||||
|
||||
const firstUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T09:00:00.000Z");
|
||||
const lastUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T10:15:00.000Z");
|
||||
|
||||
expect(firstUsedTime).toBeInTheDocument();
|
||||
expect(lastUsedTime).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user