feat(FN-2458): merge fusion/fn-2458

This commit is contained in:
gsxdsm
2026-04-25 08:26:13 -07:00
parent bc0ef85f50
commit d1a730e5c9
7 changed files with 356 additions and 0 deletions

View File

@@ -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">

View 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;
}
}

View 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>
);
}

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});