Files
Auto-Trader/frontend/src/components/chat/RunCompleteCard.tsx
T
Haozhe Wu 1e5b79dac6 chore(frontend): move to Node 22 and React Router 8
React Router 7.12.0 through 8.2.x carry a high-severity RSC-mode CSRF bypass
(GHSA-qwww-vcr4-c8h2). We were on 7.18.1, inside that range. The advisory is not
reachable here — the app is a client-side SPA on createBrowserRouter with no
@react-router/server and no RSC entry point, and the frontend ships as static
Vite output — but 7.18.1 is the final 7.x release, no backport exists, and 8.3.0
is the only patched version, so staying put means staying on an EOL line with a
permanent open alert.

react-router-dom has no 8.x at all; the DOM exports moved into react-router
itself. So the dependency is swapped and every import rewritten. All ten APIs in
use here are unchanged in 8.3.0, and none of the data-layer surface that changed
most between majors is used.

react-router 8 requires Node >=22.22.0, which is the same wall that had jsdom 30
and @testing-library/jest-dom 7 sitting in the Dependabot ignore list. Node 20
reached EOL on 2026-04-30, so the toolchain moves to Node 22 across CI, the wiki
workflow, the Dockerfile and docker-compose. That unblocks all three at once:
jsdom and jest-dom are taken here too, and their ignores are removed rather than
left carrying a rationale that no longer holds.

Verified on the frontend suite: tsc -b and vite build clean, 310 tests across 35
files pass before and after the jsdom/jest-dom bumps.
2026-07-28 14:39:59 +08:00

117 lines
4.0 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { memo, useEffect, useState, useCallback } from "react";
import { Link } from "react-router";
import { BarChart3, Code2, FileText, Loader2 } from "lucide-react";
import { api } from "@/lib/api";
import { AgentAvatar } from "./AgentAvatar";
import { MetricsCard } from "./MetricsCard";
import { MiniEquityChart } from "@/components/charts/MiniEquityChart";
import { PineScriptViewer } from "./PineScriptViewer";
import type { AgentMessage } from "@/types/agent";
interface Props {
msg: AgentMessage;
}
export const RunCompleteCard = memo(function RunCompleteCard({ msg }: Props) {
const { t } = useTranslation();
const [curve, setCurve] = useState(msg.equityCurve);
const [pineCode, setPineCode] = useState<string | null>(null);
const [pineLoading, setPineLoading] = useState(false);
const [showPine, setShowPine] = useState(false);
const [pineChecked, setPineChecked] = useState(false);
const [pineExists, setPineExists] = useState(false);
useEffect(() => {
if (!curve && msg.runId) {
api.getRun(msg.runId).then(r => {
if (r.equity_curve) setCurve(r.equity_curve.map(e => ({ time: e.time, equity: e.equity })));
}).catch(() => {});
}
}, [msg.runId, curve]);
// Check if Pine Script exists for this run (skip for shadow-only cards with no runId)
useEffect(() => {
if (!msg.runId) {
setPineChecked(true);
return;
}
if (!pineChecked) {
api.getRunPine(msg.runId).then(r => {
setPineChecked(true);
if (r.exists && r.content) {
setPineExists(true);
setPineCode(r.content);
}
}).catch(() => { setPineChecked(true); });
}
}, [msg.runId, pineChecked]);
const handlePineClick = useCallback(async () => {
if (pineCode) {
setShowPine(true);
return;
}
if (!msg.runId) return;
setPineLoading(true);
try {
const r = await api.getRunPine(msg.runId);
if (r.exists && r.content) {
setPineCode(r.content);
setPineExists(true);
setShowPine(true);
}
} catch { /* ignore */ }
finally { setPineLoading(false); }
}, [pineCode, msg.runId]);
return (
<div className="flex gap-3">
<AgentAvatar />
<div className="flex-1 min-w-0 space-y-2">
{msg.metrics && Object.keys(msg.metrics).length > 0 && (
<MetricsCard metrics={msg.metrics} compact />
)}
{curve && curve.length > 1 && (
<MiniEquityChart data={curve} height={80} />
)}
<div className="flex items-center gap-3 flex-wrap">
{msg.runId && (
<Link
to={`/runs/${msg.runId}`}
className="text-sm text-primary hover:underline inline-flex items-center gap-1.5 font-medium"
>
<BarChart3 className="h-3.5 w-3.5" />
{t("runComplete.fullReport")}
</Link>
)}
{pineExists && (
<button
onClick={handlePineClick}
disabled={pineLoading}
className="text-sm text-emerald-600 dark:text-emerald-400 hover:underline inline-flex items-center gap-1.5 font-medium disabled:opacity-50"
>
{pineLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Code2 className="h-3.5 w-3.5" />}
Pine Script
</button>
)}
{msg.shadowId && (
<a
href={`/shadow-reports/${encodeURIComponent(msg.shadowId)}?format=html`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:underline inline-flex items-center gap-1.5 font-medium"
>
<FileText className="h-3.5 w-3.5" />
Shadow Report
</a>
)}
</div>
{showPine && pineCode && (
<PineScriptViewer code={pineCode} onClose={() => setShowPine(false)} />
)}
</div>
</div>
);
});