feat(frontend): guided-minimalism WebUI overhaul — 12-way audit + fix fleet

Land the pre-talk WebUI campaign: first-frame stability (pre-paint theme
boot via CSP-safe public/theme-boot.js, self-hosted fonts + OFL license),
streaming performance (rAF token batching, throttled flush, streaming
markdown with deferred highlight, 160-row windowing, swarm state in its
own store slice with per-run coalescing), correctness (FIFO tool matching,
retry preserves request semantics, display/request separation, cache-hit
session refresh, multi-tab message.received), guided-minimalism welcome
(time-aware greeting + brand lockup + 4 quick actions + collapsible
library, fill-first affordance), SwarmDashboard rebuild with honest
indeterminate progress, five-locale copy de-jargoning with lazy locale
chunks, accessibility Level-A fixes (color-blind metric glyphs, aria-live
streaming, skip link, focus-visible affordances), robustness (markdown
link/table/KaTeX overflow, safe localStorage, portal modal), and '/' now
opens the product with marketing moved to /about.
This commit is contained in:
Haozhe Wu committed 2026-07-29 23:07:35 +08:00
1 parent c8741e34bf
commit 71933ecf31
80 files changed
+4108 -1094

No files matched your search

+3
View File
@@ -20,3 +20,6 @@ repository as factual mathematical content (formulas are not copyrightable):
Prose, tables, and figures from the source papers and reports are NOT
reproduced in this repository. Only the mathematical formulas — which are
factual content and not subject to copyright — are reimplemented here.
This product bundles Inter and JetBrains Mono fonts from @fontsource 5.3.0
under the SIL Open Font License 1.1. See frontend/public/fonts/LICENSE.
+134 -4
View File
@@ -3,10 +3,142 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Vibe-Trading — vibe trading with your professional financial agent team</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Fonts are self-hosted via @fontsource (imported in src/main.tsx) so no
external font CDN is contacted — see VT-006 / the CSP font-src policy. -->
<link rel="preload" href="/fonts/inter-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/inter-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin />
<style>
html { background-color: hsl(220 20% 98%); }
html.dark { background-color: hsl(220 20% 5%); }
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("/fonts/inter-latin-ext-400-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("/fonts/inter-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("/fonts/inter-latin-ext-500-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("/fonts/inter-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 600;
src: url("/fonts/inter-latin-ext-600-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 600;
src: url("/fonts/inter-latin-600-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 700;
src: url("/fonts/inter-latin-ext-700-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 700;
src: url("/fonts/inter-latin-700-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("/fonts/jetbrains-mono-latin-ext-400-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("/fonts/jetbrains-mono-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("/fonts/jetbrains-mono-latin-ext-500-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("/fonts/jetbrains-mono-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 700;
src: url("/fonts/jetbrains-mono-latin-ext-700-normal.woff2") format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 700;
src: url("/fonts/jetbrains-mono-latin-700-normal.woff2") format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
</style>
<script src="/theme-boot.js"></script>
<meta name="description" content="Vibe-Trading — vibe trading with your professional financial agent team. Natural-language finance research, backtesting, swarm workflows, document reading, and multi-market analysis." />
</head>
<body class="min-h-screen bg-background text-foreground antialiased">
@@ -14,5 +146,3 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4 -4
View File
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#1a365d"/>
<rect width="32" height="32" rx="6" fill="hsl(27 90% 52%)"/>
<!-- V -->
<path d="M6 8 L11 24 L16 8" stroke="#22d3ee" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M6 8 L11 24 L16 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- T -->
<line x1="18" y1="8" x2="28" y2="8" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round"/>
<line x1="23" y1="8" x2="23" y2="24" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round"/>
<line x1="18" y1="8" x2="28" y2="8" stroke="rgba(255,255,255,0.75)" stroke-width="3" stroke-linecap="round"/>
<line x1="23" y1="8" x2="23" y2="24" stroke="rgba(255,255,255,0.75)" stroke-width="3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 481 B

After

Width:  |  Height:  |  Size: 519 B

+96
View File
@@ -0,0 +1,96 @@
Inter
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
Font files distributed via @fontsource/inter 5.3.0.
JetBrains Mono
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
Font files distributed via @fontsource/jetbrains-mono 5.3.0.
Both font families are licensed under the SIL Open Font License, Version 1.1.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font Software,
subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
(() => {
const root = document.documentElement;
let savedTheme = null;
try {
savedTheme = window.localStorage.getItem("qa-theme");
} catch {
// Storage can be unavailable in restricted iframes and WebViews.
}
let prefersDark = false;
if (savedTheme !== "dark" && savedTheme !== "light" && typeof window.matchMedia === "function") {
try {
prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
} catch {
// Fall back to light when the media-query API is present but unusable.
}
}
const dark = savedTheme === "dark" || (savedTheme !== "light" && prefersDark);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
})();
@@ -7,7 +7,7 @@ import { calcMA, calcBOLL, calcMACD, calcRSI, calcKDJ, calcEMA } from "@/lib/ind
import { getChartTheme } from "@/lib/chart-theme";
import { abbreviateNum } from "@/lib/formatters";
import { echarts, CHART_GROUP, connectCharts } from "@/lib/echarts";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useThemeDark } from "@/lib/theme-store";
type Sub = "vol" | "macd" | "rsi" | "kdj";
type Range = "1M" | "3M" | "6M" | "1Y" | "ALL";
@@ -40,7 +40,7 @@ export function CandlestickChart({ data, markers, indicators, height = 500 }: Pr
const [range, setRange] = useState<Range>("ALL");
const [overlays, setOverlays] = useState<Set<Overlay>>(new Set(["ma5", "ma20"]));
const [showMenu, setShowMenu] = useState(false);
const { dark } = useDarkMode();
const dark = useThemeDark();
const toggleOverlay = useCallback((id: Overlay) => {
setOverlays(prev => {
@@ -92,9 +92,21 @@ export function CandlestickChart({ data, markers, indicators, height = 500 }: Pr
connectCharts();
chartRef.current = chart;
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(containerRef.current);
return () => { ro.disconnect(); chart.dispose(); chartRef.current = null; };
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
chartRef.current = null;
};
}, [data.length === 0, dark]); // only re-init when going empty↔non-empty or theme changes
// Update chart options — setOption on existing instance, no dispose
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import i18n from "@/i18n";
import { echarts } from "@/lib/echarts";
import { getChartTheme } from "@/lib/chart-theme";
import { useThemeDark } from "@/lib/theme-store";
interface Props {
labels: string[];
@@ -11,6 +12,7 @@ interface Props {
export function CorrelationMatrix({ labels, matrix, height = 500 }: Props) {
const ref = useRef<HTMLDivElement>(null);
const dark = useThemeDark();
useEffect(() => {
if (!ref.current || labels.length === 0 || matrix.length === 0) return;
@@ -97,10 +99,21 @@ export function CorrelationMatrix({ labels, matrix, height = 500 }: Props) {
],
});
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(ref.current!);
return () => { ro.disconnect(); chart.dispose(); };
}, [labels, matrix]);
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [labels, matrix, dark]);
if (labels.length === 0) {
return <div className="text-muted-foreground text-sm p-4">{i18n.t("charts.noCorrelationData")}</div>;
+15 -4
View File
@@ -4,7 +4,7 @@ import type { EquityPoint } from "@/lib/api";
import { getChartTheme } from "@/lib/chart-theme";
import { abbreviateNum } from "@/lib/formatters";
import { echarts, CHART_GROUP, connectCharts } from "@/lib/echarts";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useThemeDark } from "@/lib/theme-store";
interface Props {
data: EquityPoint[];
@@ -13,7 +13,7 @@ interface Props {
export function EquityChart({ data, height = 300 }: Props) {
const ref = useRef<HTMLDivElement>(null);
const { dark } = useDarkMode();
const dark = useThemeDark();
useEffect(() => {
if (!ref.current || data.length === 0) return;
@@ -101,9 +101,20 @@ export function EquityChart({ data, height = 300 }: Props) {
],
});
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(ref.current!);
return () => { ro.disconnect(); chart.dispose(); };
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [data, dark]);
if (data.length === 0) {
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { echarts } from "@/lib/echarts";
import { getChartTheme } from "@/lib/chart-theme";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useThemeDark } from "@/lib/theme-store";
interface Props {
data: Array<{ time: string; equity: number | string }>;
@@ -10,7 +10,7 @@ interface Props {
export function MiniEquityChart({ data, height = 80 }: Props) {
const ref = useRef<HTMLDivElement>(null);
const { dark } = useDarkMode();
const dark = useThemeDark();
useEffect(() => {
if (!ref.current || data.length < 2) return;
@@ -37,9 +37,20 @@ export function MiniEquityChart({ data, height = 80 }: Props) {
}],
});
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(ref.current);
return () => { ro.disconnect(); chart.dispose(); };
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [data, dark]);
if (data.length < 2) return null;
@@ -3,7 +3,7 @@ import i18n from "@/i18n";
import type { CorrelationRegimeResponse } from "@/lib/api";
import { getChartTheme } from "@/lib/chart-theme";
import { echarts } from "@/lib/echarts";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useThemeDark } from "@/lib/theme-store";
interface Props {
data: CorrelationRegimeResponse;
@@ -12,7 +12,7 @@ interface Props {
export function RegimeTimeline({ data, height = 260 }: Props) {
const ref = useRef<HTMLDivElement>(null);
const { dark } = useDarkMode();
const dark = useThemeDark();
useEffect(() => {
if (!ref.current || data.dates.length === 0) return;
@@ -123,9 +123,20 @@ export function RegimeTimeline({ data, height = 260 }: Props) {
],
});
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(ref.current!);
return () => { ro.disconnect(); chart.dispose(); };
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [data, dark]);
if (data.dates.length === 0) return null;
+4 -2
View File
@@ -1,7 +1,9 @@
import { BrandMark } from "@/components/common/BrandMark";
export function AgentAvatar() {
return (
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-[#1a365d] to-[#0891b2] flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5 select-none">
P
<div className="h-8 w-8 shrink-0 mt-0.5" aria-hidden="true">
<BrandMark className="h-8 w-8" />
</div>
);
}
@@ -1,4 +1,4 @@
import { memo, useEffect, useState, useCallback } from "react";
import { memo, useEffect, useState, useCallback, useMemo } from "react";
import { cn } from "@/lib/utils";
import type { AgentMessage } from "@/types/agent";
@@ -10,9 +10,10 @@ interface Props {
export const ConversationTimeline = memo(function ConversationTimeline({ messages, containerRef }: Props) {
const [activeIdx, setActiveIdx] = useState(-1);
const userIndices = messages
const userIndices = useMemo(() => messages
.map((m, i) => m.type === "user" ? i : -1)
.filter(i => i >= 0);
.filter(i => i >= 0)
.slice(-40), [messages]);
useEffect(() => {
const container = containerRef.current;
+127 -20
View File
@@ -1,15 +1,15 @@
import i18n from '@/i18n';
import { memo, useState, useCallback } from "react";
import { User, XCircle, RefreshCw, Copy, Check } from "lucide-react";
import { Component, memo, useState, useCallback, type ReactNode } from "react";
import { XCircle, RefreshCw, Copy, Check, Paperclip, Users, Target } from "lucide-react";
import ReactMarkdown, { type Options as ReactMarkdownOptions } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { formatTimestamp } from "@/lib/formatters";
import { normalizeMathDelimiters } from "@/lib/markdown";
import type { AgentMessage } from "@/types/agent";
import type { StoredAgentMessage } from "@/stores/agent";
import { AgentAvatar } from "./AgentAvatar";
import { RunCompleteCard } from "./RunCompleteCard";
@@ -20,6 +20,87 @@ const remarkPlugins: ReactMarkdownOptions["remarkPlugins"] = [
[remarkMath, { singleDollarTextMath: false }],
];
const rehypePlugins: ReactMarkdownOptions["rehypePlugins"] = [rehypeHighlight, rehypeKatex];
const markdownComponents: ReactMarkdownOptions["components"] = {
table: ({ node, ...props }) => {
void node;
return (
<div className="overflow-x-auto">
<table {...props} />
</div>
);
},
a: ({ node, ...props }) => {
void node;
return <a {...props} target="_blank" rel="noopener noreferrer" />;
},
};
const proseClassName = "prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed prose-table:border prose-table:border-border/50 prose-th:bg-muted/30 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-th:text-left prose-th:text-xs prose-th:font-medium prose-td:text-xs prose-hr:hidden";
interface MarkdownErrorBoundaryProps {
content: string;
children: ReactNode;
}
interface MarkdownErrorBoundaryState {
failed: boolean;
}
class MarkdownErrorBoundary extends Component<MarkdownErrorBoundaryProps, MarkdownErrorBoundaryState> {
state: MarkdownErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): MarkdownErrorBoundaryState {
return { failed: true };
}
componentDidUpdate(previous: MarkdownErrorBoundaryProps) {
if (this.state.failed && previous.content !== this.props.content) {
this.setState({ failed: false });
}
}
render() {
if (this.state.failed) {
return <span className="whitespace-pre-wrap">{this.props.content}</span>;
}
return this.props.children;
}
}
interface MarkdownContentProps {
content: string;
streaming?: boolean;
showCursor?: boolean;
}
export const MarkdownContent = memo(function MarkdownContent({
content,
streaming = false,
showCursor = false,
}: MarkdownContentProps) {
let normalized = content;
try {
normalized = normalizeMathDelimiters(content);
} catch {
normalized = content;
}
return (
<div className={proseClassName}>
<MarkdownErrorBoundary content={content}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={streaming ? [] : rehypePlugins}
components={markdownComponents}
>
{normalized}
</ReactMarkdown>
</MarkdownErrorBoundary>
{showCursor && (
<span className="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse align-middle" />
)}
</div>
);
});
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -29,13 +110,21 @@ function CopyButton({ text }: { text: string }) {
setTimeout(() => setCopied(false), 1500);
});
}, [text]);
const label = copied ? i18n.t("messageBubble.copied") : i18n.t("messageBubble.copy");
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
title={copied ? i18n.t("messageBubble.copied") : i18n.t("messageBubble.copy")}
className="absolute top-2 right-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
aria-label={label}
title={label}
>
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
{copied && (
<span className="sr-only" role="status">
{i18n.t("messageBubble.copied")}
</span>
)}
</button>
);
}
@@ -52,22 +141,43 @@ function getRetryHint(content: string): string {
}
interface Props {
msg: AgentMessage;
msg: StoredAgentMessage;
onRetry?: (msg: AgentMessage) => void;
}
export const MessageBubble = memo(function MessageBubble({ msg, onRetry }: Props) {
const ts = msg.timestamp ? formatTimestamp(msg.timestamp) : null;
if (msg.type === "user") {
const meta = msg.meta;
return (
<div className="flex justify-end gap-3 group">
<div className="max-w-[72%] rounded-2xl rounded-tr-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap">
{msg.content}
{ts && <span className="block text-[9px] opacity-50 text-right mt-1">{ts}</span>}
<div className="flex justify-end group">
<div className="max-w-[72%] max-h-[40vh] overflow-y-auto break-words rounded-[18px] bg-muted px-4 py-3 text-[15px] text-foreground leading-relaxed whitespace-pre-wrap">
{meta && (meta.attachment || meta.swarmMode || meta.goalMode) && (
<div className="mb-1.5 flex flex-wrap justify-end gap-1.5 text-[10px] leading-none text-muted-foreground">
{meta.attachment && (
<span
className="inline-flex max-w-full items-center gap-1 rounded-full bg-background/60 px-2 py-1 text-muted-foreground"
title={i18n.t("agent.attachmentChip" as never)}
>
<Paperclip className="h-3 w-3 shrink-0" />
<span className="sr-only">{i18n.t("agent.attachmentChip" as never)}: </span>
<span className="truncate">{meta.attachment.filename}</span>
</span>
)}
{meta.swarmMode && (
<span className="inline-flex items-center gap-1 rounded-full bg-background/60 px-2 py-1 text-muted-foreground">
<Users className="h-3 w-3" />
{i18n.t("agent.swarmModeChip" as never)}
</span>
)}
{meta.goalMode && (
<span className="inline-flex items-center gap-1 rounded-full bg-background/60 px-2 py-1 text-muted-foreground">
<Target className="h-3 w-3" />
{i18n.t("agent.goalModeChip" as never)}
</span>
)}
</div>
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User className="h-4 w-4 text-muted-foreground" />
)}
{msg.content}
</div>
</div>
);
@@ -75,14 +185,11 @@ export const MessageBubble = memo(function MessageBubble({ msg, onRetry }: Props
if (msg.type === "answer") {
return (
<div className="flex gap-3 group">
<div className="flex gap-3 group relative">
<AgentAvatar />
<div className="flex-1 min-w-0 relative">
<div className="flex-1 min-w-0 space-y-1.5">
<CopyButton text={msg.content} />
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed prose-table:border prose-table:border-border/50 prose-th:bg-muted/30 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-th:text-left prose-th:text-xs prose-th:font-medium prose-td:text-xs prose-hr:hidden">
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins}>{normalizeMathDelimiters(msg.content)}</ReactMarkdown>
</div>
{ts && <span className="text-[9px] text-muted-foreground/30 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">{ts}</span>}
<MarkdownContent content={msg.content} />
</div>
</div>
);
+16 -1
View File
@@ -1,4 +1,5 @@
import { memo } from "react";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { getMetricLabel, DISPLAY_ORDER, formatMetricVal, metricSentiment } from "@/lib/formatters";
@@ -8,6 +9,12 @@ const SENTIMENT = {
negative: "text-danger",
} as const;
// Verdict must never be color-only: metricSentiment is a threshold judgment
// (Sharpe +0.20 renders red, +1.50 green — identical glyphs otherwise), so a
// redundant shape + sr-only word carries it for color-blind and AT users.
const SENTIMENT_GLYPH: Partial<Record<string, string>> = { positive: "▲", negative: "▼" };
const SENTIMENT_SR_KEY: Partial<Record<string, string>> = { positive: "metrics.good", negative: "metrics.poor" };
interface Props {
metrics: Record<string, number>;
compact?: boolean;
@@ -33,10 +40,18 @@ export const MetricsCard = memo(function MetricsCard({ metrics, compact = false
{getMetricLabel(k)}
</p>
<p className={cn(
"text-sm font-bold font-mono tabular-nums mt-0.5",
"text-sm font-semibold font-mono tabular-nums mt-0.5",
SENTIMENT[metricSentiment(k, v)]
)}>
{SENTIMENT_GLYPH[metricSentiment(k, v)] != null && (
<span aria-hidden="true" className="me-0.5 text-[10px] align-middle">
{SENTIMENT_GLYPH[metricSentiment(k, v)]}
</span>
)}
{formatMetricVal(k, v)}
{SENTIMENT_SR_KEY[metricSentiment(k, v)] != null && (
<span className="sr-only"> {i18n.t(SENTIMENT_SR_KEY[metricSentiment(k, v)]! as never)}</span>
)}
</p>
</div>
))}
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { memo, useState, useCallback } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, Copy, Check, ExternalLink } from "lucide-react";
interface Props {
@@ -10,6 +11,22 @@ interface Props {
export const PineScriptViewer = memo(function PineScriptViewer({ code, onClose }: Props) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement;
closeRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus();
};
}, [onClose]);
const handleCopy = useCallback(async () => {
try {
@@ -29,9 +46,12 @@ export const PineScriptViewer = memo(function PineScriptViewer({ code, onClose }
}
}, [code]);
return (
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t("pineViewer.pineScript")}
className="relative w-full max-w-3xl max-h-[80vh] mx-4 rounded-xl border bg-background shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
@@ -59,6 +79,9 @@ export const PineScriptViewer = memo(function PineScriptViewer({ code, onClose }
{t("pineViewer.docs")}
</a>
<button
ref={closeRef}
type="button"
aria-label={t("layout.cancel")}
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
@@ -81,6 +104,7 @@ export const PineScriptViewer = memo(function PineScriptViewer({ code, onClose }
</p>
</div>
</div>
</div>
</div>,
document.body,
);
});
@@ -1,14 +1,23 @@
import { useTranslation } from 'react-i18next';
import { memo, useEffect, useState, useCallback } from "react";
import { lazy, memo, Suspense, useCallback, useEffect, useState } 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";
const MiniEquityChart = lazy(() =>
import("@/components/charts/MiniEquityChart").then((module) => ({
default: module.MiniEquityChart,
})),
);
function MiniEquityChartSkeleton() {
return <div className="h-20 rounded-lg bg-muted/40 animate-pulse" />;
}
interface Props {
msg: AgentMessage;
}
@@ -16,6 +25,7 @@ interface Props {
export const RunCompleteCard = memo(function RunCompleteCard({ msg }: Props) {
const { t } = useTranslation();
const [curve, setCurve] = useState(msg.equityCurve);
const [curveLoading, setCurveLoading] = useState(!msg.equityCurve && Boolean(msg.runId));
const [pineCode, setPineCode] = useState<string | null>(null);
const [pineLoading, setPineLoading] = useState(false);
const [showPine, setShowPine] = useState(false);
@@ -23,11 +33,21 @@ export const RunCompleteCard = memo(function RunCompleteCard({ msg }: Props) {
const [pineExists, setPineExists] = useState(false);
useEffect(() => {
if (!curve && msg.runId) {
if (curve || !msg.runId) return;
let cancelled = false;
setCurveLoading(true);
api.getRun(msg.runId).then(r => {
if (r.equity_curve) setCurve(r.equity_curve.map(e => ({ time: e.time, equity: e.equity })));
}).catch(() => {});
if (!cancelled && r.equity_curve) {
setCurve(r.equity_curve.map(e => ({ time: e.time, equity: e.equity })));
}
}).catch(() => {}).finally(() => {
if (!cancelled) setCurveLoading(false);
});
return () => {
cancelled = true;
};
}, [msg.runId, curve]);
// Check if Pine Script exists for this run (skip for shadow-only cards with no runId)
@@ -72,9 +92,13 @@ export const RunCompleteCard = memo(function RunCompleteCard({ msg }: Props) {
{msg.metrics && Object.keys(msg.metrics).length > 0 && (
<MetricsCard metrics={msg.metrics} compact />
)}
{curve && curve.length > 1 && (
{curveLoading ? (
<MiniEquityChartSkeleton />
) : curve && curve.length > 1 ? (
<Suspense fallback={<MiniEquityChartSkeleton />}>
<MiniEquityChart data={curve} height={80} />
)}
</Suspense>
) : null}
<div className="flex items-center gap-3 flex-wrap">
{msg.runId && (
<Link
@@ -0,0 +1,322 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import {
CheckCircle2,
Circle,
Loader2,
RotateCcw,
ShieldAlert,
XCircle,
} from "lucide-react";
import {
localizeStatus,
type ManifestTranslation,
} from "@/lib/swarmI18n";
import { localizeToolName } from "@/lib/tools";
import type {
SwarmAgentDisplayStatus,
SwarmAgentStatus,
SwarmRunStatus,
} from "@/types/agent";
interface Props {
agents: SwarmRunStatus["agents"];
emptyStatus?: string;
isActive?: boolean;
}
const WIDE_GRID_COLUMNS =
"grid-cols-[minmax(0,1.25fr)_minmax(0,0.85fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1.5fr)]";
const AGENT_ENTER_ANIMATION =
"animate-[msg-enter_300ms_cubic-bezier(0.16,1,0.3,1)_both] motion-reduce:animate-none";
function formatElapsed(
seconds: number | undefined,
t: ManifestTranslation,
): string {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return "-";
if (seconds < 60) {
return t("swarm.elapsed.seconds", {
value: seconds.toFixed(seconds < 10 ? 1 : 0),
});
}
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return t("swarm.elapsed.minutesSeconds", { minutes: mins, seconds: secs });
}
function statusTone(status: SwarmAgentDisplayStatus): string {
switch (status) {
case "done":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "failed":
return "bg-destructive/10 text-destructive";
case "blocked":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "retry":
return "bg-sky-500/10 text-sky-600 dark:text-sky-400";
case "running":
return "bg-primary/10 text-primary";
case "cancelled":
case "waiting":
default:
return "bg-muted text-muted-foreground";
}
}
function StatusIcon({
animate,
status,
}: {
animate: boolean;
status: SwarmAgentDisplayStatus;
}) {
switch (status) {
case "done":
return <CheckCircle2 className="h-3 w-3" />;
case "failed":
return <XCircle className="h-3 w-3" />;
case "blocked":
return <ShieldAlert className="h-3 w-3" />;
case "retry":
return <RotateCcw className="h-3 w-3" />;
case "running":
return (
<Loader2 className={animate ? "h-3 w-3 animate-spin" : "h-3 w-3"} />
);
case "cancelled":
return <XCircle className="h-3 w-3" />;
case "waiting":
default:
return <Circle className="h-3 w-3" />;
}
}
function StatusPill({
agentStatus,
animate,
t,
}: {
agentStatus: SwarmAgentDisplayStatus;
animate: boolean;
t: ManifestTranslation;
}) {
return (
<span
className={[
"inline-flex max-w-full min-w-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium capitalize",
"transition-colors duration-300",
statusTone(agentStatus),
].join(" ")}
>
<span className="shrink-0">
<StatusIcon animate={animate} status={agentStatus} />
</span>
<span className="truncate">{localizeStatus(agentStatus, t)}</span>
</span>
);
}
function agentKey(agent: SwarmAgentStatus): string {
return agent.taskId || agent.agentId;
}
function WideAgentRow({
agent,
isActive,
t,
}: {
agent: SwarmAgentStatus;
isActive: boolean;
t: ManifestTranslation;
}) {
const output = agent.error || agent.lastText || "-";
return (
<div
className={[
"grid min-w-0 items-center gap-2 rounded-md px-2 py-2 text-xs hover:bg-muted/30",
WIDE_GRID_COLUMNS,
AGENT_ENTER_ANIMATION,
].join(" ")}
role="row"
>
<div className="min-w-0" role="cell">
<div className="truncate font-medium text-foreground">
{agent.agentId}
</div>
{agent.role && (
<div className="truncate text-[10px] text-muted-foreground">
{agent.role}
</div>
)}
</div>
<div className="min-w-0" role="cell">
<StatusPill
agentStatus={agent.status}
animate={isActive}
t={t}
/>
</div>
<div
className="truncate font-mono text-[11px] text-muted-foreground"
role="cell"
title={agent.tool || ""}
>
{agent.tool ? localizeToolName(agent.tool, agent.tool) : "-"}
</div>
<div
className="min-w-0 text-end font-mono text-[11px] text-muted-foreground"
role="cell"
>
<div className="truncate">{formatElapsed(agent.elapsed_s, t)}</div>
{agent.iterations != null && (
<div
className="inline-flex items-center justify-end gap-1 text-[10px]"
title={t("swarmStatus.iters")}
>
<RotateCcw className="h-2.5 w-2.5" aria-hidden="true" />
{agent.iterations}
</div>
)}
</div>
<div
className={[
"min-w-0 truncate text-[11px]",
agent.error ? "text-destructive" : "text-muted-foreground",
].join(" ")}
role="cell"
title={output}
>
{output}
</div>
</div>
);
}
function NarrowAgentCard({
agent,
isActive,
t,
}: {
agent: SwarmAgentStatus;
isActive: boolean;
t: ManifestTranslation;
}) {
return (
<div
className={[
"min-w-0 rounded-md border bg-muted/20 px-2.5 py-2",
AGENT_ENTER_ANIMATION,
].join(" ")}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-foreground">
{agent.agentId}
</div>
{agent.role && (
<div className="truncate text-[10px] text-muted-foreground">
{agent.role}
</div>
)}
</div>
<StatusPill
agentStatus={agent.status}
animate={isActive}
t={t}
/>
</div>
<div className="mt-2 flex min-w-0 items-center justify-between gap-3 font-mono text-[10px] text-muted-foreground">
<span className="min-w-0 truncate" title={agent.tool || ""}>
<span className="sr-only">{t("swarmStatus.tool")}</span>
{agent.tool ? localizeToolName(agent.tool, agent.tool) : "-"}
</span>
<span className="shrink-0">
<span className="sr-only">{t("swarmStatus.time")}</span>
{formatElapsed(agent.elapsed_s, t)}
</span>
</div>
</div>
);
}
export const SwarmDashboard = memo(function SwarmDashboard({
agents,
emptyStatus,
isActive = true,
}: Props) {
const { t: typedT } = useTranslation();
const t = typedT as unknown as ManifestTranslation;
return (
<div className="mt-3 min-w-0">
{agents.length === 0 ? (
<div className="py-3 text-xs text-muted-foreground">
{isActive ? (
t("swarmStatus.teamStarting")
) : (
<>
{emptyStatus || localizeStatus("unknown", t)}
{" — "}
{t("swarmStatus.detailsUnavailable")}
</>
)}
</div>
) : (
<>
<div
className="hidden min-w-0 sm:block"
data-agent-layout="wide"
role="table"
>
<div
className={[
"grid min-w-0 gap-2 border-b px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground",
WIDE_GRID_COLUMNS,
].join(" ")}
role="row"
>
<span className="truncate" role="columnheader">
{t("swarmStatus.agent")}
</span>
<span className="truncate" role="columnheader">
{t("swarmStatus.status")}
</span>
<span className="truncate" role="columnheader">
{t("swarmStatus.tool")}
</span>
<span className="truncate text-end" role="columnheader">
{t("swarmStatus.time")}
</span>
<span className="truncate" role="columnheader">
{t("swarmStatus.output")}
</span>
</div>
<div className="divide-y" role="rowgroup">
{agents.map((agent) => (
<WideAgentRow
agent={agent}
isActive={isActive}
key={agentKey(agent)}
t={t}
/>
))}
</div>
</div>
<div className="space-y-2 sm:hidden" data-agent-layout="narrow">
{agents.map((agent) => (
<NarrowAgentCard
agent={agent}
isActive={isActive}
key={agentKey(agent)}
t={t}
/>
))}
</div>
</>
)}
</div>
);
});
+134 -122
View File
@@ -1,69 +1,109 @@
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
import { memo } from "react";
import { Clock, Users } from "lucide-react";
import { SwarmDashboard } from "@/components/chat/SwarmDashboard";
import {
CheckCircle2,
Circle,
Clock,
Loader2,
RotateCcw,
ShieldAlert,
Users,
XCircle,
} from "lucide-react";
import { ProgressBar } from "@/components/chat/ProgressBar";
import { localizeToolName } from "@/lib/tools";
import type { SwarmAgentDisplayStatus, SwarmRunStatus } from "@/types/agent";
localizePreset,
localizeStatus,
type ManifestTranslation,
} from "@/lib/swarmI18n";
import type { SwarmRunStatus } from "@/types/agent";
interface Props {
status: SwarmRunStatus;
}
function formatElapsed(seconds: number | undefined): string {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return "-";
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
function LayerStepper({
status,
t,
}: {
status: SwarmRunStatus;
t: ManifestTranslation;
}) {
const hasLayerTotal =
Number.isFinite(status.totalLayers) && status.totalLayers > 0;
const isActive = status.status === "pending" || status.status === "running";
if (!hasLayerTotal) {
if (!isActive) return null;
return (
<div
className="flex min-w-0 items-center gap-2"
data-layer-state="indeterminate"
role="status"
>
<progress
className="sr-only"
aria-label={t("swarm.preparingLayers")}
/>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full w-1/3 rounded-full bg-primary animate-[pulse-slide_2s_ease-in-out_infinite]" />
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[10px] text-muted-foreground">
<span
className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse"
aria-hidden="true"
/>
{t("swarm.preparingLayers")}
</span>
</div>
);
}
function statusTone(status: SwarmAgentDisplayStatus): string {
switch (status) {
case "done":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "failed":
return "bg-destructive/10 text-destructive";
case "blocked":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "retry":
return "bg-sky-500/10 text-sky-600 dark:text-sky-400";
case "running":
return "bg-primary/10 text-primary";
case "cancelled":
return "bg-muted text-muted-foreground";
case "waiting":
default:
return "bg-muted text-muted-foreground";
}
}
const layerTotal = status.totalLayers;
const layerCurrent = Math.min(
Math.max(
Number.isFinite(status.currentLayer) ? status.currentLayer + 1 : 1,
1,
),
layerTotal,
);
const label = t("swarmStatus.layer", {
current: layerCurrent,
total: layerTotal,
});
const isRunComplete = status.status === "completed";
function StatusIcon({ status }: { status: SwarmAgentDisplayStatus }) {
switch (status) {
case "done":
return <CheckCircle2 className="h-3 w-3" />;
case "failed":
return <XCircle className="h-3 w-3" />;
case "blocked":
return <ShieldAlert className="h-3 w-3" />;
case "retry":
return <RotateCcw className="h-3 w-3" />;
case "running":
return <Loader2 className="h-3 w-3 animate-spin" />;
case "cancelled":
return <XCircle className="h-3 w-3" />;
case "waiting":
default:
return <Circle className="h-3 w-3" />;
}
return (
<div
aria-label={label}
aria-valuemax={layerTotal}
aria-valuemin={0}
aria-valuenow={layerCurrent}
aria-valuetext={label}
className="grid h-1.5 min-w-0 gap-1"
data-layer-state="determinate"
role="progressbar"
style={{
gridTemplateColumns: `repeat(${layerTotal}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: layerTotal }, (_, index) => {
const isDone = isRunComplete || index < layerCurrent - 1;
const isCurrent = !isRunComplete && index === layerCurrent - 1;
return (
<span
aria-hidden="true"
className={[
"relative h-1.5 min-w-0 overflow-hidden rounded-full transition-colors duration-300",
isDone
? "bg-primary"
: isCurrent
? "bg-primary/30"
: "bg-muted",
].join(" ")}
key={index}
>
{isActive && isCurrent && (
<span className="absolute inset-0 rounded-full bg-primary animate-pulse motion-reduce:animate-none" />
)}
</span>
);
})}
</div>
);
}
function runTone(status: SwarmRunStatus["status"]): string {
@@ -84,11 +124,14 @@ function runTone(status: SwarmRunStatus["status"]): string {
}
export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props) {
const { t } = useTranslation();
const done = status.agents.filter((agent) => ["done", "failed", "blocked", "cancelled"].includes(agent.status)).length;
const { t: typedT } = useTranslation();
const t = typedT as unknown as ManifestTranslation;
const done = status.agents.filter((agent) =>
["done", "failed", "blocked", "cancelled"].includes(agent.status),
).length;
const total = status.agents.length;
const layerTotal = Math.max(status.totalLayers, status.currentLayer + 1, 1);
const layerCurrent = Math.min(status.currentLayer + 1, layerTotal);
const hasAgentTotal = total > 0;
const isActive = status.status === "pending" || status.status === "running";
return (
<div className="flex gap-3">
@@ -98,78 +141,47 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
<div className="min-w-0 flex-1 rounded-lg border bg-background p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">{status.preset}</span>
<span className={["shrink-0 text-xs font-medium capitalize", runTone(status.status)].join(" ")}>
{status.status.replace(/_/g, " ")}
<span className="truncate text-sm font-semibold text-foreground">
{localizePreset(status.preset, t)}
</span>
<span
className={[
"shrink-0 text-xs font-medium capitalize",
runTone(status.status),
].join(" ")}
>
{localizeStatus(status.status, t)}
</span>
</div>
{(hasAgentTotal || isActive) && (
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{t('swarmStatus.agents', { done, total: total || 0 })}</span>
</div>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,1fr)_9rem] sm:items-center">
<ProgressBar
current={total ? done : 0}
total={Math.max(total, 1)}
height="xs"
showCount
ariaLabel="Swarm agent progress"
{hasAgentTotal ? (
<span>{t("swarmStatus.agents", { done, total })}</span>
) : (
<span className="inline-flex items-center gap-1.5" role="status">
<span
className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse"
aria-hidden="true"
/>
<div className="text-right font-mono text-[11px] text-muted-foreground">
{t('swarmStatus.layer', { current: layerCurrent, total: layerTotal })}
</div>
</div>
<div className="mt-3 overflow-x-auto">
<div className="min-w-[620px]">
<div className="grid grid-cols-[10rem_7rem_9rem_5rem_4rem_minmax(0,1fr)] gap-2 border-b pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>{t('swarmStatus.agent')}</span>
<span>{t('swarmStatus.status')}</span>
<span>{t('swarmStatus.tool')}</span>
<span className="text-right">{t('swarmStatus.time')}</span>
<span className="text-right">{t('swarmStatus.iters')}</span>
<span>{t('swarmStatus.output')}</span>
</div>
<div className="divide-y">
{status.agents.map((agent) => (
<div
key={`${agent.taskId || agent.agentId}`}
className="grid grid-cols-[10rem_7rem_9rem_5rem_4rem_minmax(0,1fr)] gap-2 py-2 text-xs"
>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{agent.agentId}</div>
{agent.role && <div className="truncate text-[10px] text-muted-foreground">{agent.role}</div>}
</div>
<div>
<span className={["inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium capitalize", statusTone(agent.status)].join(" ")}>
<StatusIcon status={agent.status} />
{agent.status}
<span>{t("swarm.assemblingTeam")}</span>
</span>
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground" title={agent.tool || ""}>
{agent.tool ? localizeToolName(agent.tool, agent.tool) : "-"}
</div>
<div className="text-right font-mono text-[11px] text-muted-foreground">
{formatElapsed(agent.elapsed_s)}
</div>
<div className="text-right font-mono text-[11px] text-muted-foreground">
{agent.iterations ?? "-"}
</div>
<div className={["truncate text-[11px]", agent.error ? "text-destructive" : "text-muted-foreground"].join(" ")} title={agent.error || agent.lastText || ""}>
{agent.error || agent.lastText || "-"}
</div>
</div>
))}
{status.agents.length === 0 && (
<div className="py-3 text-xs text-muted-foreground">
{t('swarmStatus.waitingForEvents')}
)}
</div>
)}
</div>
{(status.totalLayers > 0 || isActive) && (
<div className="mt-3">
<LayerStepper status={status} t={t} />
</div>
</div>
)}
<SwarmDashboard
agents={status.agents}
emptyStatus={localizeStatus(status.status, t)}
isActive={isActive}
/>
</div>
</div>
);
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useMemo, memo } from "react";
import { memo, useId, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, CheckCircle2, XCircle, Circle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { localizeToolName } from "@/lib/tools";
@@ -12,16 +12,10 @@ interface Props {
export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLatest = false }: Props) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(isLatest);
const toolLabel = (tool?: string): string => {
if (!tool) return t('thinking.processing');
return localizeToolName(tool);
};
useEffect(() => {
if (!isLatest) setExpanded(false);
}, [isLatest]);
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const detailsId = useId();
const expanded = userOpen ?? isLatest;
const processingLabel = t('thinking.processing');
const { steps, hasError, isRunning, totalMs, latestTool, latestThinking } = useMemo(() => {
let totalMs = 0;
@@ -33,7 +27,7 @@ export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLat
for (const m of messages) {
if (m.type === "thinking" && m.content) latestThinking = m.content;
if (m.type === "tool_call") {
steps.push({ tool: m.tool || "", label: toolLabel(m.tool), status: m.status === "running" ? "running" : "ok", elapsed_ms: undefined });
steps.push({ tool: m.tool || "", label: m.tool ? localizeToolName(m.tool) : processingLabel, status: m.status === "running" ? "running" : "ok", elapsed_ms: undefined });
if (m.status === "running") latestTool = m.tool || "";
}
if (m.type === "tool_result") {
@@ -54,19 +48,21 @@ export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLat
latestTool,
latestThinking,
};
}, [messages]);
}, [messages, processingLabel]);
const stepCount = steps.length;
const summaryText = isRunning
? `${t('thinking.running')} ${toolLabel(latestTool)}...`
? `${t('thinking.running')} ${latestTool ? localizeToolName(latestTool) : processingLabel}...`
: `${t('thinking.done')} · ${t('thinking.steps', { count: stepCount })}${totalMs > 0 ? ` · ${(totalMs / 1000).toFixed(1)}s` : ""}`;
return (
<div className="rounded-lg border border-border/40 bg-muted/5 overflow-hidden">
{/* Summary bar */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs hover:bg-muted/10 transition-colors"
aria-controls={detailsId}
aria-expanded={expanded}
onClick={() => setUserOpen(!expanded)}
className="w-full min-w-0 flex items-center gap-2 px-3 py-2 text-xs hover:bg-muted/10 transition-colors"
>
{expanded
? <ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
@@ -78,7 +74,7 @@ export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLat
) : (
<CheckCircle2 className="h-3 w-3 text-success/70 shrink-0" />
)}
<span className={cn("text-muted-foreground", isRunning && "text-foreground")}>
<span className={cn("min-w-0 flex-1 truncate text-start text-muted-foreground", isRunning && "text-foreground")}>
{summaryText}
</span>
</button>
@@ -92,6 +88,7 @@ export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLat
</div>
)}
<div id={detailsId} hidden={!expanded}>
{/* Expanded step list */}
{expanded && steps.length > 0 && (
<div className="border-t border-border/30 px-3 py-1.5 space-y-0.5">
@@ -139,5 +136,6 @@ export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLat
</div>
)}
</div>
</div>
);
});
@@ -1,4 +1,5 @@
import { useRef, type JSX } from "react";
import { memo, useEffect, useMemo, useRef, useState, type JSX } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProgressBar } from "@/components/chat/ProgressBar";
@@ -12,6 +13,29 @@ interface EtaSample {
suppressed: boolean;
}
function calculateEta(tc: ToolCallEntry, previous?: EtaSample): number | null {
const progress = tc.progress;
if (
!progress
|| typeof progress.current !== "number"
|| typeof progress.total !== "number"
|| progress.total <= 0
) {
return null;
}
const stage = progress.stage || "";
if (previous && progress.current < previous.current) return null;
if (previous?.suppressed && previous.stage === stage) return null;
if (!previous || previous.stage !== stage) return null;
if (progress.current < 3 || progress.current < progress.total * 0.1) return null;
if (tc.elapsed_s == null || tc.elapsed_s <= 0) return null;
const eta = (tc.elapsed_s / progress.current) * (progress.total - progress.current);
if (!isFinite(eta) || eta < 0) return null;
return Math.round(eta);
}
/* ---------- Determinate progress ring ---------- */
interface RingProps {
current: number;
@@ -58,13 +82,12 @@ function ProgressRing({ current, total }: RingProps): JSX.Element {
interface RowProps {
entry: ToolCallEntry;
stepIndex: number;
totalSteps: number;
isHeader?: boolean;
connector?: "branch" | "end" | "none";
eta: number | null;
}
function ToolRow({ entry, stepIndex, totalSteps, isHeader, connector = "none", eta }: RowProps): JSX.Element {
function ToolRow({ entry, stepIndex, connector = "none", eta }: RowProps): JSX.Element {
const { t } = useTranslation();
const progress = entry.progress;
const hasDeterminate = !!(progress && typeof progress.current === "number" && typeof progress.total === "number" && progress.total > 0);
const stage = progress?.stage || "";
@@ -79,14 +102,12 @@ function ToolRow({ entry, stepIndex, totalSteps, isHeader, connector = "none", e
: <Loader2 className="h-3 w-3 animate-spin text-primary shrink-0" />;
const localized = localizeToolName(entry.tool);
const stepLabel = isHeader
? `${totalSteps} tools running`
: `Step ${stepIndex} · ${localized}`;
const stepLabel = t("toolProgress.step" as never, { step: stepIndex, tool: localized });
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-x-2 gap-y-0.5 text-xs min-w-0">
{/* Primary row */}
<div className="flex items-center gap-2 min-w-0 sm:flex-none">
<div className="flex items-center gap-2 min-w-0">
{connector !== "none" && (
<span className="text-border/60 shrink-0 w-3 text-center" aria-hidden="true">
{connector === "branch" ? "├" : "└"}
@@ -95,7 +116,10 @@ function ToolRow({ entry, stepIndex, totalSteps, isHeader, connector = "none", e
{icon}
<span className="text-foreground truncate">{stepLabel}</span>
{entry.elapsed_s != null && (
<span className="ml-auto sm:ml-0 tabular-nums text-[10px] text-muted-foreground/70 shrink-0">
<span
aria-hidden="true"
className="ml-auto sm:ml-0 tabular-nums text-[10px] text-muted-foreground/70 shrink-0"
>
{entry.elapsed_s.toFixed(0)}s
</span>
)}
@@ -117,8 +141,11 @@ function ToolRow({ entry, stepIndex, totalSteps, isHeader, connector = "none", e
/>
)}
{eta != null && (
<span className="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">
~{eta}s left
<span
aria-hidden="true"
className="text-[10px] text-muted-foreground/70 tabular-nums shrink-0"
>
{t("toolProgress.etaSeconds" as never, { seconds: eta })}
</span>
)}
</div>
@@ -135,121 +162,157 @@ function ToolRow({ entry, stepIndex, totalSteps, isHeader, connector = "none", e
/* ---------- Public component ---------- */
interface Props {
/** Full toolCalls slice from the store. The component filters running ones internally. */
/** Full toolCalls slice from the store for the current attempt. */
toolCalls: ToolCallEntry[];
}
const MAX_VISIBLE = 3;
const COLLAPSED_ROW_COUNT = MAX_VISIBLE - 1;
export function ToolProgressIndicator({ toolCalls }: Props): JSX.Element | null {
// Per-tool ETA samples (mutable across renders, not state to avoid re-renders).
interface IndexedToolCall {
entry: ToolCallEntry;
index: number;
}
export const ToolProgressIndicator = memo(function ToolProgressIndicator({
toolCalls,
}: Props): JSX.Element | null {
const { t } = useTranslation();
const [showEarlier, setShowEarlier] = useState(false);
const etaSamplesRef = useRef<Map<string, EtaSample>>(new Map());
const running = toolCalls.filter((tc) => tc.status === "running");
if (running.length === 0) return null;
const { indexedRows, running, anyError, collapsedRows } = useMemo(() => {
const indexedRows: IndexedToolCall[] = [];
const runningRows: IndexedToolCall[] = [];
const terminalRows: IndexedToolCall[] = [];
const running: ToolCallEntry[] = [];
let anyError = false;
toolCalls.forEach((entry, index) => {
const indexed = { entry, index };
indexedRows.push(indexed);
if (entry.status === "running") {
running.push(entry);
runningRows.push(indexed);
} else {
terminalRows.push(indexed);
}
if (entry.status === "error") anyError = true;
});
const collapsedRows = toolCalls.length <= MAX_VISIBLE
? indexedRows
: [
...runningRows.slice(-COLLAPSED_ROW_COUNT),
...terminalRows.reverse(),
].slice(0, COLLAPSED_ROW_COUNT);
return { indexedRows, running, anyError, collapsedRows };
}, [toolCalls]);
const etaById = useMemo(() => {
const eta = new Map<string, number | null>();
for (const entry of running) {
eta.set(entry.id, calculateEta(entry, etaSamplesRef.current.get(entry.id)));
}
return eta;
}, [running]);
useEffect(() => {
const previousSamples = etaSamplesRef.current;
const nextSamples = new Map<string, EtaSample>();
for (const entry of running) {
const progress = entry.progress;
if (!progress || typeof progress.current !== "number") continue;
const stage = progress.stage || "";
const previous = previousSamples.get(entry.id);
const sameStage = previous?.stage === stage;
nextSamples.set(entry.id, {
stage,
current: progress.current,
suppressed: Boolean(
sameStage
&& (previous?.suppressed || progress.current < previous.current),
),
});
}
etaSamplesRef.current = nextSamples;
}, [running]);
if (toolCalls.length === 0) return null;
const totalSoFar = toolCalls.length;
/* ---------- compute ETA for each running tool ---------- */
const computeEta = (tc: ToolCallEntry): number | null => {
const p = tc.progress;
if (!p || typeof p.current !== "number" || typeof p.total !== "number") return null;
if (p.total <= 0) return null;
const stage = p.stage || "";
const samples = etaSamplesRef.current;
const prev = samples.get(tc.tool);
// Out-of-order: current decreased → suppress for the rest of the run.
if (prev && p.current < prev.current) {
samples.set(tc.tool, { stage, current: p.current, suppressed: true });
return null;
}
if (prev?.suppressed && prev.stage === stage) {
// Update tracking but keep suppressed.
samples.set(tc.tool, { stage, current: p.current, suppressed: true });
return null;
}
samples.set(tc.tool, { stage, current: p.current, suppressed: false });
// Need a stable stage and enough samples to extrapolate.
if (!prev || prev.stage !== stage) return null;
if (p.current < 3) return null;
if (p.current < p.total * 0.1) return null;
if (tc.elapsed_s == null || tc.elapsed_s <= 0) return null;
const eta = (tc.elapsed_s / p.current) * (p.total - p.current);
if (!isFinite(eta) || eta < 0) return null;
return Math.round(eta);
};
const rows = showEarlier ? indexedRows : collapsedRows;
const overflow = showEarlier ? 0 : toolCalls.length - rows.length;
const coarseStatus = running.length > 0
? t("toolProgress.toolsRunning" as never, { count: running.length })
: t("toolProgress.toolsCompleted" as never, { count: toolCalls.length });
/* ---------- aggregate icon state for the header row ---------- */
// (Used when 2+ tools are running — header shows multi-tool aggregate.)
// Note: filtered list is `running` so all are still running by construction.
// We still inspect entire toolCalls so an earlier error in this turn shows
// through the aggregate.
const anyError = toolCalls.some((tc) => tc.status === "error");
const aggregateIcon = anyError
? <XCircle className="h-3 w-3 text-danger shrink-0" />
: <Loader2 className="h-3 w-3 animate-spin text-primary shrink-0" />;
// Display rule: ≤3 running → show all; >3 → first 2 + "… +N more".
const showAll = running.length <= MAX_VISIBLE;
const rows = showAll ? running : running.slice(0, 2);
const overflow = showAll ? 0 : running.length - rows.length;
: running.length > 0
? <Loader2 className="h-3 w-3 animate-spin text-primary shrink-0" />
: <CheckCircle2 className="h-3 w-3 text-success shrink-0" />;
/* ---------- render ---------- */
if (running.length === 1) {
const only = running[0];
const eta = computeEta(only);
if (toolCalls.length === 1) {
const only = toolCalls[0];
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="min-w-0"
>
<div className="min-w-0">
<span className="sr-only" role="status" aria-live="polite">
{coarseStatus}
</span>
<ToolRow
entry={only}
stepIndex={totalSoFar}
totalSteps={running.length}
eta={eta}
eta={etaById.get(only.id) ?? null}
/>
</div>
);
}
// 2+ running — header + indented rows.
// 2+ calls — header + indented rows.
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={cn("min-w-0 space-y-1")}
>
<div className={cn("min-w-0 space-y-1")}>
<span className="sr-only" role="status" aria-live="polite">
{coarseStatus}
</span>
{/* Header row */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div
aria-hidden="true"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
{aggregateIcon}
<span className="text-foreground">{running.length} tools running</span>
<span className="text-foreground">{coarseStatus}</span>
</div>
{/* Indented rows */}
<div className="pl-4 space-y-1">
{rows.map((tc, i) => (
{rows.map(({ entry, index }, i) => (
<ToolRow
key={tc.id}
entry={tc}
stepIndex={toolCalls.indexOf(tc) + 1}
totalSteps={running.length}
key={entry.id}
entry={entry}
stepIndex={index + 1}
connector={i === rows.length - 1 && overflow === 0 ? "end" : "branch"}
eta={computeEta(tc)}
eta={etaById.get(entry.id) ?? null}
/>
))}
{overflow > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60">
<span className="text-border/60 shrink-0 w-3 text-center" aria-hidden="true">└</span>
<span>… +{overflow} more</span>
<button
type="button"
className="rounded-sm hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setShowEarlier(true)}
>
{t("toolProgress.earlier" as never, { count: overflow })}
</button>
</div>
)}
</div>
</div>
);
}
});
+125 -55
View File
@@ -1,5 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, TrendingUp, Globe, Sparkles, Users, UserCircle2, NotebookPen, Landmark, Gem } from "lucide-react";
import { ChevronDown, TrendingUp, Globe, Sparkles, Users, UserCircle2, NotebookPen, Landmark, Gem } from "lucide-react";
import { BrandMark } from "@/components/common/BrandMark";
interface Example {
titleKey: string;
@@ -10,7 +12,6 @@ interface Example {
interface Category {
labelKey: string;
icon: React.ReactNode;
color: string;
examples: Example[];
}
@@ -18,7 +19,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.multiMarketBacktest",
icon: <TrendingUp className="h-4 w-4" />,
color: "text-red-400 border-red-500/30 hover:border-red-500/60 hover:bg-red-500/5",
examples: [
{
titleKey: "welcome.examples.crossMarketPortfolio",
@@ -40,7 +40,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.researchAnalysis",
icon: <Sparkles className="h-4 w-4" />,
color: "text-amber-400 border-amber-500/30 hover:border-amber-500/60 hover:bg-amber-500/5",
examples: [
{
titleKey: "welcome.examples.multiFactorAlpha",
@@ -57,7 +56,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.valueInvesting",
icon: <Gem className="h-4 w-4" />,
color: "text-yellow-400 border-yellow-500/30 hover:border-yellow-500/60 hover:bg-yellow-500/5",
examples: [
{
titleKey: "welcome.examples.valueCommittee",
@@ -84,7 +82,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.swarmTeams",
icon: <Users className="h-4 w-4" />,
color: "text-violet-400 border-violet-500/30 hover:border-violet-500/60 hover:bg-violet-500/5",
examples: [
{
titleKey: "welcome.examples.investmentCommittee",
@@ -101,7 +98,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.docWebResearch",
icon: <Globe className="h-4 w-4" />,
color: "text-blue-400 border-blue-500/30 hover:border-blue-500/60 hover:bg-blue-500/5",
examples: [
{
titleKey: "welcome.examples.earningsReport",
@@ -118,7 +114,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.tradeJournal",
icon: <NotebookPen className="h-4 w-4" />,
color: "text-orange-400 border-orange-500/30 hover:border-orange-500/60 hover:bg-orange-500/5",
examples: [
{
titleKey: "welcome.examples.analyzeBrokerExport",
@@ -135,7 +130,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.tradingConnectors",
icon: <Landmark className="h-4 w-4" />,
color: "text-cyan-400 border-cyan-500/30 hover:border-cyan-500/60 hover:bg-cyan-500/5",
examples: [
{
titleKey: "welcome.examples.checkConnector",
@@ -157,7 +151,6 @@ const CATEGORIES: Category[] = [
{
labelKey: "welcome.categories.shadowAccount",
icon: <UserCircle2 className="h-4 w-4" />,
color: "text-emerald-400 border-emerald-500/30 hover:border-emerald-500/60 hover:bg-emerald-500/5",
examples: [
{
titleKey: "welcome.examples.trainShadow",
@@ -178,67 +171,140 @@ const CATEGORIES: Category[] = [
},
];
const CAPABILITY_CHIP_KEYS = [
"welcome.capabilities.financeSkills",
"welcome.capabilities.swarmTeams",
"welcome.capabilities.autoTools",
"welcome.capabilities.markets",
"welcome.capabilities.connectors",
"welcome.capabilities.timeframes",
"welcome.capabilities.optimizers",
"welcome.capabilities.riskMetrics",
"welcome.capabilities.options",
"welcome.capabilities.pdfWeb",
"welcome.capabilities.factorML",
"welcome.capabilities.journalAnalyzer",
"welcome.capabilities.shadowBacktest",
"welcome.capabilities.memory",
"welcome.capabilities.sessionSearch",
const GREETING_KEYS = {
morning: [
"welcome.greetings.morning1",
"welcome.greetings.morning2",
"welcome.greetings.morning3",
],
afternoon: [
"welcome.greetings.afternoon1",
"welcome.greetings.afternoon2",
"welcome.greetings.afternoon3",
],
evening: [
"welcome.greetings.evening1",
"welcome.greetings.evening2",
"welcome.greetings.evening3",
],
night: [
"welcome.greetings.night1",
"welcome.greetings.night2",
"welcome.greetings.night3",
],
} as const;
const QUICK_ACTIONS = [
{
titleKey: "welcome.examples.valuationCheck",
promptKey: "welcome.examples.valuationCheckPrompt",
icon: <Gem className="h-4 w-4" aria-hidden="true" />,
},
{
titleKey: "welcome.examples.optionsGreeks",
promptKey: "welcome.examples.optionsGreeksPrompt",
icon: <Sparkles className="h-4 w-4" aria-hidden="true" />,
},
{
titleKey: "welcome.examples.crossMarketPortfolio",
promptKey: "welcome.examples.crossMarketPortfolioPrompt",
icon: <TrendingUp className="h-4 w-4" aria-hidden="true" />,
},
{
titleKey: "welcome.examples.investmentCommittee",
promptKey: "welcome.examples.investmentCommitteePrompt",
icon: <Users className="h-4 w-4" aria-hidden="true" />,
},
] as const;
function pickGreetingKey(): string {
const hour = new Date().getHours();
const bucket =
hour >= 5 && hour < 12
? "morning"
: hour >= 12 && hour < 17
? "afternoon"
: hour >= 17 && hour < 22
? "evening"
: "night";
const variants = GREETING_KEYS[bucket];
return variants[Math.floor(Math.random() * variants.length)] ?? variants[0];
}
interface Props {
onExample: (s: string) => void;
}
export function WelcomeScreen({ onExample }: Props) {
const { t } = useTranslation();
const [greetingKey] = useState(() => pickGreetingKey());
const [isExamplesOpen, setIsExamplesOpen] = useState(false);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-8 text-center">
{/* Header */}
<div className="space-y-3">
<div className="h-16 w-16 mx-auto rounded-2xl bg-gradient-to-br from-primary/80 to-info/80 flex items-center justify-center shadow-lg">
<Bot className="h-8 w-8 text-white" />
<div className="flex w-full flex-col items-center px-4 pb-14 text-center">
<div className="w-full max-w-3xl">
<div className="mx-auto max-w-2xl">
<div className="flex items-center justify-center gap-3">
<BrandMark className="h-9 w-9 shrink-0" />
<h1 className="font-serif text-[34px] font-normal leading-tight sm:text-[40px]">
{t(greetingKey as any)}
</h1>
</div>
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('welcome.title')}</h2>
<p className="text-xs text-muted-foreground mt-1 max-w-sm mx-auto leading-relaxed">
{t('welcome.subtitle')}
<p className="mt-3 text-sm text-muted-foreground">
{t("welcome.taskSubtitle" as any)}
</p>
<p className="text-sm text-muted-foreground mt-2 max-w-md leading-relaxed mx-auto">
{t('welcome.describePrompt')}
</p>
</div>
</div>
{/* Capability chips */}
<div className="flex flex-wrap justify-center gap-2 max-w-lg">
{CAPABILITY_CHIP_KEYS.map((key) => (
<span
key={key}
className="px-2.5 py-1 text-xs rounded-full border border-border/60 text-muted-foreground bg-muted/30"
<div
className="mt-8 flex flex-wrap justify-center gap-2"
role="group"
aria-label={t("welcome.quickActions" as any)}
>
{t(key)}
</span>
{QUICK_ACTIONS.map((action, index) => (
<button
key={action.titleKey}
type="button"
onClick={() => onExample(t(action.promptKey as any))}
className={
index === 0
? "inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/[0.08] px-4 py-2 text-sm text-primary transition-colors hover:border-primary/50 hover:bg-primary/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
: "inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-4 py-2 text-sm transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
}
>
{action.icon}
<span>{t(action.titleKey as any)}</span>
</button>
))}
</div>
{/* Example categories grid */}
<div className="w-full max-w-2xl text-left space-y-4">
<p className="text-xs text-muted-foreground px-1">{t('welcome.tryExample')}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
aria-expanded={isExamplesOpen}
aria-controls="welcome-example-library"
onClick={() => setIsExamplesOpen((open) => !open)}
className="mt-10 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<span>{t("welcome.browseAllExamples" as any)}</span>
<ChevronDown
className={`h-4 w-4 transition-transform duration-300 ${isExamplesOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
<div
id="welcome-example-library"
aria-hidden={!isExamplesOpen}
className={`grid text-left transition-[grid-template-rows,opacity] duration-300 ease-out ${
isExamplesOpen
? "grid-rows-[1fr] opacity-100"
: "grid-rows-[0fr] opacity-0"
}`}
>
<div className="min-h-0 overflow-hidden">
<div className="grid grid-cols-1 gap-3 pt-8 sm:grid-cols-2">
{CATEGORIES.map((cat) => (
<div key={cat.labelKey} className="space-y-2">
<div className={`flex items-center gap-1.5 text-xs font-medium px-1 ${cat.color.split(" ").filter(c => c.startsWith("text-")).join(" ")}`}>
<div className="flex items-center gap-1.5 px-1 text-xs font-medium text-muted-foreground">
{cat.icon}
<span>{t(cat.labelKey as any)}</span>
</div>
@@ -246,13 +312,15 @@ export function WelcomeScreen({ onExample }: Props) {
{cat.examples.map((ex) => (
<button
key={ex.titleKey}
type="button"
tabIndex={isExamplesOpen ? 0 : -1}
onClick={() => onExample(t(ex.promptKey as any))}
className={`block w-full text-left px-3 py-2.5 rounded-xl border transition-colors ${cat.color}`}
className="block w-full rounded-xl border border-border/60 px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<span className="text-sm font-medium text-foreground leading-snug">
<span className="text-sm font-medium leading-snug text-foreground">
{t(ex.titleKey as any)}
</span>
<span className="block text-xs text-muted-foreground mt-0.5 leading-snug">
<span className="mt-0.5 block text-xs leading-snug text-muted-foreground">
{t(ex.descKey as any)}
</span>
</button>
@@ -263,5 +331,7 @@ export function WelcomeScreen({ onExample }: Props) {
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,15 +1,15 @@
import { render, screen } from "@testing-library/react";
import { render } from "@testing-library/react";
import { AgentAvatar } from "../AgentAvatar";
describe("AgentAvatar", () => {
it("renders the letter P", () => {
render(<AgentAvatar />);
expect(screen.getByText("P")).toBeInTheDocument();
});
it("has gradient background styling", () => {
it("renders the brand mark as a decorative 32px avatar", () => {
const { container } = render(<AgentAvatar />);
const el = container.firstChild as HTMLElement;
expect(el.className).toMatch(/bg-gradient/);
const wrapper = container.firstElementChild;
const mark = wrapper?.querySelector("svg");
expect(wrapper).toHaveAttribute("aria-hidden", "true");
expect(wrapper).toHaveClass("h-8", "w-8");
expect(mark).toHaveClass("h-8", "w-8");
expect(container).not.toHaveTextContent("P");
});
});
@@ -0,0 +1,28 @@
import { createRef } from "react";
import { render, screen } from "@testing-library/react";
import { ConversationTimeline } from "../ConversationTimeline";
import type { AgentMessage } from "@/types/agent";
describe("ConversationTimeline", () => {
it("caps the minimap at the 40 most recent user messages", () => {
const messages: AgentMessage[] = Array.from({ length: 50 }, (_, index) => ({
id: `message-${index}`,
type: "user",
content: `Prompt ${index}`,
timestamp: index,
}));
const containerRef = createRef<HTMLDivElement>();
render(
<>
<div ref={containerRef} />
<ConversationTimeline messages={messages} containerRef={containerRef} />
</>,
);
expect(screen.getAllByRole("button")).toHaveLength(40);
expect(screen.queryByTitle("Prompt 9")).not.toBeInTheDocument();
expect(screen.getByTitle("Prompt 10")).toBeInTheDocument();
expect(screen.getByTitle("Prompt 49")).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { render } from "@testing-library/react";
import { MessageBubble } from "../MessageBubble";
import { MarkdownContent, MessageBubble } from "../MessageBubble";
import type { AgentMessage } from "@/types/agent";
// Unlike MessageBubble.test.tsx, react-markdown is NOT mocked here: these tests
@@ -44,4 +44,31 @@ describe("MessageBubble LaTeX rendering", () => {
);
expect(container.querySelector(".katex")).toBeNull();
});
it("keeps streaming markdown structural without running KaTeX enhancement", () => {
const { container } = render(
<MarkdownContent content={"Sharpe: \\(\\frac{R_p}{\\sigma_p}\\)"} streaming showCursor />,
);
expect(container.querySelector(".katex")).toBeNull();
expect(container.textContent).toContain("Sharpe:");
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
it("wraps markdown tables in a horizontal scroll container", () => {
const { container } = render(
<MarkdownContent content={"| Symbol | Return |\n| --- | ---: |\n| AAPL | 12% |"} />,
);
const table = container.querySelector("table");
expect(table).not.toBeNull();
expect(table?.parentElement).toHaveClass("overflow-x-auto");
});
it("opens markdown links in a separate, isolated tab", () => {
const { container } = render(
<MarkdownContent content={"[Source](https://example.com/report)"} />,
);
const link = container.querySelector("a");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
@@ -1,7 +1,9 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18n from "@/i18n";
import { MessageBubble } from "../MessageBubble";
import type { AgentMessage } from "@/types/agent";
import type { StoredAgentMessage } from "@/stores/agent";
// Mock react-markdown (heavy dependency, renders raw content in tests)
vi.mock("react-markdown", () => ({
@@ -17,7 +19,7 @@ vi.mock("../RunCompleteCard", () => ({
),
}));
function makeMsg(overrides: Partial<AgentMessage> = {}): AgentMessage {
function makeMsg(overrides: Partial<StoredAgentMessage> = {}): StoredAgentMessage {
return {
id: "msg-1",
type: "answer",
@@ -29,14 +31,47 @@ function makeMsg(overrides: Partial<AgentMessage> = {}): AgentMessage {
describe("MessageBubble", () => {
describe("user messages", () => {
it("renders user content in a styled bubble", () => {
render(<MessageBubble msg={makeMsg({ type: "user", content: "Hello agent!" })} />);
expect(screen.getByText("Hello agent!")).toBeInTheDocument();
it("renders user content in the bounded neutral bubble without an avatar or timestamp", () => {
const { container } = render(
<MessageBubble msg={makeMsg({ type: "user", content: "Hello agent!" })} />,
);
const bubble = screen.getByText("Hello agent!");
expect(bubble).toHaveClass(
"max-h-[40vh]",
"overflow-y-auto",
"break-words",
"rounded-[18px]",
"bg-muted",
"px-4",
"py-3",
"text-[15px]",
"text-foreground",
);
expect(bubble).not.toHaveClass("rounded-tr-sm");
expect(screen.queryByText("14:30")).not.toBeInTheDocument();
expect(container.querySelector("svg")).toBeNull();
});
it("shows timestamp", () => {
render(<MessageBubble msg={makeMsg({ type: "user" })} />);
expect(screen.getByText("14:30")).toBeInTheDocument();
it("renders safe request metadata without exposing an attachment path", () => {
render(
<MessageBubble
msg={makeMsg({
type: "user",
content: "Analyze this",
meta: {
attachment: { filename: "trades.csv" },
swarmMode: true,
},
})}
/>,
);
expect(screen.getByText("trades.csv")).toBeInTheDocument();
expect(screen.getByText(i18n.t("agent.swarmModeChip" as never))).toBeInTheDocument();
expect(screen.queryByText(/\/private\/tmp|file_path|path:/i)).not.toBeInTheDocument();
expect(screen.getByText("trades.csv").parentElement).toHaveClass(
"bg-background/60",
"text-muted-foreground",
);
});
});
@@ -45,6 +80,34 @@ describe("MessageBubble", () => {
render(<MessageBubble msg={makeMsg({ type: "answer", content: "Here is the **analysis**" })} />);
expect(screen.getByTestId("markdown")).toHaveTextContent("Here is the **analysis**");
});
it("uses the streaming-compatible content shape and omits the timestamp", () => {
const { container } = render(
<MessageBubble msg={makeMsg({ type: "answer", content: "Analysis" })} />,
);
expect(screen.getByTestId("markdown").parentElement).toHaveClass(
"prose",
"text-[15px]",
);
expect(container.firstElementChild?.children[1]).toHaveClass(
"flex-1",
"min-w-0",
"space-y-1.5",
);
expect(screen.queryByText("14:30")).not.toBeInTheDocument();
});
it("exposes the copy action to keyboard focus and announces success", async () => {
render(<MessageBubble msg={makeMsg({ type: "answer", content: "Analysis" })} />);
const copyButton = screen.getByRole("button", { name: "Copy" });
expect(copyButton).toHaveClass("focus-visible:opacity-100");
const user = userEvent.setup();
await user.click(copyButton);
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Copied");
});
});
describe("error messages", () => {
@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { PineScriptViewer } from "../PineScriptViewer";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe("PineScriptViewer", () => {
it("renders an accessible portal dialog and restores focus after Escape closes it", () => {
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
const onClose = vi.fn();
const { container, unmount } = render(
<PineScriptViewer code="strategy('test')" onClose={onClose} />,
);
const dialog = screen.getByRole("dialog", { name: "pineViewer.pineScript" });
expect(container).not.toContainElement(dialog);
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(screen.getByRole("button", { name: "layout.cancel" })).toHaveFocus();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
expect(trigger).toHaveFocus();
trigger.remove();
});
});
@@ -0,0 +1,114 @@
// @vitest-environment node
import { renderToStaticMarkup } from "react-dom/server";
import i18n from "../../../i18n";
import { SwarmDashboard } from "../SwarmDashboard";
import type { SwarmAgentStatus } from "@/types/agent";
function makeAgents(): SwarmAgentStatus[] {
return [
{
agentId: "macro",
role: "Macro analyst",
status: "running",
tool: "read_file",
elapsed_s: 12,
iterations: 2,
lastText: "Checking market regime",
},
{
agentId: "risk",
status: "done",
elapsed_s: 65,
iterations: 4,
lastText: "Risk review complete",
},
];
}
describe("SwarmDashboard", () => {
beforeAll(async () => {
i18n.addResourceBundle(
"en",
"translation",
{
swarmStatus: {
detailsUnavailable: "details unavailable",
teamStarting:
"The team is spinning up — first updates in a few seconds…",
},
},
true,
true,
);
await i18n.changeLanguage("en");
});
it("renders responsive wide rows and narrow cards without horizontal overflow", () => {
const html = renderToStaticMarkup(
<SwarmDashboard agents={makeAgents()} />,
);
expect(html).toContain('data-agent-layout="wide"');
expect(html).toContain('data-agent-layout="narrow"');
expect(html).toContain(
"grid-cols-[minmax(0,1.25fr)_minmax(0,0.85fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1.5fr)]",
);
expect(html).not.toContain("min-w-[620px]");
expect(html).not.toContain("overflow-x-auto");
expect(html).toContain("Checking market regime");
expect(html).toContain("Risk review complete");
expect(html).toContain('role="table"');
expect(html.match(/role="columnheader"/g)).toHaveLength(5);
expect(html.match(/role="cell"/g)).toHaveLength(10);
expect(html).toContain("text-end");
expect(html).not.toContain("text-right");
expect(html).not.toContain("mt-3 hidden");
expect(html).not.toContain("mt-3 space-y-2");
});
it("adds status-color transitions and a reduced-motion-safe row entrance", () => {
const html = renderToStaticMarkup(
<SwarmDashboard agents={makeAgents()} />,
);
expect(html).toContain("transition-colors duration-300");
expect(html).toContain(
"animate-[msg-enter_300ms_cubic-bezier(0.16,1,0.3,1)_both]",
);
expect(html).toContain("motion-reduce:animate-none");
});
it("renders audience-facing copy while an empty run starts", () => {
const html = renderToStaticMarkup(<SwarmDashboard agents={[]} />);
expect(html).toContain(
"The team is spinning up — first updates in a few seconds…",
);
expect(html).not.toContain('data-agent-layout="wide"');
expect(html).not.toContain('data-agent-layout="narrow"');
});
it("renders a static unavailable state when an empty run is terminal", () => {
const html = renderToStaticMarkup(
<SwarmDashboard
agents={[]}
emptyStatus="Completed"
isActive={false}
/>,
);
expect(html).toContain("Completed — details unavailable");
expect(html).not.toContain("The team is spinning up");
expect(html).not.toContain("animate-pulse");
expect(html).not.toContain("animate-spin");
});
it("stops stale running-agent spinners after the run terminates", () => {
const html = renderToStaticMarkup(
<SwarmDashboard agents={makeAgents()} isActive={false} />,
);
expect(html).not.toContain("animate-spin");
});
});
@@ -1,6 +1,7 @@
// @vitest-environment node
import { renderToStaticMarkup } from "react-dom/server";
import i18n from "../../../i18n";
import { SwarmStatusCard } from "../SwarmStatusCard";
import {
applySwarmEvent,
@@ -30,16 +31,40 @@ function makeStatus(overrides: Partial<SwarmRunStatus> = {}): SwarmRunStatus {
}
describe("SwarmStatusCard", () => {
beforeAll(async () => {
i18n.addResourceBundle(
"en",
"translation",
{
swarmStatus: {
detailsUnavailable: "details unavailable",
teamStarting:
"The team is spinning up — first updates in a few seconds…",
},
swarm: {
presets: {
investment_committee: "Investment Committee Review",
quant_strategy_desk: "Quant Strategy Desk",
value_investing_committee: "Value Investing Committee",
},
},
},
true,
true,
);
await i18n.changeLanguage("en");
});
it("renders agent status rows", () => {
const html = renderToStaticMarkup(<SwarmStatusCard status={makeStatus()} />);
expect(html).toContain("demo_team");
expect(html).toContain("running");
expect(html).toContain("waiting");
expect(html).toContain("done");
expect(html).toContain("failed");
expect(html).toContain("blocked");
expect(html).toContain("retry");
expect(html).toContain("Demo team");
expect(html).toContain("Running");
expect(html).toContain("Waiting");
expect(html).toContain("Done");
expect(html).toContain("Failed");
expect(html).toContain("Blocked");
expect(html).toContain("Retrying");
expect(html).toContain("checking exposure");
expect(html).toContain("missing data");
});
@@ -47,7 +72,81 @@ describe("SwarmStatusCard", () => {
it("shows empty state while waiting for events", () => {
const html = renderToStaticMarkup(<SwarmStatusCard status={makeStatus({ agents: [] })} />);
expect(html).toContain("Waiting for agent events...");
expect(html).toContain(
"The team is spinning up — first updates in a few seconds…",
);
});
it("renders honest indeterminate progress while totals are unknown", () => {
const html = renderToStaticMarkup(
<SwarmStatusCard
status={makeStatus({ agents: [], currentLayer: 0, totalLayers: 0 })}
/>,
);
expect(html).not.toContain("0/1");
expect(html).not.toContain("Layer 1/1");
expect(html).not.toContain('value="0" max="1"');
expect(html).toContain('data-layer-state="indeterminate"');
expect(html).toContain(
"animate-[pulse-slide_2s_ease-in-out_infinite]",
);
expect(html.match(/animate-pulse/g)).toHaveLength(2);
expect(html).toMatch(
/<progress class="sr-only" aria-label="[^"]+"><\/progress>/,
);
});
it("keeps determinate counts when real totals are available", () => {
const html = renderToStaticMarkup(<SwarmStatusCard status={makeStatus()} />);
expect(html).toContain("3/6 agents");
expect(html).toContain("Layer 1/2");
expect(html).toContain('data-layer-state="determinate"');
expect(html).toContain('role="progressbar"');
expect(html).toContain('aria-valuemin="0"');
expect(html).toContain('aria-valuetext="Layer 1/2"');
expect(html).toContain("grid h-1.5 min-w-0 gap-1");
expect(html).not.toContain("grid h-2 min-w-0 gap-1");
expect(html).not.toContain(
"animate-[pulse-slide_2s_ease-in-out_infinite]",
);
});
it("renders a static terminal empty state without active-run copy", () => {
const html = renderToStaticMarkup(
<SwarmStatusCard
status={makeStatus({
agents: [],
status: "completed",
currentLayer: 0,
totalLayers: 0,
})}
/>,
);
expect(html).toContain("Completed — details unavailable");
expect(html).not.toContain("Assembling team");
expect(html).not.toContain("Preparing layers");
expect(html).not.toContain("The team is spinning up");
expect(html).not.toContain("animate-pulse");
expect(html).not.toContain("pulse-slide");
expect(html).not.toContain("<progress");
});
it("uses translated preset names and humanizes custom preset ids", () => {
const translated = renderToStaticMarkup(
<SwarmStatusCard
status={makeStatus({ preset: "value_investing_committee" })}
/>,
);
const custom = renderToStaticMarkup(
<SwarmStatusCard status={makeStatus({ preset: "custom_alpha_team" })} />,
);
expect(translated).toContain("Value Investing Committee");
expect(translated).not.toContain("value_investing_committee");
expect(custom).toContain("Custom alpha team");
});
it("builds a card model from swarm.started payload", () => {
@@ -18,10 +18,10 @@ function makeMsg(overrides: Partial<AgentMessage> = {}): AgentMessage {
describe("ThinkingTimeline", () => {
it("shows summary text when done", () => {
const msgs: AgentMessage[] = [
makeMsg({ type: "tool_call", tool: "run_backtest", status: "ok" }),
makeMsg({ type: "tool_call", tool: "backtest", status: "ok" }),
makeMsg({
type: "tool_result",
tool: "run_backtest",
tool: "backtest",
status: "ok",
elapsed_ms: 3200,
content: "done",
@@ -35,7 +35,7 @@ describe("ThinkingTimeline", () => {
it("shows running state with spinner", () => {
const msgs: AgentMessage[] = [
makeMsg({ type: "tool_call", tool: "run_backtest", status: "running" }),
makeMsg({ type: "tool_call", tool: "backtest", status: "running" }),
];
render(<ThinkingTimeline messages={msgs} isLatest />);
@@ -58,7 +58,7 @@ describe("ThinkingTimeline", () => {
await user.click(screen.getByRole("button"));
// Now expanded — should show step labels
expect(screen.getByText("Run command")).toBeInTheDocument();
expect(screen.getByText("Run command")).toBeVisible();
});
it("shows error icon when a step failed", () => {
@@ -102,8 +102,8 @@ describe("ThinkingTimeline", () => {
makeMsg({ type: "tool_result", tool: "bash", status: "ok", elapsed_ms: 500, content: "ok" }),
makeMsg({ type: "tool_call", tool: "write_file", status: "ok" }),
makeMsg({ type: "tool_result", tool: "write_file", status: "ok", elapsed_ms: 200, content: "ok" }),
makeMsg({ type: "tool_call", tool: "run_backtest", status: "ok" }),
makeMsg({ type: "tool_result", tool: "run_backtest", status: "ok", elapsed_ms: 5000, content: "ok" }),
makeMsg({ type: "tool_call", tool: "backtest", status: "ok" }),
makeMsg({ type: "tool_result", tool: "backtest", status: "ok", elapsed_ms: 5000, content: "ok" }),
];
render(<ThinkingTimeline messages={msgs} />);
@@ -115,4 +115,36 @@ describe("ThinkingTimeline", () => {
expect(screen.getByText("Generate code")).toBeInTheDocument();
expect(screen.getByText("Run backtest")).toBeInTheDocument();
});
it("exposes disclosure semantics and preserves an explicit user expansion", async () => {
const user = userEvent.setup();
const msgs: AgentMessage[] = [
makeMsg({ type: "tool_call", tool: "bash", status: "ok" }),
makeMsg({ type: "tool_result", tool: "bash", status: "ok", content: "ok" }),
];
const { rerender } = render(<ThinkingTimeline messages={msgs} />);
const button = screen.getByRole("button");
const detailsId = button.getAttribute("aria-controls");
expect(button).toHaveAttribute("aria-expanded", "false");
expect(detailsId).toBeTruthy();
expect(document.getElementById(detailsId!)).not.toBeVisible();
await user.click(button);
expect(button).toHaveAttribute("aria-expanded", "true");
expect(document.getElementById(detailsId!)).toBeVisible();
rerender(<ThinkingTimeline messages={msgs} isLatest />);
rerender(<ThinkingTimeline messages={msgs} isLatest={false} />);
expect(button).toHaveAttribute("aria-expanded", "true");
expect(document.getElementById(detailsId!)).toBeVisible();
});
it("truncates long summary text within the disclosure row", () => {
render(<ThinkingTimeline messages={[
makeMsg({ type: "tool_call", tool: "unusually_long_tool_name", status: "running" }),
]} />);
expect(screen.getByText(/Running unusually_long_tool_name/)).toHaveClass("truncate");
});
});
@@ -1,11 +1,25 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToolProgressIndicator } from "../ToolProgressIndicator";
import type { ToolCallEntry } from "@/types/agent";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => {
if (key === "toolProgress.step") return `Step ${values?.step} · ${values?.tool}`;
if (key === "toolProgress.toolsRunning") return `${values?.count} tools running`;
if (key === "toolProgress.toolsCompleted") return `${values?.count} tools completed`;
if (key === "toolProgress.earlier") return `+${values?.count} earlier`;
if (key === "toolProgress.etaSeconds") return `~${values?.seconds}s left`;
return key;
},
}),
}));
function makeTc(overrides: Partial<ToolCallEntry> = {}): ToolCallEntry {
return {
id: "tc-1",
tool: "run_backtest",
tool: "backtest",
arguments: {},
status: "running",
timestamp: Date.now(),
@@ -14,10 +28,11 @@ function makeTc(overrides: Partial<ToolCallEntry> = {}): ToolCallEntry {
}
describe("ToolProgressIndicator", () => {
it("renders nothing when no tools are running", () => {
it("keeps completed tools visible for the remainder of the attempt", () => {
const tcs = [makeTc({ status: "ok" }), makeTc({ id: "tc-2", status: "error" })];
const { container } = render(<ToolProgressIndicator toolCalls={tcs} />);
expect(container.innerHTML).toBe("");
render(<ToolProgressIndicator toolCalls={tcs} />);
expect(screen.getAllByText("2 tools completed")).toHaveLength(2);
expect(screen.getAllByText(/Run backtest/)).toHaveLength(2);
});
it("renders nothing for empty array", () => {
@@ -39,20 +54,60 @@ describe("ToolProgressIndicator", () => {
makeTc({ id: "tc-2", tool: "write_file" }),
];
render(<ToolProgressIndicator toolCalls={tcs} />);
expect(screen.getByText("2 tools running")).toBeInTheDocument();
expect(screen.getAllByText("2 tools running")).toHaveLength(2);
expect(screen.getByText(/Run command/)).toBeInTheDocument();
expect(screen.getByText(/Generate code/)).toBeInTheDocument();
});
it("shows overflow indicator for > 3 running tools", () => {
it("renders repeated tool names as distinct rows keyed by unique ids", () => {
const tcs = [
makeTc({ id: "backtest#1", status: "ok" }),
makeTc({ id: "backtest#2", status: "running" }),
];
render(<ToolProgressIndicator toolCalls={tcs} />);
expect(screen.getAllByText(/Run backtest/)).toHaveLength(2);
});
it("shows the most recent running tools behind a clickable earlier count", async () => {
const user = userEvent.setup();
const tcs = [
makeTc({ id: "tc-1", tool: "bash" }),
makeTc({ id: "tc-2", tool: "write_file" }),
makeTc({ id: "tc-3", tool: "run_backtest" }),
makeTc({ id: "tc-3", tool: "backtest" }),
makeTc({ id: "tc-4", tool: "read_file" }),
];
render(<ToolProgressIndicator toolCalls={tcs} />);
expect(screen.getByText(/… \+2 more/)).toBeInTheDocument();
expect(screen.queryByText(/Run command/)).not.toBeInTheDocument();
expect(screen.queryByText(/Generate code/)).not.toBeInTheDocument();
expect(screen.getByText(/Run backtest/)).toBeInTheDocument();
expect(screen.getByText(/Read file/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+2 earlier" }));
expect(screen.getByText(/Run command/)).toBeInTheDocument();
expect(screen.getByText(/Generate code/)).toBeInTheDocument();
});
it("prioritizes running rows, then the most recent terminal row", async () => {
const user = userEvent.setup();
const tcs = [
makeTc({ id: "tc-1", tool: "bash", status: "ok" }),
makeTc({ id: "tc-2", tool: "write_file", status: "error" }),
makeTc({ id: "tc-3", tool: "backtest", status: "ok" }),
makeTc({ id: "tc-4", tool: "read_file", status: "running" }),
];
render(<ToolProgressIndicator toolCalls={tcs} />);
const visibleSteps = screen.getAllByText(/^Step /).map((node) => node.textContent);
expect(visibleSteps).toEqual([
"Step 4 · Read file",
"Step 3 · Run backtest",
]);
expect(screen.queryByText(/Generate code/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+2 earlier" }));
expect(screen.getByText(/Generate code/)).toBeInTheDocument();
expect(screen.getByText(/Run command/)).toBeInTheDocument();
});
it("shows determinate progress bar when progress data exists", () => {
@@ -67,4 +122,13 @@ describe("ToolProgressIndicator", () => {
// Should have a progressbar element
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("announces only coarse status and hides ticking elapsed numerals", () => {
render(<ToolProgressIndicator toolCalls={[makeTc({ elapsed_s: 5 })]} />);
const status = screen.getByRole("status");
expect(status).toHaveClass("sr-only");
expect(status).not.toHaveAttribute("aria-atomic");
expect(screen.getByText("5s")).toHaveAttribute("aria-hidden", "true");
});
});
@@ -1,44 +1,141 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18n from "../../../i18n";
import { WelcomeScreen } from "../WelcomeScreen";
describe("WelcomeScreen", () => {
const onExample = vi.fn();
beforeAll(async () => {
i18n.addResourceBundle(
"en",
"translation",
{
welcome: {
taskSubtitle: "What would you like to research, test, or understand today?",
quickActions: "Quick actions",
browseAllExamples: "Browse all examples",
greetings: {
morning1: "Good morning.",
morning2: "Morning — ready when you are.",
morning3: "Good morning. Let's get started.",
afternoon1: "Good afternoon.",
afternoon2: "Ready for the next question?",
afternoon3: "What are we exploring this afternoon?",
evening1: "Good evening.",
evening2: "Let's make sense of the market.",
evening3: "Ready for some focused research?",
night1: "Still thinking?",
night2: "Let's work through it.",
night3: "One more idea before you wrap up?",
},
},
},
true,
true,
);
await i18n.changeLanguage("en");
});
beforeEach(() => onExample.mockClear());
it("renders the title", () => {
render(<WelcomeScreen onExample={onExample} />);
expect(screen.getByText("Vibe-Trading")).toBeInTheDocument();
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("renders capability chips", () => {
it.each([
[5, "Good morning."],
[12, "Good afternoon."],
[17, "Good evening."],
[22, "Still thinking?"],
])("renders the local-hour greeting for %i:00", (hour, greeting) => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 6, 29, hour));
vi.spyOn(Math, "random").mockReturnValue(0);
render(<WelcomeScreen onExample={onExample} />);
expect(screen.getByText("Finance Skills Library")).toBeInTheDocument();
expect(screen.getByText("Swarm Agent Teams")).toBeInTheDocument();
expect(screen.getByText("Shadow Account Backtest")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: greeting })).toBeInTheDocument();
expect(
screen.getByText("What would you like to research, test, or understand today?"),
).toBeInTheDocument();
});
it("renders example categories", () => {
render(<WelcomeScreen onExample={onExample} />);
expect(screen.getByText("A-Share Backtest")).toBeInTheDocument();
expect(screen.getByText("Research & Analysis")).toBeInTheDocument();
expect(screen.getByText("Swarm Teams")).toBeInTheDocument();
});
it("calls onExample with prompt when an example button is clicked", async () => {
render(<WelcomeScreen onExample={onExample} />);
it("hands off all four quick-action prompts unchanged", async () => {
const actions = [
{
label: "Check if a stock is expensive",
prompt:
"Use the financial_rigor tool to verify Kweichow Moutai's valuation: price 1500, EPS 68.6, book value per share 180 — compute PE, PB, ROE exactly, then a three-scenario valuation (growth 12%/8%/0%, PE 22/18/14, 3 years)",
},
{
label: "Options risk check (Greeks)",
prompt:
"Calculate option Greeks using Black-Scholes: spot=100, strike=105, risk-free rate=3%, vol=25%, expiry=90 days, analyze Delta/Gamma/Theta/Vega",
},
{
label: "Balance a 3-stock portfolio",
prompt:
"Build a risk-parity portfolio with 000001.SZ, 600519.SH, 000858.SZ, backtest for the full year of 2024, and compare with equal-weighted benchmark",
},
{
label: "Buy or sell? Let a committee debate",
prompt:
"[Swarm Team Mode] Use the investment_committee preset to evaluate whether to go long or short on 600519.SH given current market conditions",
},
];
const user = userEvent.setup();
await user.click(screen.getByText("A-Share Portfolio Optimization"));
expect(onExample).toHaveBeenCalledTimes(1);
expect(onExample).toHaveBeenCalledWith(
expect.stringContaining("risk-parity portfolio"),
render(<WelcomeScreen onExample={onExample} />);
const quickActions = screen.getByRole("group", { name: "Quick actions" });
expect(within(quickActions).getAllByRole("button")).toHaveLength(4);
for (const [index, action] of actions.entries()) {
await user.click(
within(quickActions).getByRole("button", { name: action.label }),
);
expect(onExample).toHaveBeenNthCalledWith(index + 1, action.prompt);
}
expect(onExample).toHaveBeenCalledTimes(4);
});
it("renders the helper text", () => {
it("reveals all eight categories and 21 examples from the disclosure", async () => {
const user = userEvent.setup();
render(<WelcomeScreen onExample={onExample} />);
expect(screen.getByText("Describe a trading strategy to get started.")).toBeInTheDocument();
expect(screen.getByText("Try an example:")).toBeInTheDocument();
const trigger = screen.getByRole("button", { name: "Browse all examples" });
const library = document.getElementById("welcome-example-library");
expect(library).not.toBeNull();
expect(library).toHaveAttribute("aria-hidden", "true");
expect(
screen.queryByRole("button", { name: /A-Share MACD Strategy/ }),
).not.toBeInTheDocument();
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(library).toHaveAttribute("aria-hidden", "false");
expect(within(library!).getAllByRole("button")).toHaveLength(21);
for (const category of [
"A-Share Backtest",
"Research & Analysis",
"Value Investing",
"AI Analyst Teams",
"Document & Web Research",
"Trade Journal",
"Trading Connectors",
"Shadow Account",
]) {
expect(within(library!).getByText(category)).toBeInTheDocument();
}
});
it("does not render capability chips", () => {
render(<WelcomeScreen onExample={onExample} />);
expect(screen.queryByText("Finance Skills Library")).not.toBeInTheDocument();
expect(screen.queryByText("Swarm Agent Teams")).not.toBeInTheDocument();
expect(screen.queryByText("Shadow Account Backtest")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,43 @@
export function BrandMark({
className = "h-6 w-6",
}: {
className?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
fill="none"
className={className}
aria-hidden="true"
>
<rect width="32" height="32" rx="6" fill="hsl(27 90% 52%)" />
<path
d="M6 8 L11 24 L16 8"
stroke="#ffffff"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<line
x1="18"
y1="8"
x2="28"
y2="8"
stroke="rgba(255,255,255,0.75)"
strokeWidth="3"
strokeLinecap="round"
/>
<line
x1="23"
y1="8"
x2="23"
y2="24"
stroke="rgba(255,255,255,0.75)"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}
+66 -41
View File
@@ -5,7 +5,9 @@ import { Activity, BarChart3, Bot, Check, ChevronDown, FileText, Languages, Moon
import { cn } from "@/lib/utils";
import { useDarkMode } from "@/hooks/useDarkMode";
import { api, type SessionItem } from "@/lib/api";
import { safeGet, safeSet } from "@/lib/storage";
import { useAgentStore } from "@/stores/agent";
import { BrandMark } from "@/components/common/BrandMark";
import { ConnectionBanner } from "@/components/layout/ConnectionBanner";
import { SUPPORTED_LANGUAGES } from "@/i18n";
@@ -15,14 +17,16 @@ import { SUPPORTED_LANGUAGES } from "@/i18n";
export function Layout() {
const { t } = useTranslation();
// "/" is the product (chat); marketing moved to /about. The Agent entry
// matches both "/" and legacy "/agent" deep links.
const NAV = [
{ to: "/", icon: BarChart3, label: t('layout.home') },
{ to: "/agent", icon: Bot, label: t('layout.agent') },
{ to: "/", icon: Bot, label: t('layout.agent') },
{ to: "/runtime", icon: Activity, label: t('layout.runtime') },
{ to: "/reports", icon: FileText, label: t('layout.reports') },
{ to: "/alpha-zoo", icon: Layers, label: t('layout.alphaZoo') },
{ to: "/settings", icon: Settings, label: t('layout.settings') },
{ to: "/correlation", icon: BarChart3, label: t('layout.correlation') },
{ to: "/about", icon: BarChart3, label: t('layout.about') },
];
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
@@ -31,13 +35,13 @@ export function Layout() {
const [sessionsLoading, setSessionsLoading] = useState(true);
const sseStatus = useAgentStore(s => s.sseStatus);
const sseRetryAttempt = useAgentStore(s => s.sseRetryAttempt);
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("qa-sidebar") === "collapsed");
const [collapsed, setCollapsed] = useState(() => safeGet("qa-sidebar") === "collapsed");
const activeSessionId = searchParams.get("session");
const streamingSessionId = useAgentStore(s => s.streamingSessionId);
useEffect(() => {
localStorage.setItem("qa-sidebar", collapsed ? "collapsed" : "expanded");
safeSet("qa-sidebar", collapsed ? "collapsed" : "expanded");
}, [collapsed]);
const loadSessions = () => {
@@ -75,38 +79,57 @@ export function Layout() {
return (
<div className="flex h-screen bg-background rtl:flex-row-reverse">
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-4 focus:z-[70] focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/40"
>
{t('layout.skipToMain', { defaultValue: 'Skip to main content' })}
</a>
{/* Sidebar */}
<aside className={cn(
"border-e bg-card flex flex-col shrink-0 transition-all duration-200 overflow-visible",
<aside
aria-label={t('layout.sidebar', { defaultValue: 'Vibe-Trading sidebar' })}
className={cn(
"max-md:w-12 border-e border-border/60 bg-card flex flex-col shrink-0 transition-all duration-200 overflow-visible",
collapsed ? "w-12" : "w-64"
)}>
)}
>
{/* Brand */}
<div className={cn("border-b", collapsed ? "p-2 flex justify-center" : "p-4")}>
<Link to="/" className={cn("flex items-center font-bold text-base tracking-tight", collapsed ? "justify-center" : "gap-2")}>
<BarChart3 className="h-5 w-5 text-primary shrink-0" />
{!collapsed && "Vibe-Trading"}
<div className={cn("border-b border-border/60", collapsed ? "p-2 flex justify-center" : "p-4 max-md:p-2 max-md:flex max-md:justify-center")}>
<Link
to="/"
aria-label="Vibe-Trading"
className={cn("flex items-center", collapsed ? "justify-center" : "gap-2 max-md:justify-center")}
>
<BrandMark className="h-6 w-6 shrink-0" />
{!collapsed && (
<span className="text-[15px] font-semibold tracking-tight max-md:hidden">Vibe-Trading</span>
)}
</Link>
</div>
{/* Nav */}
<nav className={cn("space-y-0.5", collapsed ? "p-1" : "p-2")}>
<nav
aria-label={t('layout.mainNavigation', { defaultValue: 'Main navigation' })}
className={cn("space-y-0.5", collapsed ? "p-1" : "p-2 max-md:p-1")}
>
{NAV.map(({ to, icon: Icon, label }) => {
const text = label;
return (
<Link
key={to}
to={to}
aria-label={text}
className={cn(
"flex items-center rounded-md text-sm transition-colors",
collapsed ? "justify-center p-2" : "gap-3 px-3 py-2",
(to === "/" ? pathname === "/" : pathname.startsWith(to))
"flex items-center rounded-md text-[13px] transition-colors",
collapsed ? "justify-center px-2 py-1.5" : "gap-3 px-3 py-1.5 max-md:justify-center max-md:px-2",
(to === "/" ? pathname === "/" || pathname.startsWith("/agent") : pathname.startsWith(to))
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)}
title={collapsed ? text : undefined}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
{!collapsed && text}
{!collapsed && <span className="max-md:hidden">{text}</span>}
</Link>
);
})}
@@ -114,7 +137,7 @@ export function Layout() {
{/* Sessions — hidden when collapsed */}
{!collapsed && (
<div className="flex-1 overflow-auto border-t mt-2 flex flex-col">
<div className="flex-1 overflow-auto border-t border-border/60 mt-2 flex flex-col max-md:hidden">
<div className="flex items-center justify-between px-4 py-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
@@ -122,10 +145,11 @@ export function Layout() {
</span>
<Link
to="/agent"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('layout.newChat')}
className="flex items-center gap-1 p-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title={t('layout.newChat')}
>
<Plus className="h-3.5 w-3.5" />
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
@@ -152,7 +176,8 @@ export function Layout() {
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") renameSession(s.session_id); if (e.key === "Escape") setRenameTarget(null); }}
onBlur={() => renameSession(s.session_id)}
className="flex-1 min-w-0 ps-3 pe-2 py-1 rounded-md text-xs border border-primary bg-background outline-none"
aria-label={`${t('layout.rename')}: ${s.title || s.session_id}`}
className="flex-1 min-w-0 ps-3 pe-2 py-1.5 rounded-md text-xs border border-primary bg-background outline-none focus:ring-2 focus:ring-primary/40"
/>
) : (
<Link
@@ -165,7 +190,7 @@ export function Layout() {
)}
title={s.title || s.session_id}
>
<span className="flex items-center gap-1.5">
<span className="flex min-w-0 items-center gap-1.5">
{streamingSessionId === s.session_id ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
) : (
@@ -174,27 +199,27 @@ export function Layout() {
isActive ? "bg-primary/70" : "bg-muted-foreground/40"
)} />
)}
{s.title || s.session_id.slice(0, 16)}
<span className="min-w-0 truncate">{s.title || s.session_id.slice(0, 16)}</span>
</span>
</Link>
)}
{!isRenaming && isDeleting ? (
<div className="absolute right-0.5 flex items-center gap-0.5">
<button onClick={() => deleteSession(s.session_id)} className="p-1 text-danger hover:bg-danger/10 rounded text-[10px] font-medium">{t('layout.confirm')}</button>
<button onClick={() => setDeleteTarget(null)} className="p-1 text-muted-foreground hover:bg-muted rounded text-[10px]">{t('layout.cancel')}</button>
<button onClick={() => deleteSession(s.session_id)} className="p-1.5 text-danger hover:bg-danger/10 rounded text-[10px] font-medium">{t('layout.confirm')}</button>
<button onClick={() => setDeleteTarget(null)} className="p-1.5 text-muted-foreground hover:bg-muted rounded text-[10px]">{t('layout.cancel')}</button>
</div>
) : !isRenaming ? (
<div className="absolute right-1 opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity">
<div className="absolute right-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 flex items-center gap-0.5 transition-opacity">
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setRenameTarget(s.session_id); setRenameValue(s.title || ""); }}
className="p-1 text-muted-foreground hover:text-foreground rounded"
className="p-1.5 text-muted-foreground hover:text-foreground rounded"
title={t('layout.rename')}
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeleteTarget(s.session_id); }}
className="p-1 text-muted-foreground hover:text-danger rounded"
className="p-1.5 text-muted-foreground hover:text-danger rounded"
title={t('layout.delete')}
>
<Trash2 className="h-3 w-3" />
@@ -212,7 +237,7 @@ export function Layout() {
{collapsed && <div className="flex-1" />}
{/* Footer */}
<div className={cn("border-t", collapsed ? "p-1 flex flex-col items-center gap-1" : "p-3 space-y-2")}>
<div className={cn("mt-auto border-t border-border/60", collapsed ? "p-1 flex flex-col items-center gap-1" : "p-3 space-y-2 max-md:p-1 max-md:flex max-md:flex-col max-md:items-center max-md:gap-1 max-md:space-y-0")}>
{collapsed ? (
<>
<button onClick={toggle} className="p-1.5 text-muted-foreground hover:text-foreground rounded transition-colors" title={dark ? t('layout.light') : t('layout.dark')}>
@@ -224,27 +249,27 @@ export function Layout() {
</>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between max-md:flex-col">
<button
onClick={toggle}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-1.5 p-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{dark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{dark ? t('layout.light') : t('layout.dark')}
<span className="max-md:hidden">{dark ? t('layout.light') : t('layout.dark')}</span>
</button>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 max-md:hidden">
<button
onClick={() => setCollapsed(true)}
className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
className="p-1.5 text-muted-foreground hover:text-foreground rounded transition-colors"
title={t('layout.collapse')}
>
<ChevronsLeft className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 max-md:items-center">
<LanguageSwitcher />
<p className="text-[10px] text-muted-foreground/60">{t('app.version')}</p>
<p className="text-[10px] text-muted-foreground/60 max-md:hidden">{t('app.version')}</p>
</div>
</>
)}
@@ -254,7 +279,7 @@ export function Layout() {
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
<ConnectionBanner status={sseStatus} retryAttempt={sseRetryAttempt} />
<main className="flex-1 overflow-auto">
<main id="main" className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
@@ -358,11 +383,11 @@ function LanguageSwitcher() {
aria-haspopup="menu"
aria-expanded={open}
aria-label={t("layout.language")}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-1 p-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors max-md:justify-center"
>
<Languages className="h-3.5 w-3.5 shrink-0" />
<span className="whitespace-nowrap">{current.label}</span>
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-180")} />
<span className="whitespace-nowrap max-md:hidden">{current.label}</span>
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform max-md:hidden", open && "rotate-180")} />
</button>
{open && menuStyle && (
<ul
@@ -375,7 +400,7 @@ function LanguageSwitcher() {
minWidth: menuStyle.minWidth,
zIndex: 60,
}}
className="rounded-md border border-border bg-popover shadow-lg ring-1 ring-black/5"
className="rounded-md border border-border/60 bg-popover shadow-lg ring-1 ring-black/5"
>
{SUPPORTED_LANGUAGES.map((lang) => {
const active = lang.code === current.code;
@@ -12,15 +12,15 @@ describe("ConnectionBanner", () => {
expect(container.innerHTML).toBe("");
});
it("shows reconnecting message with attempt number", () => {
it("shows the calm reconnecting message regardless of attempt count", () => {
render(<ConnectionBanner status="reconnecting" retryAttempt={3} />);
expect(screen.getByText(/reconnecting/i)).toBeInTheDocument();
expect(screen.getByText(/attempt 3/)).toBeInTheDocument();
expect(screen.getByText(/Reconnecting/)).toBeInTheDocument();
});
it("defaults to attempt 1 when retryAttempt is not provided", () => {
it("renders the same message when retryAttempt is not provided", () => {
render(<ConnectionBanner status="reconnecting" />);
expect(screen.getByText(/attempt 1/)).toBeInTheDocument();
expect(screen.getByText(/Reconnecting/)).toBeInTheDocument();
});
it("has warning styling", () => {
@@ -0,0 +1,126 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { Layout } from "../Layout";
const sessions = [
{
session_id: "session-1",
title: "A very long session title that must truncate",
},
];
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => ({
"app.version": "v0.1.12",
"layout.agent": "Agent",
"layout.alphaZoo": "Alpha Zoo",
"layout.cancel": "Cancel",
"layout.collapse": "Collapse",
"layout.confirm": "Confirm",
"layout.correlation": "Correlation Matrix",
"layout.dark": "Dark",
"layout.delete": "Delete",
"layout.expand": "Expand",
"layout.home": "Home",
"layout.language": "Language",
"layout.light": "Light",
"layout.mainNavigation": "Main navigation",
"layout.newChat": "New Chat",
"layout.noSessions": "No sessions yet",
"layout.rename": "Rename",
"layout.reports": "Reports",
"layout.runtime": "Runtime",
"layout.sessions": "Sessions",
"layout.settings": "Settings",
"layout.sidebar": "Vibe-Trading sidebar",
"layout.skipToMain": "Skip to main content",
})[key] ?? key,
i18n: {
language: "en",
languages: ["en"],
changeLanguage: vi.fn().mockResolvedValue(undefined),
},
}),
}));
vi.mock("@/hooks/useDarkMode", () => ({
useDarkMode: () => ({ dark: false, toggle: vi.fn() }),
}));
vi.mock("@/lib/api", () => ({
api: {
listSessions: vi.fn().mockResolvedValue([
{
session_id: "session-1",
title: "A very long session title that must truncate",
},
]),
deleteSession: vi.fn().mockResolvedValue(undefined),
renameSession: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("@/stores/agent", () => ({
useAgentStore: (selector: (state: {
sseStatus: string;
sseRetryAttempt: number;
streamingSessionId: null;
}) => unknown) => selector({
sseStatus: "connected",
sseRetryAttempt: 0,
streamingSessionId: null,
}),
}));
function renderLayout() {
return render(
<MemoryRouter initialEntries={["/agent"]}>
<Routes>
<Route element={<Layout />}>
<Route path="/agent" element={<div>Agent content</div>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
describe("Layout accessibility", () => {
it("labels landmarks, brand, main content, and the new-chat affordance", () => {
renderLayout();
expect(screen.getByRole("complementary", { name: "Vibe-Trading sidebar" })).toHaveClass("max-md:w-12");
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Vibe-Trading" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "New Chat" })).toHaveAttribute("title", "New Chat");
expect(screen.getByText("Skip to main content")).toHaveAttribute("href", "#main");
expect(screen.getByRole("main")).toHaveAttribute("id", "main");
});
it("exposes session actions on keyboard focus and labels the rename input", async () => {
renderLayout();
const title = await screen.findByText(sessions[0].title);
expect(title).toHaveClass("min-w-0", "truncate");
const renameButton = screen.getByRole("button", { name: "Rename" });
expect(renameButton.parentElement).toHaveClass("group-focus-within:opacity-100");
fireEvent.click(renameButton);
expect(screen.getByRole("textbox", { name: `Rename: ${sessions[0].title}` })).toHaveClass(
"focus:ring-2",
"focus:ring-primary/40",
);
});
it("does not crash when localStorage access is blocked", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new DOMException("Blocked", "SecurityError");
});
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("Blocked", "SecurityError");
});
expect(() => renderLayout()).not.toThrow();
});
});
@@ -1,43 +1,66 @@
import { renderHook, act } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { useDarkMode } from "../useDarkMode";
function mockMatchMedia(matches: boolean) {
const listeners = new Set<(event: MediaQueryListEvent) => void>();
const mediaQuery = {
matches,
media: "(prefers-color-scheme: dark)",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => {
listeners.add(listener);
}),
removeEventListener: vi.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => {
listeners.delete(listener);
}),
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList;
vi.spyOn(window, "matchMedia").mockReturnValue(mediaQuery);
return {
emit(nextMatches: boolean) {
listeners.forEach((listener) => listener({ matches: nextMatches } as MediaQueryListEvent));
},
};
}
describe("useDarkMode", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = "";
mockMatchMedia(false);
});
it("defaults to light when no preference stored and OS is light", () => {
it("follows the light system theme when no preference is stored", () => {
const { result } = renderHook(() => useDarkMode());
expect(result.current.dark).toBe(false);
expect(localStorage.getItem("qa-theme")).toBeNull();
});
it("reads dark preference from localStorage", () => {
it("follows the dark system theme when no preference is stored", () => {
mockMatchMedia(true);
const { result } = renderHook(() => useDarkMode());
expect(result.current.dark).toBe(true);
expect(localStorage.getItem("qa-theme")).toBeNull();
});
it("uses a stored preference over the system theme", () => {
localStorage.setItem("qa-theme", "dark");
const { result } = renderHook(() => useDarkMode());
expect(result.current.dark).toBe(true);
});
it("reads light preference from localStorage", () => {
localStorage.setItem("qa-theme", "light");
it("persists a preference only when toggled", () => {
const { result } = renderHook(() => useDarkMode());
expect(result.current.dark).toBe(false);
});
it("toggles dark mode", () => {
const { result } = renderHook(() => useDarkMode());
expect(result.current.dark).toBe(false);
act(() => result.current.toggle());
expect(result.current.dark).toBe(true);
act(() => result.current.toggle());
expect(result.current.dark).toBe(false);
});
it("persists preference to localStorage on change", () => {
const { result } = renderHook(() => useDarkMode());
expect(localStorage.getItem("qa-theme")).toBe("light");
expect(localStorage.getItem("qa-theme")).toBeNull();
act(() => result.current.toggle());
expect(localStorage.getItem("qa-theme")).toBe("dark");
@@ -46,14 +69,38 @@ describe("useDarkMode", () => {
expect(localStorage.getItem("qa-theme")).toBe("light");
});
it("toggles dark class on document.documentElement", () => {
it("keeps the root class and color scheme in sync", () => {
const { result } = renderHook(() => useDarkMode());
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(document.documentElement.style.colorScheme).toBe("light");
act(() => result.current.toggle());
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(document.documentElement.style.colorScheme).toBe("dark");
});
act(() => result.current.toggle());
expect(document.documentElement.classList.contains("dark")).toBe(false);
it("re-reads the stored preference after a cross-tab storage event", () => {
const { result } = renderHook(() => useDarkMode());
localStorage.setItem("qa-theme", "dark");
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "qa-theme" })));
expect(result.current.dark).toBe(true);
expect(document.documentElement.style.colorScheme).toBe("dark");
});
it("resumes following system changes when the stored preference is removed", () => {
localStorage.setItem("qa-theme", "light");
const system = mockMatchMedia(false);
const { result } = renderHook(() => useDarkMode());
localStorage.removeItem("qa-theme");
act(() => {
window.dispatchEvent(new StorageEvent("storage", { key: "qa-theme" }));
system.emit(true);
});
expect(result.current.dark).toBe(true);
expect(localStorage.getItem("qa-theme")).toBeNull();
});
});
+71 -8
View File
@@ -1,16 +1,79 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { safeGet, safeSet } from "@/lib/storage";
import { publishThemeChange } from "@/lib/theme-store";
const STORAGE_KEY = "qa-theme";
function getSystemPreference(): boolean {
if (typeof window.matchMedia !== "function") return false;
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
} catch {
return false;
}
}
function getPreferredTheme(): boolean {
const saved = safeGet(STORAGE_KEY);
if (saved === "dark") return true;
if (saved === "light") return false;
return getSystemPreference();
}
export function useDarkMode() {
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem("qa-theme");
if (saved) return saved === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
const [dark, setDark] = useState(getPreferredTheme);
const darkRef = useRef(dark);
useEffect(() => {
darkRef.current = dark;
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("qa-theme", dark ? "dark" : "light");
document.documentElement.style.colorScheme = dark ? "dark" : "light";
publishThemeChange();
}, [dark]);
return { dark, toggle: () => setDark((d) => !d) };
useEffect(() => {
const syncTheme = (nextDark: boolean) => {
const changed = darkRef.current !== nextDark;
darkRef.current = nextDark;
setDark(nextDark);
if (!changed) publishThemeChange();
};
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY && event.key !== null) return;
syncTheme(getPreferredTheme());
};
window.addEventListener("storage", onStorage);
if (typeof window.matchMedia !== "function") {
return () => window.removeEventListener("storage", onStorage);
}
let mediaQuery: MediaQueryList;
try {
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
} catch {
return () => window.removeEventListener("storage", onStorage);
}
const onSystemChange = (event: MediaQueryListEvent) => {
if (safeGet(STORAGE_KEY) !== null) return;
syncTheme(event.matches);
};
mediaQuery.addEventListener("change", onSystemChange);
return () => {
window.removeEventListener("storage", onStorage);
mediaQuery.removeEventListener("change", onSystemChange);
};
}, []);
const toggle = useCallback(() => {
const nextDark = !darkRef.current;
darkRef.current = nextDark;
safeSet(STORAGE_KEY, nextDark ? "dark" : "light");
setDark(nextDark);
}, []);
return { dark, toggle };
}
+88 -15
View File
@@ -1,11 +1,8 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { safeRemove } from "@/lib/storage";
import en from "./locales/en.json";
import zhCN from "./locales/zh-CN.json";
import ja from "./locales/ja.json";
import ko from "./locales/ko.json";
import ar from "./locales/ar.json";
// Language registry — keep in sync with the Layout switcher and README_xx.md.
// `dir` flags whether the language is right-to-left so the app can mirror the
@@ -19,6 +16,51 @@ export const SUPPORTED_LANGUAGES = [
] as const;
export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
type LazyLanguageCode = Exclude<SupportedLanguageCode, "en">;
const localeLoaders = {
"zh-CN": () => import("./locales/zh-CN.json"),
ja: () => import("./locales/ja.json"),
ko: () => import("./locales/ko.json"),
ar: () => import("./locales/ar.json"),
} satisfies Record<LazyLanguageCode, () => Promise<{ default: typeof en }>>;
const LANGUAGE_STORAGE_KEY = "i18nextLng";
const LOCALE_LOAD_ATTEMPTS = 2;
const loadedLanguages = new Set<SupportedLanguageCode>(["en"]);
const loadingLanguages = new Map<LazyLanguageCode, Promise<void>>();
let lastStableLanguage: SupportedLanguageCode = "en";
function getLazyLanguage(code: string): LazyLanguageCode | undefined {
return (Object.keys(localeLoaders) as LazyLanguageCode[]).find(
(supportedCode) => code === supportedCode || code.startsWith(`${supportedCode}-`),
);
}
async function loadLanguage(code: LazyLanguageCode): Promise<void> {
if (loadedLanguages.has(code)) return;
const inFlight = loadingLanguages.get(code);
if (inFlight) return inFlight;
const load = (async () => {
let lastError: unknown;
for (let attempt = 0; attempt < LOCALE_LOAD_ATTEMPTS; attempt += 1) {
try {
const { default: resources } = await localeLoaders[code]();
i18n.addResourceBundle(code, "translation", resources, true, true);
loadedLanguages.add(code);
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
})().finally(() => loadingLanguages.delete(code));
loadingLanguages.set(code, load);
return load;
}
const RTL_CODES = new Set<SupportedLanguageCode>(
SUPPORTED_LANGUAGES.filter((l) => l.dir === "rtl").map((l) => l.code),
@@ -37,17 +79,55 @@ export function applyDocumentDirection(code: string): void {
document.documentElement.setAttribute("lang", code);
}
// Keep the <html dir/lang> attributes in sync with the active language and
// lazily register non-English resources. English remains available as the
// fallback while a requested locale is loading, so the UI never renders keys.
i18n.on("languageChanged", (lng) => {
applyDocumentDirection(lng);
const lazyLanguage = getLazyLanguage(lng);
if (!lazyLanguage) {
lastStableLanguage = "en";
return;
}
if (loadedLanguages.has(lazyLanguage)) {
lastStableLanguage = lazyLanguage;
return;
}
const previousLanguage = lastStableLanguage;
void loadLanguage(lazyLanguage)
.then(() => {
// Do not let a slow import undo a newer language selection.
if (getLazyLanguage(i18n.language) === lazyLanguage) {
return i18n.changeLanguage(lazyLanguage);
}
})
.catch(async (error: unknown) => {
// A later selection owns the document now; do not roll it back.
if (getLazyLanguage(i18n.language) !== lazyLanguage) return;
console.warn(
`[i18n] Failed to load locale "${lazyLanguage}" after ${LOCALE_LOAD_ATTEMPTS} attempts; reverting to "${previousLanguage}".`,
error,
);
safeRemove(LANGUAGE_STORAGE_KEY);
await i18n.changeLanguage(previousLanguage);
// LanguageDetector caches every change, including the rollback. Keep the
// failed explicit selection cleared so the next load starts from a safe default.
safeRemove(LANGUAGE_STORAGE_KEY);
});
});
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
"zh-CN": { translation: zhCN },
ja: { translation: ja },
ko: { translation: ko },
ar: { translation: ar },
},
initAsync: false,
// Default to English for everyone on first visit; only an explicit toggle
// (persisted to localStorage) switches language. After a manual choice
// the navigator value can act as a fallback when the saved language is
@@ -71,14 +151,7 @@ i18n
},
});
// Keep the <html dir/lang> attributes in sync with the active language so
// RTL languages (Arabic today, Hebrew if added later) render correctly
// without a page reload.
applyDocumentDirection(i18n.language || "en");
i18n.on("languageChanged", (lng) => applyDocumentDirection(lng));
// Re-apply after async detection resolves — the synchronous call above may
// fire before LanguageDetector finishes, causing a brief LTR flash for RTL
// users on first visit.
i18n.on("initialized", () => {
if (i18n.language) applyDocumentDirection(i18n.language);
});
+84 -31
View File
@@ -21,7 +21,11 @@
"dark": "داكن",
"language": "اللغة",
"collapse": "طي",
"expand": "توسيع"
"expand": "توسيع",
"skipToMain": "تخطَّ إلى المحتوى الرئيسي",
"sidebar": "الشريط الجانبي لـ Vibe-Trading",
"mainNavigation": "التنقل الرئيسي",
"about": "حول"
},
"runtime": {
"monitorBadge": "مراقب وقت التشغيل",
@@ -142,17 +146,14 @@
"return": "العائد",
"sharpe": "Sharpe",
"fullReport": "التقرير الكامل",
"compare": "مقارنة"
"compare": "مقارنة",
"unknown": "غير معروف"
},
"connection": {
"reconnecting": "فُقد الاتصال، تتم إعادة الاتصال (المحاولة {{attempt}})…",
"reconnecting": "جارٍ إعادة الاتصال…",
"disconnected": "فُقد الاتصال"
},
"welcome": {
"title": "Vibe-Trading",
"subtitle": "تداول بالذكاء مع فريقك من الوكلاء الماليين المحترفين",
"describePrompt": "صف استراتيجية تداول لتبدأ.",
"tryExample": "جرّب مثالًا:",
"categories": {
"multiMarketBacktest": "اختبار عكسي لأسواق متعددة",
"researchAnalysis": "البحث والتحليل",
@@ -164,7 +165,7 @@
"valueInvesting": "استثمار القيمة"
},
"examples": {
"crossMarketPortfolio": "تحسين محفظة أسهم A",
"crossMarketPortfolio": "موازنة محفظة من ثلاثة أسهم",
"crossMarketPortfolioDesc": "تحسين محفظة أسهم A بمُحسّن تكافؤ المخاطر",
"btcMacd": "استراتيجية MACD لأسهم A",
"btcMacdDesc": "اختبار عكسي لاستراتيجية MACD لأسهم A على مستوى الدقيقة",
@@ -172,7 +173,7 @@
"usTechMaxDivDesc": "محفظة أقصى تنويع للأسهم الزرقاء",
"multiFactorAlpha": "نموذج ألفا متعدد العوامل",
"multiFactorAlphaDesc": "تركيب عوامل مرجّحة بـ IC عبر 300 سهم",
"optionsGreeks": "تحليل Greeks للخيارات",
"optionsGreeks": "فحص مخاطر الخيارات (Greeks)",
"optionsGreeksDesc": "تسعير Black-Scholes مع تحليل Delta/Gamma/Theta/Vega",
"valueCommittee": "لجنة استثمار القيمة",
"valueCommitteeDesc": "نقاش أربعة أساتذة حول سهم واحد: Buffett / Munger / Duan Yongping / Li Lu",
@@ -183,10 +184,10 @@
"thesisTracker": "متتبع أطروحة الاستثمار",
"thesisTrackerDesc": "اكتب أطروحة من 5 جمل + خطوط حمراء + مراسي تقييم لحيازة",
"thesisTrackerPrompt": "استخدم مهارة thesis-tracker لبناء أطروحة استثمارية لـ Kweichow Moutai (600519.SH): أطروحة أساسية من 5 جمل، افتراضات قابلة للدحض، خطوط حمراء، ومراسي تقييم، مع التحقق عبر financial_rigor",
"valuationCheck": "فحص تقييم دقيق",
"valuationCheck": "هل السهم مُقيَّم بأكثر من قيمته؟",
"valuationCheckDesc": "تحقق من PE / PB / ROE / القيمة السوقية بحساب عشري دقيق دون انجراف عائم",
"valuationCheckPrompt": "استخدم أداة financial_rigor للتحقق من تقييم Kweichow Moutai: السعر 1500، EPS 68.6، القيمة الدفترية للسهم 180 — احسب PE وPB وROE بدقة، ثم تقييمًا بثلاثة سيناريوهات (نمو 12%/8%/0%، PE 22/18/14، 3 سنوات)",
"investmentCommittee": "مراجعة لجنة الاستثمار",
"investmentCommittee": "شراء أم بيع؟ دع اللجنة تتناقش",
"investmentCommitteeDesc": "نقاش متعدد الوكلاء: شراء مقابل بيع، مراجعة المخاطر، قرار مدير المحفظة",
"quantStrategyDesk": "مكتب استراتيجية الكمّ",
"quantStrategyDeskDesc": "خط أنابيب: فرز → بحث عوامل → اختبار عكسي → تدقيق المخاطر",
@@ -228,22 +229,22 @@
"shadowDeltaPrompt": "شغّل اختبارًا عكسيًا ظليًا لآخر 90 يومًا وحلّل أين انحرفت أرباحي عن الظلّ (انتهاكات القواعد، الخروج المبكر، إشارات مفقودة)",
"shadowReportPrompt": "اعرض تقرير الظلّ وأعطِني الرابط — ابدأ بفرق أرباحك مقابل الظلّ"
},
"capabilities": {
"financeSkills": "مكتبة المهارات المالية",
"swarmTeams": "فرق وكلاء السرب",
"autoTools": "أدوات مكتشفة تلقائيًا",
"markets": "أسهم A · متعدد العوامل · كمّي",
"connectors": "ملفات تعريف موصلات التداول",
"timeframes": "أطر زمنية من الدقيقة إلى اليومي",
"optimizers": "4 محسّنات للمحفظة",
"riskMetrics": "أكثر من 15 مقياس مخاطر",
"options": "الخيارات والمشتقات",
"pdfWeb": "بحث PDF والويب",
"factorML": "تحليل العوامل وتعلّم الآلة",
"journalAnalyzer": "محلّل يومية التداول",
"shadowBacktest": "اختبار عكسي للحساب الظلّي",
"memory": "ذاكرة دائمة",
"sessionSearch": "بحث الجلسات"
"taskSubtitle": "اسأل عن سهم أو استراتيجية أو محفظتك — سأجلب البيانات وأجري الحسابات وأعرض كل خطوة.",
"quickActions": "إجراءات سريعة",
"browseAllExamples": "تصفح جميع الأمثلة",
"greetings": {
"morning1": "صباح الخير.",
"morning2": "صباح الخير — جاهزون متى شئت.",
"morning3": "صباح الخير. لنبدأ.",
"afternoon1": "نهارك سعيد.",
"afternoon2": "هل أنت مستعد للسؤال التالي؟",
"afternoon3": "ماذا نستكشف بعد الظهر؟",
"evening1": "مساء الخير.",
"evening2": "لنفهم السوق معًا.",
"evening3": "هل نبدأ بحثًا مركزًا؟",
"night1": "ما زلت تفكر؟",
"night2": "لنرتب الأفكار معًا.",
"night3": "فكرة أخيرة قبل أن تختم يومك؟"
}
},
"settings": {
@@ -354,7 +355,12 @@
"shellTools": "أدوات Shell",
"scheduler": "المُجدوِل",
"sessionRuntime": "وقت تشغيل الجلسة",
"flagsReadFrom": "تُقرأ الأعلام من {{env1}} و{{env2}} و{{env3}} في {{env_path}}."
"flagsReadFrom": "تُقرأ الأعلام من {{env1}} و{{env2}} و{{env3}} في {{env_path}}.",
"unknownError": "خطأ غير معروف",
"providerChangeHint": "يؤدي تغيير المزوّد إلى تحديث النموذج ونقطة النهاية الموصى بهما.",
"oauth": "OAuth",
"apiKey": "مفتاح API",
"tushareTokenDesc": "يُستخدم لبيانات الأسهم الصينية (فئة A) والعقود الآجلة والصناديق والاقتصاد الكلي. إذا لم يُضبط، يتراجع المشروع إلى AKShare حيثما توفر."
},
"qveris": {
"title": "سوق أدوات QVeris",
@@ -437,7 +443,12 @@
"moreOptions": "خيارات إضافية",
"uploadPdf": "رفع مستند PDF",
"researchGoal": "هدف البحث",
"agentSwarm": "سرب الوكلاء"
"agentSwarm": "سرب الوكلاء",
"attachmentChip": "مرفق",
"swarmModeChip": "وضع السرب",
"goalModeChip": "هدف البحث",
"continueGoalMessage": "متابعة هدف البحث: {{goal}}",
"loadEarlier": "تحميل الرسائل الأقدم"
},
"runDetail": {
"title": "تفاصيل التشغيل",
@@ -515,7 +526,9 @@
"trades": "عدد التداولات",
"maxConsecLosses": "أقصى خسائر متتالية",
"exposureTime": "وقت التعرّض",
"avgHoldingPeriod": "متوسط فترة الاحتفاظ"
"avgHoldingPeriod": "متوسط فترة الاحتفاظ",
"better": "أفضل",
"worse": "أسوأ"
},
"correlation": {
"title": "مصفوفة الارتباط",
@@ -742,7 +755,9 @@
"time": "الوقت",
"iters": "التكرارات",
"output": "المخرجات",
"waitingForEvents": "بانتظار أحداث الوكيل..."
"waitingForEvents": "بانتظار أحداث الوكيل...",
"teamStarting": "الفريق قيد التشغيل — أولى التحديثات خلال ثوانٍ…",
"detailsUnavailable": "التفاصيل غير متاحة"
},
"mandate": {
"runtimeMandate": "تفويض وقت تشغيل الموصل",
@@ -819,5 +834,43 @@
"noPriceData": "لا توجد بيانات أسعار",
"noCorrelationData": "لا توجد بيانات ارتباط",
"noEquityData": "لا توجد بيانات أصول"
},
"toolProgress": {
"step": "الخطوة {{step}} · {{tool}}",
"toolsRunning": "الأدوات قيد التشغيل: {{count}}",
"toolsCompleted": "الأدوات المكتملة: {{count}}",
"more": "… و{{count}} أخرى",
"etaSeconds": "متبقٍ نحو {{seconds}} ثانية",
"earlier": "+{{count}} سابقة"
},
"swarm": {
"assemblingTeam": "جارٍ تشكيل الفريق…",
"preparingLayers": "جارٍ تجهيز الطبقات…",
"agentProgress": "تقدم وكلاء السرب",
"status": {
"waiting": "في الانتظار",
"running": "قيد التشغيل",
"done": "منجز",
"failed": "فشل",
"blocked": "محجوب",
"retry": "إعادة المحاولة",
"cancelled": "أُلغي",
"pending": "بانتظار البدء",
"completed": "مكتمل",
"unknown": "غير معروف"
},
"elapsed": {
"seconds": "{{value}} ث",
"minutesSeconds": "{{minutes}} د {{seconds}} ث"
},
"presets": {
"investment_committee": "مراجعة لجنة الاستثمار",
"quant_strategy_desk": "مكتب الاستراتيجيات الكمية",
"value_investing_committee": "لجنة الاستثمار القيمي"
}
},
"metrics": {
"good": "جيد",
"poor": "ضعيف"
}
}
+103 -50
View File
@@ -17,7 +17,7 @@
"correlation": "Correlation Matrix",
"sessions": "Sessions",
"newChat": "New Chat",
"noSessions": "No sessions yet",
"noSessions": "No chats yet — press + to start one",
"confirm": "Confirm",
"cancel": "Cancel",
"rename": "Rename",
@@ -26,7 +26,11 @@
"dark": "Dark",
"language": "Language",
"collapse": "Collapse",
"expand": "Expand"
"expand": "Expand",
"skipToMain": "Skip to main content",
"sidebar": "Vibe-Trading sidebar",
"mainNavigation": "Main navigation",
"about": "About"
},
"runtime": {
"monitorBadge": "Runtime Monitor",
@@ -147,21 +151,18 @@
"return": "Return",
"sharpe": "Sharpe",
"fullReport": "Full Report",
"compare": "Compare"
"compare": "Compare",
"unknown": "Unknown"
},
"connection": {
"reconnecting": "Connection lost, reconnecting (attempt {{attempt}})…",
"disconnected": "Connection lost"
"reconnecting": "Reconnecting…",
"disconnected": "Connection lost. Reload the page to reconnect."
},
"welcome": {
"title": "Vibe-Trading",
"subtitle": "vibe trading with your professional financial agent team",
"describePrompt": "Describe a trading strategy to get started.",
"tryExample": "Try an example:",
"categories": {
"multiMarketBacktest": "A-Share Backtest",
"researchAnalysis": "Research & Analysis",
"swarmTeams": "Swarm Teams",
"swarmTeams": "AI Analyst Teams",
"docWebResearch": "Document & Web Research",
"tradeJournal": "Trade Journal",
"tradingConnectors": "Trading Connectors",
@@ -169,34 +170,34 @@
"valueInvesting": "Value Investing"
},
"examples": {
"crossMarketPortfolio": "A-Share Portfolio Optimization",
"crossMarketPortfolioDesc": "A-shares portfolio optimization with risk-parity optimizer",
"crossMarketPortfolio": "Balance a 3-stock portfolio",
"crossMarketPortfolioDesc": "Split money across 3 A-shares so each carries equal risk (risk parity), then compare to equal weights",
"crossMarketPortfolioPrompt": "Build a risk-parity portfolio with 000001.SZ, 600519.SH, 000858.SZ, backtest for the full year of 2024, and compare with equal-weighted benchmark",
"btcMacd": "A-Share MACD Strategy",
"btcMacdDesc": "Minute-level A-share MACD strategy backtest",
"btcMacdPrompt": "Backtest MACD strategy on 000001.SZ 5-minute bars: fast=12, slow=26, signal=9, last 30 days",
"usTechMaxDiv": "Blue-Chip Max Diversification",
"usTechMaxDivDesc": "Blue-chip max diversification portfolio",
"usTechMaxDiv": "Spread Risk Across Blue Chips",
"usTechMaxDivDesc": "Weights 5 blue chips so no single name dominates the risk (max-diversification optimizer)",
"usTechMaxDivPrompt": "Backtest the maximum diversification portfolio for 600519.SH, 000858.SZ, 601318.SH, 000001.SZ, 600036.SH for the full year of 2024",
"multiFactorAlpha": "Multi-Factor Alpha Model",
"multiFactorAlphaDesc": "IC-weighted factor synthesis across 300 stocks",
"multiFactorAlphaDesc": "Blend 4 stock-picking signals across 300 stocks, weighted by how well each predicted returns (IC)",
"multiFactorAlphaPrompt": "Build a multi-factor alpha model using momentum, reversal, volatility, and turnover on CSI 300 constituents with IC-weighted factor synthesis, backtest 2023-2024",
"optionsGreeks": "Options Greeks Analysis",
"optionsGreeksDesc": "Black-Scholes pricing with Delta/Gamma/Theta/Vega",
"optionsGreeks": "Options risk check (Greeks)",
"optionsGreeksDesc": "Price an option, then see how much it moves with the stock, with time, and with volatility (the Greeks)",
"optionsGreeksPrompt": "Calculate option Greeks using Black-Scholes: spot=100, strike=105, risk-free rate=3%, vol=25%, expiry=90 days, analyze Delta/Gamma/Theta/Vega",
"valueCommittee": "Value Investing Committee",
"valueCommitteeDesc": "Buffett / Munger / Duan Yongping / Li Lu — four-master debate on one stock",
"valueCommitteePrompt": "Use run_swarm with the value_investing_committee preset to analyze Tencent (00700.HK): Buffett on moat & price, Munger on inversion, Duan Yongping on business & management, Li Lu on 10-year certainty, then the chair synthesizes consensus and contradictions",
"bottleneckHunter": "Supply-Chain Bottleneck Hunter",
"bottleneckHunterDesc": "Find hidden Layer 2/3 choke-point winners in a super-trend",
"bottleneckHunterDesc": "Find the small suppliers a big trend can't run without, then check if they're still cheap",
"bottleneckHunterPrompt": "Use the bottleneck-hunter skill to find Layer 2/3 supply-chain bottleneck opportunities in AI infrastructure (optics / lasers / InP substrates), score each choke point and list candidate companies that pass the valuation gate",
"thesisTracker": "Investment Thesis Tracker",
"thesisTrackerDesc": "Write a 5-sentence thesis + red lines + valuation anchors for a holding",
"thesisTrackerDesc": "Write down why you own it in 5 sentences, plus the red lines that would make you sell",
"thesisTrackerPrompt": "Use the thesis-tracker skill to build an investment thesis for Kweichow Moutai (600519.SH): 5-sentence core thesis, falsifiable assumptions, red lines, and valuation anchors, verified with financial_rigor",
"valuationCheck": "Exact Valuation Check",
"valuationCheckDesc": "Verify PE / PB / ROE / market cap with exact decimal arithmetic (no float drift)",
"valuationCheck": "Check if a stock is expensive",
"valuationCheckDesc": "Recompute PE / PB / ROE / market cap to the cent, then stress it under 3 growth scenarios",
"valuationCheckPrompt": "Use the financial_rigor tool to verify Kweichow Moutai's valuation: price 1500, EPS 68.6, book value per share 180 — compute PE, PB, ROE exactly, then a three-scenario valuation (growth 12%/8%/0%, PE 22/18/14, 3 years)",
"investmentCommittee": "Investment Committee Review",
"investmentCommittee": "Buy or sell? Let a committee debate",
"investmentCommitteeDesc": "Multi-agent debate: long vs short, risk review, PM decision",
"investmentCommitteePrompt": "[Swarm Team Mode] Use the investment_committee preset to evaluate whether to go long or short on 600519.SH given current market conditions",
"quantStrategyDesk": "Quant Strategy Desk",
@@ -209,7 +210,7 @@
"macroResearchDesc": "Macro analysis and A-share market outlook",
"macroResearchPrompt": "Read the latest central bank monetary policy report and summarize its impact on the A-share market",
"analyzeBrokerExport": "Analyze My Broker Export",
"analyzeBrokerExportDesc": "Parse 同花顺/东财/富途/generic CSV — holding days, win rate, PnL ratio, hourly distribution",
"analyzeBrokerExportDesc": "Reads exports from Tonghuashun / Eastmoney / Futu, or any CSV — holding days, win rate, profit-to-loss ratio, time-of-day pattern",
"analyzeBrokerExportPrompt": "Analyze the trade journal I just uploaded — full profile with holding stats, win rate, top symbols, and hourly distribution",
"diagnoseBehavior": "Diagnose My Behavior Biases",
"diagnoseBehaviorDesc": "Disposition effect, overtrading, chasing momentum, anchoring — severity + numeric evidence",
@@ -230,25 +231,25 @@
"shadowDeltaDesc": "Backtest your shadow strategy and attribute delta vs. your actual PnL",
"shadowDeltaPrompt": "Run a shadow backtest for the last 90 days and break down where my PnL diverged from the shadow (rule violations, early exits, missed signals)",
"shadowReport": "Generate Shadow Report",
"shadowReportDesc": "8-section HTML/PDF — equity curve, per-market Sharpe, attribution waterfall",
"shadowReportDesc": "A shareable 8-part report: your account curve, risk-adjusted return, and where each gain came from",
"shadowReportPrompt": "Render the shadow report and give me the URL — lead with the you-vs-shadow delta"
},
"capabilities": {
"financeSkills": "Finance Skills Library",
"swarmTeams": "Swarm Agent Teams",
"autoTools": "Auto-Discovered Tools",
"markets": "A-Share · Multi-Factor · Quantitative",
"connectors": "Trading Connector Profiles",
"timeframes": "Minute to Daily Timeframes",
"optimizers": "4 Portfolio Optimizers",
"riskMetrics": "15+ Risk Metrics",
"options": "Options & Derivatives",
"pdfWeb": "PDF & Web Research",
"factorML": "Factor Analysis & ML",
"journalAnalyzer": "Trade Journal Analyzer",
"shadowBacktest": "Shadow Account Backtest",
"memory": "Persistent Memory",
"sessionSearch": "Session Search"
"taskSubtitle": "Ask about a stock, a strategy, or your portfolio — I'll pull the data, run the numbers, and show every step.",
"quickActions": "Quick actions",
"browseAllExamples": "Browse all examples",
"greetings": {
"morning1": "Good morning.",
"morning2": "Morning — ready when you are.",
"morning3": "Good morning. Let's get started.",
"afternoon1": "Good afternoon.",
"afternoon2": "Ready for the next question?",
"afternoon3": "What are we exploring this afternoon?",
"evening1": "Good evening.",
"evening2": "Let's make sense of the market.",
"evening3": "Ready for some focused research?",
"night1": "Still thinking?",
"night2": "Let's work through it.",
"night3": "One more idea before you wrap up?"
}
},
"settings": {
@@ -359,7 +360,12 @@
"shellTools": "Shell Tools",
"scheduler": "Scheduler",
"sessionRuntime": "Session Runtime",
"flagsReadFrom": "Flags are read from {{env1}}, {{env2}}, and {{env3}} in {{env_path}}."
"flagsReadFrom": "Flags are read from {{env1}}, {{env2}}, and {{env3}} in {{env_path}}.",
"unknownError": "Unknown error",
"providerChangeHint": "Changing providers updates the recommended model and endpoint.",
"oauth": "OAuth",
"apiKey": "API key",
"tushareTokenDesc": "Used for China A-share, futures, fund, and macro data. If unset, the project falls back to AKShare where available."
},
"qveris": {
"title": "QVeris Tool Marketplace",
@@ -387,8 +393,8 @@
"agent": {
"connectionLostReconnect": "Connection lost, reconnecting…",
"connectionRestored": "Connection restored",
"agentWorking": "Agent is working…",
"reasoning": "Reasoning…",
"agentWorking": "Getting to work…",
"reasoning": "Thinking it through…",
"running": "running",
"newMessages": "New messages",
"newResearchGoal": "New Research Goal",
@@ -402,7 +408,7 @@
"save": "Save",
"cancelGoal": "Cancel goal",
"describeGoal": "Describe the research goal to attach to this session",
"placeholder": "e.g. Create a dual MA crossover strategy for 000001.SZ, backtest 2024",
"placeholder": "e.g. Test a simple moving-average strategy on 000001.SZ over 2024",
"send": "Send message",
"messageInputLabel": "Message to the agent",
"inputHint": "Enter to send · Shift+Enter for a new line",
@@ -421,8 +427,8 @@
"researchGoalUpdated": "Research goal updated",
"failedToUpdateGoal": "Failed to update goal.",
"failedToLoadGoal": "Failed to load goal.",
"cancelRequestSent": "Cancel request sent",
"cancelFailed": "Cancel failed",
"cancelRequestSent": "Stopping…",
"cancelFailed": "Couldn't stop it — try again.",
"connectorHalted": "Connector runtime halted",
"failedToHaltConnector": "Failed to halt connector runtime.",
"cancelledFromUI": "Cancelled from Web UI.",
@@ -442,7 +448,12 @@
"moreOptions": "More options",
"uploadPdf": "Upload PDF document",
"researchGoal": "Research Goal",
"agentSwarm": "Agent Swarm"
"agentSwarm": "Agent Swarm",
"attachmentChip": "Attachment",
"swarmModeChip": "Team mode",
"goalModeChip": "Research goal",
"continueGoalMessage": "Continue research goal: {{goal}}",
"loadEarlier": "Load earlier messages"
},
"runDetail": {
"title": "Run Detail",
@@ -457,7 +468,7 @@
"downloadTradesCsv": "Download Trades CSV",
"downloadMetricsCsv": "Download Metrics CSV",
"noChartData": "No chart data available",
"noChartDataDesc": "The backtest engine may not have generated price data. Check the artifacts/ directory.",
"noChartDataDesc": "This run didn't save price data, so there's no chart. The metrics below are still valid.",
"symbol": "Symbol",
"showOnly": "Show only",
"addSymbol": "Add symbol",
@@ -520,7 +531,9 @@
"trades": "Trades",
"maxConsecLosses": "Max Consec. Losses",
"exposureTime": "Exposure Time",
"avgHoldingPeriod": "Avg Holding Period"
"avgHoldingPeriod": "Avg Holding Period",
"better": "Better",
"worse": "Worse"
},
"correlation": {
"title": "Correlation Matrix",
@@ -747,7 +760,9 @@
"time": "Time",
"iters": "Iters",
"output": "Output",
"waitingForEvents": "Waiting for agent events..."
"waitingForEvents": "Waiting for agent events...",
"teamStarting": "The team is spinning up — first updates in a few seconds…",
"detailsUnavailable": "details unavailable"
},
"mandate": {
"runtimeMandate": "Connector runtime mandate",
@@ -819,5 +834,43 @@
},
"errorBoundary": {
"somethingWrong": "Something went wrong"
},
"toolProgress": {
"step": "Step {{step}} · {{tool}}",
"toolsRunning": "Tools running: {{count}}",
"toolsCompleted": "Tools completed: {{count}}",
"more": "… +{{count}} more",
"etaSeconds": "~{{seconds}}s left",
"earlier": "+{{count}} earlier"
},
"swarm": {
"assemblingTeam": "Assembling team…",
"preparingLayers": "Preparing layers…",
"agentProgress": "Swarm agent progress",
"status": {
"waiting": "Waiting",
"running": "Running",
"done": "Done",
"failed": "Failed",
"blocked": "Blocked",
"retry": "Retrying",
"cancelled": "Cancelled",
"pending": "Pending",
"completed": "Completed",
"unknown": "Unknown"
},
"elapsed": {
"seconds": "{{value}}s",
"minutesSeconds": "{{minutes}}m {{seconds}}s"
},
"presets": {
"investment_committee": "Investment Committee Review",
"quant_strategy_desk": "Quant Strategy Desk",
"value_investing_committee": "Value Investing Committee"
}
},
"metrics": {
"good": "good",
"poor": "poor"
}
}
+84 -31
View File
@@ -21,7 +21,11 @@
"dark": "ダーク",
"language": "言語",
"collapse": "折りたたむ",
"expand": "展開"
"expand": "展開",
"skipToMain": "メインコンテンツへスキップ",
"sidebar": "Vibe-Trading サイドバー",
"mainNavigation": "メインナビゲーション",
"about": "概要"
},
"runtime": {
"monitorBadge": "ランタイムモニター",
@@ -142,17 +146,14 @@
"return": "リターン",
"sharpe": "Sharpe",
"fullReport": "完全なレポート",
"compare": "比較"
"compare": "比較",
"unknown": "不明"
},
"connection": {
"reconnecting": "接続が切断されました。再接続中です({{attempt}} 回目)…",
"reconnecting": "再接続しています…",
"disconnected": "接続が切断されました"
},
"welcome": {
"title": "Vibe-Trading",
"subtitle": "プロの金融エージェントチームと vibe trading を",
"describePrompt": "取引戦略を記述して開始してください。",
"tryExample": "例を試す:",
"categories": {
"multiMarketBacktest": "マルチマーケットバックテスト",
"researchAnalysis": "リサーチ & 分析",
@@ -164,7 +165,7 @@
"valueInvesting": "バリュー投資"
},
"examples": {
"crossMarketPortfolio": "A株ポートフォリオ最適化",
"crossMarketPortfolio": "3銘柄の配分バランス",
"crossMarketPortfolioDesc": "A株ポートフォリオのリスクパリティ最適化",
"btcMacd": "A株 MACD 戦略",
"btcMacdDesc": "分足データによるA株MACD戦略バックテスト",
@@ -172,7 +173,7 @@
"usTechMaxDivDesc": "ブルーチップ最大分散ポートフォリオ",
"multiFactorAlpha": "マルチファクター Alpha モデル",
"multiFactorAlphaDesc": "300 銘柄に対する IC 重み付きファクター合成",
"optionsGreeks": "オプション Greeks 分析",
"optionsGreeks": "オプションのリスク指標(Greeks)",
"optionsGreeksDesc": "Black-Scholes 価格モデルに基づく Delta/Gamma/Theta/Vega 分析",
"valueCommittee": "バリュー投資委員会",
"valueCommitteeDesc": "Buffett / Munger / Duan Yongping / Li Lu の4視点で1銘柄を討論",
@@ -183,10 +184,10 @@
"thesisTracker": "投資テーシス・トラッカー",
"thesisTrackerDesc": "保有銘柄に5文のテーシス、レッドライン、評価アンカーを作成",
"thesisTrackerPrompt": "thesis-tracker スキルを使い、Kweichow Moutai (600519.SH) の投資テーシスを作ってください: 5文の中核テーシス、反証可能な仮説、レッドライン、評価アンカーを financial_rigor で検証してください",
"valuationCheck": "精密バリュエーション検算",
"valuationCheck": "割高かどうかを検証(PE/PB/ROE)",
"valuationCheckDesc": "厳密な十進演算で PE / PB / ROE / 時価総額を検証 (浮動小数点誤差なし)",
"valuationCheckPrompt": "financial_rigor ツールで Kweichow Moutai の評価を検証してください: 価格 1500、EPS 68.6、1株当たり純資産 180 から PE、PB、ROE を厳密に計算し、3年の3シナリオ評価 (成長率 12%/8%/0%、PE 22/18/14) も実行してください",
"investmentCommittee": "投資委員会レビュー",
"investmentCommittee": "買いか売りか?委員会で討論",
"investmentCommitteeDesc": "マルチエージェント討論: ロング vs ショート、リスクレビュー、PM 決定",
"quantStrategyDesk": "クォント戦略デスク",
"quantStrategyDeskDesc": "スクリーニング → ファクター研究 → バックテスト → リスク監査のパイプライン",
@@ -228,22 +229,22 @@
"shadowDeltaPrompt": "過去90日間のシャドウバックテストを実行し、実際のPnLとシャドウが乖離した箇所 (ルール違反、早期退出、シグナル見逃し) を詳細に分析",
"shadowReportPrompt": "シャドウレポートをレンダリングしてURLを渡す — あなた vs シャドウのデルタを先頭に表示"
},
"capabilities": {
"financeSkills": "金融スキルライブラリ",
"swarmTeams": "Swarm エージェントチーム",
"autoTools": "自動検出ツール",
"markets": "A株 · マルチファクター · クォンツ",
"connectors": "トレーディングコネクタプロファイル",
"timeframes": "分足から日足までの時間軸",
"optimizers": "4 種類のポートフォリオ最適化手法",
"riskMetrics": "15+ リスク指標",
"options": "オプション & デリバティブ",
"pdfWeb": "PDF & Web リサーチ",
"factorML": "ファクター分析 & ML",
"journalAnalyzer": "トレードジャーナル分析",
"shadowBacktest": "シャドウアカウントバックテスト",
"memory": "永続メモリ",
"sessionSearch": "セッション検索"
"taskSubtitle": "銘柄・戦略・ポートフォリオについて聞いてください。データ取得から計算まで、すべての手順をお見せします。",
"quickActions": "クイックアクション",
"browseAllExamples": "すべての例を見る",
"greetings": {
"morning1": "おはようございます。",
"morning2": "おはようございます。いつでもどうぞ。",
"morning3": "おはようございます。始めましょう。",
"afternoon1": "こんにちは。",
"afternoon2": "次の質問の準備はいいですか?",
"afternoon3": "今日の午後は何を探求しますか?",
"evening1": "こんばんは。",
"evening2": "市場を読み解きましょう。",
"evening3": "集中リサーチを始めますか?",
"night1": "まだ考え中ですか?",
"night2": "一緒に整理しましょう。",
"night3": "締めくくる前に、もう一つ試しますか?"
}
},
"settings": {
@@ -354,7 +355,12 @@
"shellTools": "シェルツール",
"scheduler": "スケジューラー",
"sessionRuntime": "セッションランタイム",
"flagsReadFrom": "フラグは {{env_path}} の {{env1}}、{{env2}}、{{env3}} から読み取られます。"
"flagsReadFrom": "フラグは {{env_path}} の {{env1}}、{{env2}}、{{env3}} から読み取られます。",
"unknownError": "不明なエラー",
"providerChangeHint": "プロバイダーを変更すると、推奨モデルとエンドポイントが更新されます。",
"oauth": "OAuth",
"apiKey": "APIキー",
"tushareTokenDesc": "中国A株・先物・ファンド・マクロデータに使用します。未設定の場合、利用可能なところではAKShareにフォールバックします。"
},
"qveris": {
"title": "QVeris ツールマーケットプレイス",
@@ -437,7 +443,12 @@
"moreOptions": "その他のオプション",
"uploadPdf": "PDF ドキュメントをアップロード",
"researchGoal": "リサーチ目標",
"agentSwarm": "エージェント Swarm"
"agentSwarm": "エージェント Swarm",
"attachmentChip": "添付ファイル",
"swarmModeChip": "スウォームモード",
"goalModeChip": "リサーチ目標",
"continueGoalMessage": "リサーチ目標を続行: {{goal}}",
"loadEarlier": "以前のメッセージを読み込む"
},
"runDetail": {
"title": "実行詳細",
@@ -515,7 +526,9 @@
"trades": "トレード数",
"maxConsecLosses": "最大連続損失",
"exposureTime": "エクスポージャー時間",
"avgHoldingPeriod": "平均保有期間"
"avgHoldingPeriod": "平均保有期間",
"better": "良化",
"worse": "悪化"
},
"correlation": {
"title": "相関行列",
@@ -742,7 +755,9 @@
"time": "時間",
"iters": "反復",
"output": "出力",
"waitingForEvents": "エージェントイベントを待機中..."
"waitingForEvents": "エージェントイベントを待機中...",
"teamStarting": "チームを起動中です。数秒で最初の進捗が届きます…",
"detailsUnavailable": "詳細は利用できません"
},
"mandate": {
"runtimeMandate": "コネクタランタイムマンデート",
@@ -819,5 +834,43 @@
"noPriceData": "価格データがありません",
"noCorrelationData": "相関データがありません",
"noEquityData": "エクイティデータがありません"
},
"toolProgress": {
"step": "ステップ {{step}} · {{tool}}",
"toolsRunning": "実行中のツール: {{count}}",
"toolsCompleted": "完了したツール: {{count}}",
"more": "… ほか {{count}} 件",
"etaSeconds": "残り約 {{seconds}} 秒",
"earlier": "ほか {{count}} 件(完了済み)"
},
"swarm": {
"assemblingTeam": "チームを編成中…",
"preparingLayers": "実行レイヤーを準備中…",
"agentProgress": "スウォームエージェントの進捗",
"status": {
"waiting": "待機中",
"running": "実行中",
"done": "完了",
"failed": "失敗",
"blocked": "ブロック中",
"retry": "再試行中",
"cancelled": "キャンセル済み",
"pending": "開始待ち",
"completed": "完了",
"unknown": "不明"
},
"elapsed": {
"seconds": "{{value}}秒",
"minutesSeconds": "{{minutes}}分{{seconds}}秒"
},
"presets": {
"investment_committee": "投資委員会レビュー",
"quant_strategy_desk": "クオンツ戦略デスク",
"value_investing_committee": "バリュー投資委員会"
}
},
"metrics": {
"good": "良好",
"poor": "要注意"
}
}
+84 -31
View File
@@ -21,7 +21,11 @@
"dark": "다크",
"language": "언어",
"collapse": "접기",
"expand": "펼치기"
"expand": "펼치기",
"skipToMain": "본문으로 건너뛰기",
"sidebar": "Vibe-Trading 사이드바",
"mainNavigation": "주 내비게이션",
"about": "소개"
},
"runtime": {
"monitorBadge": "런타임 모니터",
@@ -142,17 +146,14 @@
"return": "수익률",
"sharpe": "Sharpe",
"fullReport": "전체 리포트",
"compare": "비교"
"compare": "비교",
"unknown": "알 수 없음"
},
"connection": {
"reconnecting": "연결이 끊겼습니다. 재연결 중입니다({{attempt}}번째 시도)…",
"reconnecting": "다시 연결하는 중…",
"disconnected": "연결이 끊겼습니다"
},
"welcome": {
"title": "Vibe-Trading",
"subtitle": "전문 금융 에이전트 팀과 함께하는 vibe trading",
"describePrompt": "거래 전략을 설명하여 시작하세요.",
"tryExample": "예시 시도:",
"categories": {
"multiMarketBacktest": "멀티 마켓 백테스트",
"researchAnalysis": "리서치 & 분석",
@@ -164,7 +165,7 @@
"valueInvesting": "가치투자"
},
"examples": {
"crossMarketPortfolio": "A주 포트폴리오 최적화",
"crossMarketPortfolio": "3개 종목 포트폴리오 배분",
"crossMarketPortfolioDesc": "A주 포트폴리오 리스크 패리티 최적화",
"btcMacd": "A주 MACD 전략",
"btcMacdDesc": "분 단위 데이터 기반 A주 MACD 전략 백테스트",
@@ -172,7 +173,7 @@
"usTechMaxDivDesc": "블루칩 최대 분산 포트폴리오",
"multiFactorAlpha": "멀티 팩터 Alpha 모델",
"multiFactorAlphaDesc": "300개 종목에 대한 IC 가중 팩터 합성",
"optionsGreeks": "옵션 Greeks 분석",
"optionsGreeks": "옵션 리스크 지표(Greeks)",
"optionsGreeksDesc": "Black-Scholes 가격 모델, Delta/Gamma/Theta/Vega 분석",
"valueCommittee": "가치투자 위원회",
"valueCommitteeDesc": "Buffett / Munger / Duan Yongping / Li Lu 네 관점으로 한 종목 토론",
@@ -183,10 +184,10 @@
"thesisTracker": "투자 테제 트래커",
"thesisTrackerDesc": "보유 종목에 대해 5문장 테제 + 레드라인 + 밸류에이션 앵커 작성",
"thesisTrackerPrompt": "thesis-tracker 스킬로 Kweichow Moutai (600519.SH)의 투자 테제를 작성하세요: 5문장 핵심 테제, 반증 가능한 가정, 레드라인, 밸류에이션 앵커를 financial_rigor로 검증하세요",
"valuationCheck": "정밀 밸류에이션 검산",
"valuationCheck": "고평가 여부 점검(PE/PB/ROE)",
"valuationCheckDesc": "정확한 십진 연산으로 PE / PB / ROE / 시가총액 검증 (부동소수점 오차 없음)",
"valuationCheckPrompt": "financial_rigor 도구로 Kweichow Moutai의 밸류에이션을 검증하세요: 가격 1500, EPS 68.6, 주당순자산 180으로 PE, PB, ROE를 정확히 계산하고, 3년 3개 시나리오(성장률 12%/8%/0%, PE 22/18/14)도 계산하세요",
"investmentCommittee": "투자 위원회 리뷰",
"investmentCommittee": "매수냐 매도냐, 위원회 토론",
"investmentCommitteeDesc": "멀티 에이전트 토론: 롱 vs 숏, 리스크 리뷰, PM 결정",
"quantStrategyDesk": "퀀트 전략 데스크",
"quantStrategyDeskDesc": "스크리닝 → 팩터 리서치 → 백테스트 → 리스크 감사 파이프라인",
@@ -228,22 +229,22 @@
"shadowDeltaPrompt": "지난 90일간의 섀도우 백테스트를 실행하고 실제 PnL이 섀도우와 갈라진 부분(규칙 위반, 조기 퇴장, 시그널 놓침)을 세분화하여 분석",
"shadowReportPrompt": "섀도우 리포트를 렌더링하고 URL을 제공 — 내 PnL vs 섀도우 델타를 선두에 표시"
},
"capabilities": {
"financeSkills": "금융 스킬 라이브러리",
"swarmTeams": "Swarm 에이전트 팀",
"autoTools": "자동 발견 도구",
"markets": "A주 · 멀티팩터 · 퀀트",
"connectors": "트레이딩 커넥터 프로필",
"timeframes": "분 단위 ~ 일 단위 타임프레임",
"optimizers": "4가지 포트폴리오 최적화기",
"riskMetrics": "15+ 리스크 지표",
"options": "옵션 & 파생상품",
"pdfWeb": "PDF & 웹 리서치",
"factorML": "팩터 분석 & ML",
"journalAnalyzer": "매매 일지 분석기",
"shadowBacktest": "섀도우 계좌 백테스트",
"memory": "영구 메모리",
"sessionSearch": "세션 검색"
"taskSubtitle": "종목, 전략, 포트폴리오에 대해 물어보세요. 데이터 수집부터 계산까지 모든 과정을 보여드립니다.",
"quickActions": "빠른 작업",
"browseAllExamples": "모든 예시 보기",
"greetings": {
"morning1": "좋은 아침입니다.",
"morning2": "좋은 아침입니다. 언제든 시작하세요.",
"morning3": "좋은 아침입니다. 시작해 볼까요.",
"afternoon1": "좋은 오후입니다.",
"afternoon2": "다음 질문을 살펴볼까요?",
"afternoon3": "오늘 오후에는 무엇을 탐구할까요?",
"evening1": "좋은 저녁입니다.",
"evening2": "시장을 함께 읽어봅시다.",
"evening3": "집중 리서치를 시작할까요?",
"night1": "아직 고민 중인가요?",
"night2": "함께 정리해 봅시다.",
"night3": "마무리 전에 아이디어 하나 더?"
}
},
"settings": {
@@ -354,7 +355,12 @@
"shellTools": "셸 도구",
"scheduler": "스케줄러",
"sessionRuntime": "세션 런타임",
"flagsReadFrom": "플래그는 {{env_path}}의 {{env1}}, {{env2}}, {{env3}}에서 읽습니다."
"flagsReadFrom": "플래그는 {{env_path}}의 {{env1}}, {{env2}}, {{env3}}에서 읽습니다.",
"unknownError": "알 수 없는 오류",
"providerChangeHint": "제공자를 변경하면 권장 모델과 엔드포인트가 업데이트됩니다.",
"oauth": "OAuth",
"apiKey": "API 키",
"tushareTokenDesc": "중국 A주·선물·펀드·거시 데이터에 사용됩니다. 설정하지 않으면 가능한 경우 AKShare로 대체됩니다."
},
"qveris": {
"title": "QVeris 도구 마켓플레이스",
@@ -437,7 +443,12 @@
"moreOptions": "추가 옵션",
"uploadPdf": "PDF 문서 업로드",
"researchGoal": "리서치 목표",
"agentSwarm": "에이전트 Swarm"
"agentSwarm": "에이전트 Swarm",
"attachmentChip": "첨부 파일",
"swarmModeChip": "스웜 모드",
"goalModeChip": "리서치 목표",
"continueGoalMessage": "리서치 목표 계속: {{goal}}",
"loadEarlier": "이전 메시지 불러오기"
},
"runDetail": {
"title": "실행 상세",
@@ -515,7 +526,9 @@
"trades": "거래 횟수",
"maxConsecLosses": "최대 연속 손실",
"exposureTime": "익스포저 시간",
"avgHoldingPeriod": "평균 보유 기간"
"avgHoldingPeriod": "평균 보유 기간",
"better": "개선",
"worse": "악화"
},
"correlation": {
"title": "상관 행렬",
@@ -742,7 +755,9 @@
"time": "시간",
"iters": "반복",
"output": "출력",
"waitingForEvents": "에이전트 이벤트 대기 중..."
"waitingForEvents": "에이전트 이벤트 대기 중...",
"teamStarting": "팀을 시작하는 중입니다. 몇 초 후 첫 진행 상황이 도착합니다…",
"detailsUnavailable": "세부 정보 없음"
},
"mandate": {
"runtimeMandate": "커넥터 런타임 mandate",
@@ -819,5 +834,43 @@
"noPriceData": "가격 데이터 없음",
"noCorrelationData": "상관 데이터 없음",
"noEquityData": "자산 데이터 없음"
},
"toolProgress": {
"step": "단계 {{step}} · {{tool}}",
"toolsRunning": "실행 중인 도구: {{count}}",
"toolsCompleted": "완료된 도구: {{count}}",
"more": "… 외 {{count}}개",
"etaSeconds": "약 {{seconds}}초 남음",
"earlier": "이전 {{count}}개"
},
"swarm": {
"assemblingTeam": "팀 구성 중…",
"preparingLayers": "실행 레이어 준비 중…",
"agentProgress": "스웜 에이전트 진행 상황",
"status": {
"waiting": "대기 중",
"running": "실행 중",
"done": "완료",
"failed": "실패",
"blocked": "차단됨",
"retry": "재시도 중",
"cancelled": "취소됨",
"pending": "시작 대기",
"completed": "완료됨",
"unknown": "알 수 없음"
},
"elapsed": {
"seconds": "{{value}}초",
"minutesSeconds": "{{minutes}}분 {{seconds}}초"
},
"presets": {
"investment_committee": "투자 위원회 리뷰",
"quant_strategy_desk": "퀀트 전략 데스크",
"value_investing_committee": "가치 투자 위원회"
}
},
"metrics": {
"good": "양호",
"poor": "미흡"
}
}
+104 -51
View File
@@ -12,7 +12,7 @@
"correlation": "相关性矩阵",
"sessions": "会话",
"newChat": "新建对话",
"noSessions": "暂无会话",
"noSessions": "还没有对话——点 + 开始第一个",
"confirm": "确认",
"cancel": "取消",
"rename": "重命名",
@@ -21,7 +21,11 @@
"dark": "暗色",
"language": "语言",
"collapse": "收起",
"expand": "展开"
"expand": "展开",
"skipToMain": "跳转到主要内容",
"sidebar": "Vibe-Trading 侧边栏",
"mainNavigation": "主导航",
"about": "关于"
},
"runtime": {
"monitorBadge": "运行时监控",
@@ -142,21 +146,18 @@
"return": "收益率",
"sharpe": "夏普",
"fullReport": "完整报告",
"compare": "对比"
"compare": "对比",
"unknown": "未知"
},
"connection": {
"reconnecting": "连接已断开,正在重连(第 {{attempt}} 次)…",
"disconnected": "连接已断开"
"reconnecting": "正在重新连接…",
"disconnected": "连接已断开,刷新页面即可重连。"
},
"welcome": {
"title": "Vibe-Trading",
"subtitle": "与你的专业金融智能体团队一起进行 vibe trading",
"describePrompt": "描述一个交易策略即可开始。",
"tryExample": "试试示例:",
"categories": {
"multiMarketBacktest": "多市场回测",
"multiMarketBacktest": "A股回测",
"researchAnalysis": "研究与分析",
"swarmTeams": "群体智能团队",
"swarmTeams": "智能体团队",
"docWebResearch": "文档与网络研究",
"tradeJournal": "交易日志",
"tradingConnectors": "交易连接器",
@@ -164,29 +165,29 @@
"valueInvesting": "价值投资"
},
"examples": {
"crossMarketPortfolio": "A股组合优化",
"crossMarketPortfolioDesc": "A股组合风险平价优化",
"crossMarketPortfolio": "三只 A 股的仓位配比",
"crossMarketPortfolioDesc": "在 3 只 A 股之间分配资金,让每只承担同等风险(风险平价),再与等权组合对比",
"btcMacd": "A股 MACD 策略",
"btcMacdDesc": "基于分钟级数据的A股MACD策略回测",
"usTechMaxDiv": "蓝筹股最大分散化",
"usTechMaxDivDesc": "蓝筹股最大分散化投资组合",
"usTechMaxDiv": "蓝筹股分散配置",
"usTechMaxDivDesc": "给 5 只蓝筹分配权重,让风险不压在单只股票上(最大分散化优化器)",
"multiFactorAlpha": "多因子 Alpha 模型",
"multiFactorAlphaDesc": "对 300 只股票进行 IC 加权因子合成",
"optionsGreeks": "期权希腊字母分析",
"optionsGreeksDesc": "Black-Scholes 定价,Delta/Gamma/Theta/Vega 分析",
"multiFactorAlphaDesc": "在 300 只股票上合成 4 类选股信号,按各自历史预测准确度(IC)加权",
"optionsGreeks": "期权风险指标(Greeks)分析",
"optionsGreeksDesc": "给期权定价,并算出它对股价、时间、波动率的敏感度(Greeks:Delta/Gamma/Theta/Vega)",
"valueCommittee": "四大师价值委员会",
"valueCommitteeDesc": "巴菲特/芒格/段永平/李录 四视角辩论一只股票",
"valueCommitteePrompt": "用 run_swarm 执行 value_investing_committee,分析腾讯(00700.HK):巴菲特看护城河和价格、芒格做逆向、段永平看生意和人、李录看十年确定性,主席综合共识和矛盾",
"bottleneckHunter": "供应链瓶颈猎手",
"bottleneckHunterDesc": "在超级趋势里找 Layer 2/3 咽喉环节的隐藏赢家",
"bottleneckHunterDesc": "在大趋势里找出上游卡脖子的小供应商,再筛掉估值已经太贵的",
"bottleneckHunterPrompt": "用 bottleneck-hunter 技能找 AI 基础设施的 Layer 2/3 供应链瓶颈机会(光模块/激光器/InP 衬底),给瓶颈评级和过估值门的候选公司",
"thesisTracker": "投资论文追踪",
"thesisTrackerDesc": "为持仓写 5 句话论文 + 红线 + 估值锚点",
"thesisTracker": "投资逻辑追踪",
"thesisTrackerDesc": "给持仓写下 5 句话投资逻辑、触发卖出的红线和估值锚点",
"thesisTrackerPrompt": "用 thesis-tracker 技能给贵州茅台(600519.SH)建立投资论文:5 句话核心论文、可验证假设、红线清单、估值锚点,用 financial_rigor 验算",
"valuationCheck": "精确估值验算",
"valuationCheckDesc": "用精确十进制验算 PE/PB/ROE/市值(无浮点误差)",
"valuationCheck": "估值核算(PE/PB/ROE)",
"valuationCheckDesc": "逐位精确复核 PE / PB / ROE / 市值,再做乐观/中性/悲观三档估值",
"valuationCheckPrompt": "用 financial_rigor 工具验算贵州茅台估值:股价 1500、EPS 68.6、每股净资产 180,精确算 PE/PB/ROE,再做三情景估值(增速 12%/8%/0%,PE 22/18/14,3 年)",
"investmentCommittee": "投资委员会评审",
"investmentCommittee": "多空辩论:该买还是该卖",
"investmentCommitteeDesc": "多智能体辩论:多头 vs 空头,风险审查,投资经理决策",
"quantStrategyDesk": "量化策略工作台",
"quantStrategyDeskDesc": "筛选 → 因子研究 → 回测 → 风险审计流水线",
@@ -209,7 +210,7 @@
"shadowDelta": "我少赚了多少?",
"shadowDeltaDesc": "回测影子策略并与实际盈亏进行归因分析",
"shadowReport": "生成影子报告",
"shadowReportDesc": "8 章节 HTML/PDF —— 净值曲线、按市场夏普比率、归因瀑布图",
"shadowReportDesc": "可分享的 8 部分报告:账户净值曲线、风险调整后收益,以及每笔盈亏的来源拆解",
"crossMarketPortfolioPrompt": "用000001.SZ、600519.SH、000858.SZ构建风险平价组合,回测2024全年,与等权基准对比",
"btcMacdPrompt": "用000001.SZ的5分钟K线做MACD策略回测,快线12慢线26信号线9,近30天",
"usTechMaxDivPrompt": "回测600519.SH、000858.SZ、601318.SH、000001.SZ、600036.SH的最大分散化投资组合,2024全年",
@@ -228,22 +229,22 @@
"shadowDeltaPrompt": "运行过去 90 天的影子回测,并细化分析我的实际盈亏与影子账户发生分歧的地方(违反规则、过早退出、错过信号)",
"shadowReportPrompt": "渲染影子报告并向我提供 URL —— 以你与影子账户的盈亏差距(Delta)为主导"
},
"capabilities": {
"financeSkills": "金融技能库",
"swarmTeams": "群体智能团队",
"autoTools": "自动发现工具",
"markets": "A股 · 多因子 · 量化",
"connectors": "交易连接器配置",
"timeframes": "分钟级到日线级时间框架",
"optimizers": "4 种投资组合优化器",
"riskMetrics": "15+ 风险指标",
"options": "期权与衍生品",
"pdfWeb": "PDF 与网络研究",
"factorML": "因子分析与机器学习",
"journalAnalyzer": "交易日志分析器",
"shadowBacktest": "影子账户回测",
"memory": "持久化记忆",
"sessionSearch": "会话搜索"
"taskSubtitle": "问一只股票、一个策略,或者你的持仓——我会自己取数据、跑计算,并把每一步摆给你看。",
"quickActions": "快捷操作",
"browseAllExamples": "浏览全部示例",
"greetings": {
"morning1": "早上好。",
"morning2": "早上好,随时可以开始。",
"morning3": "早上好。我们开始吧。",
"afternoon1": "下午好。",
"afternoon2": "准备好研究下一个问题了吗?",
"afternoon3": "今天下午想探索什么?",
"evening1": "晚上好。",
"evening2": "一起来理清市场脉络。",
"evening3": "想深入研究点什么吗?",
"night1": "夜深了,还在琢磨?",
"night2": "我们一起把它理清。",
"night3": "收尾前,再研究一个想法?"
}
},
"settings": {
@@ -354,7 +355,12 @@
"shellTools": "Shell 工具",
"scheduler": "调度器 (Scheduler)",
"sessionRuntime": "会话运行时",
"flagsReadFrom": "功能开关从 {{env_path}} 中的 {{env1}}、{{env2}} 和 {{env3}} 读取。"
"flagsReadFrom": "功能开关从 {{env_path}} 中的 {{env1}}、{{env2}} 和 {{env3}} 读取。",
"unknownError": "未知错误",
"providerChangeHint": "更改提供商会更新推荐的模型和端点。",
"oauth": "OAuth",
"apiKey": "API 密钥",
"tushareTokenDesc": "用于中国 A 股、期货、基金和宏观数据。若未设置,项目会在可用时回退到 AKShare。"
},
"qveris": {
"title": "QVeris 工具市场",
@@ -382,8 +388,8 @@
"agent": {
"connectionLostReconnect": "连接已断开,正在重连…",
"connectionRestored": "连接已恢复",
"agentWorking": "智能体正在工作中…",
"reasoning": "推理中…",
"agentWorking": "开始动手了…",
"reasoning": "正在琢磨…",
"running": "运行中",
"newMessages": "新消息",
"newResearchGoal": "新建研究目标",
@@ -404,7 +410,7 @@
"editGoalObjectiveLabel": "编辑研究目标内容",
"exportChat": "导出对话",
"stopGeneration": "停止生成",
"runSwarmTeam": "运行群体团队",
"runSwarmTeam": "启动智能体团队",
"uploadFile": "上传文件",
"checkConnector": "检查连接器",
"analyzePortfolio": "分析连接器投资组合",
@@ -416,8 +422,8 @@
"researchGoalUpdated": "研究目标已更新",
"failedToUpdateGoal": "更新目标失败。",
"failedToLoadGoal": "加载目标失败。",
"cancelRequestSent": "取消请求已发送",
"cancelFailed": "取消失败",
"cancelRequestSent": "正在停止…",
"cancelFailed": "没能停下来,请再点一次。",
"connectorHalted": "连接器运行时已停止",
"failedToHaltConnector": "停止连接器运行时失败。",
"cancelledFromUI": "已从 Web UI 取消。",
@@ -437,7 +443,12 @@
"moreOptions": "更多选项",
"uploadPdf": "上传 PDF 文档",
"researchGoal": "研究目标",
"agentSwarm": "智能体蜂群"
"agentSwarm": "智能体团队",
"attachmentChip": "附件",
"swarmModeChip": "团队模式",
"goalModeChip": "研究目标",
"continueGoalMessage": "继续研究目标:{{goal}}",
"loadEarlier": "加载更早的消息"
},
"runDetail": {
"title": "运行详情",
@@ -452,7 +463,7 @@
"downloadTradesCsv": "下载交易 CSV",
"downloadMetricsCsv": "下载指标 CSV",
"noChartData": "暂无图表数据",
"noChartDataDesc": "回测引擎可能未生成价格数据。请检查 artifacts/ 目录。",
"noChartDataDesc": "这次运行没有保存价格数据,所以没有图表;下面的指标依然有效。",
"symbol": "标的",
"showOnly": "仅显示",
"addSymbol": "添加标的",
@@ -515,7 +526,9 @@
"trades": "交易次数",
"maxConsecLosses": "最大连续亏损",
"exposureTime": "持仓时间",
"avgHoldingPeriod": "平均持仓天数"
"avgHoldingPeriod": "平均持仓天数",
"better": "表现更优",
"worse": "表现更差"
},
"correlation": {
"title": "相关性矩阵",
@@ -742,7 +755,9 @@
"time": "耗时",
"iters": "迭代",
"output": "输出",
"waitingForEvents": "等待智能体事件..."
"waitingForEvents": "等待智能体事件...",
"teamStarting": "团队正在启动,稍等几秒就有第一批进展…",
"detailsUnavailable": "暂无详细信息"
},
"mandate": {
"runtimeMandate": "连接器运行时 mandate",
@@ -819,5 +834,43 @@
"noPriceData": "暂无价格数据",
"noCorrelationData": "暂无相关性数据",
"noEquityData": "暂无收益曲线数据"
},
"toolProgress": {
"step": "步骤 {{step}} · {{tool}}",
"toolsRunning": "运行中的工具:{{count}}",
"toolsCompleted": "已完成的工具:{{count}}",
"more": "… 另有 {{count}} 个",
"etaSeconds": "预计剩余 {{seconds}} 秒",
"earlier": "+{{count}} 个较早步骤"
},
"swarm": {
"assemblingTeam": "正在组建团队…",
"preparingLayers": "正在准备执行层…",
"agentProgress": "团队进度",
"status": {
"waiting": "等待中",
"running": "运行中",
"done": "已完成",
"failed": "失败",
"blocked": "已阻塞",
"retry": "重试中",
"cancelled": "已取消",
"pending": "等待启动",
"completed": "已完成",
"unknown": "未知"
},
"elapsed": {
"seconds": "{{value}}秒",
"minutesSeconds": "{{minutes}}分 {{seconds}}秒"
},
"presets": {
"investment_committee": "投资委员会评审",
"quant_strategy_desk": "量化策略工作台",
"value_investing_committee": "价值投资委员会"
}
},
"metrics": {
"good": "良好",
"poor": "欠佳"
}
}
+19
View File
@@ -97,5 +97,24 @@
100% { transform: translateX(-100%); }
}
.chat-scroll-container[data-streaming="true"] {
overflow-anchor: none;
}
.katex-display {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
/* Message/hero entrance — one subtle rise per mount; removed under reduced motion */
@keyframes msg-enter {
from { opacity: 0; transform: translateY(10px); }
}
.msg-enter { animation: msg-enter 300ms cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
.msg-enter { animation: none; }
}
/* RTL: mirror directional icons (chevrons, arrows) automatically */
[dir="rtl"] .rtl\:flip-x { transform: scaleX(-1); }
@@ -18,6 +18,12 @@ describe("apiAuth", () => {
localStorage.setItem("vibe_trading_api_auth_key", "my-secret");
expect(getApiAuthKey()).toBe("my-secret");
});
it("returns empty string when storage access is blocked", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(getApiAuthKey()).toBe("");
});
});
describe("setApiAuthKey", () => {
@@ -35,6 +41,12 @@ describe("apiAuth", () => {
setApiAuthKey("");
expect(localStorage.getItem("vibe_trading_api_auth_key")).toBeNull();
});
it("does not throw when storage writes are blocked", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(() => setApiAuthKey("abc")).not.toThrow();
});
});
describe("authHeaders", () => {
+16 -2
View File
@@ -2,12 +2,26 @@ import { localizeToolName, TOOL_LABELS } from "../tools";
describe("TOOL_LABELS", () => {
it("maps known tool names to user-facing labels", () => {
expect(TOOL_LABELS["run_backtest"]).toBe("Run backtest");
expect(TOOL_LABELS["backtest"]).toBe("Run backtest");
expect(TOOL_LABELS["write_file"]).toBe("Generate code");
expect(TOOL_LABELS["edit_file"]).toBe("Edit code");
expect(TOOL_LABELS["bash"]).toBe("Run command");
expect(TOOL_LABELS["compact"]).toBe("Summarize conversation");
});
it("maps every demo-path tool to an English user-facing label", () => {
expect(TOOL_LABELS).toMatchObject({
get_market_data: "Get market data",
screen_market: "Screen market",
factor_analysis: "Analyze factors",
run_swarm: "Run agent team",
web_search: "Search the web",
financial_rigor: "Verify financial analysis",
render_shadow_report: "Create shadow report",
get_fundamentals: "Get fundamentals",
});
});
it("contains all trading connector tools", () => {
const tradingKeys = Object.keys(TOOL_LABELS).filter((k) => k.startsWith("trading_"));
expect(tradingKeys.length).toBeGreaterThanOrEqual(6);
@@ -16,7 +30,7 @@ describe("TOOL_LABELS", () => {
describe("localizeToolName", () => {
it("returns label for known tools", () => {
expect(localizeToolName("run_backtest")).toBe("Run backtest");
expect(localizeToolName("backtest")).toBe("Run backtest");
});
it("returns fallback for unknown tools when fallback provided", () => {
+5 -3
View File
@@ -1,15 +1,17 @@
import { safeGet, safeRemove, safeSet } from "@/lib/storage";
const STORAGE_KEY = "vibe_trading_api_auth_key";
export function getApiAuthKey(): string {
return window.localStorage.getItem(STORAGE_KEY) || "";
return safeGet(STORAGE_KEY) || "";
}
export function setApiAuthKey(value: string): void {
const trimmed = value.trim();
if (trimmed) {
window.localStorage.setItem(STORAGE_KEY, trimmed);
safeSet(STORAGE_KEY, trimmed);
} else {
window.localStorage.removeItem(STORAGE_KEY);
safeRemove(STORAGE_KEY);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Safe localStorage access. Storage APIs THROW (SecurityError/QuotaExceededError)
// in restricted contexts — blocked third-party iframes, WebViews with DOM storage
// off, zero-quota private modes. Only the index.html boot script guards itself;
// every in-app access must go through these wrappers so a locked-down browser
// degrades to in-memory defaults instead of white-screening the app.
export function safeGet(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function safeSet(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* storage unavailable — preference simply won't persist */
}
}
export function safeRemove(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
+45
View File
@@ -0,0 +1,45 @@
export type ManifestTranslation = (
key: string,
options?: Record<string, string | number>,
) => string;
export const STATUS_TRANSLATION_KEYS = {
waiting: "swarm.status.waiting",
running: "swarm.status.running",
done: "swarm.status.done",
failed: "swarm.status.failed",
blocked: "swarm.status.blocked",
retry: "swarm.status.retry",
cancelled: "swarm.status.cancelled",
pending: "swarm.status.pending",
completed: "swarm.status.completed",
unknown: "swarm.status.unknown",
} as const;
const PRESET_WELCOME_KEYS: Record<string, string> = {
investment_committee: "welcome.examples.investmentCommittee",
quant_strategy_desk: "welcome.examples.quantStrategyDesk",
value_investing_committee: "welcome.examples.valueCommittee",
};
export function localizeStatus(
status: keyof typeof STATUS_TRANSLATION_KEYS,
t: ManifestTranslation,
): string {
return t(STATUS_TRANSLATION_KEYS[status]);
}
export function localizePreset(
preset: string,
t: ManifestTranslation,
): string {
const humanized = preset
.replace(/_/g, " ")
.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase());
const welcomeKey = PRESET_WELCOME_KEYS[preset];
const fallback = welcomeKey
? t(welcomeKey, { defaultValue: humanized })
: humanized;
return t(`swarm.presets.${preset}`, { defaultValue: fallback });
}
+27
View File
@@ -0,0 +1,27 @@
import { useSyncExternalStore } from "react";
// Single source of truth for the applied theme. The `.dark` class on <html> is
// set before first paint by the inline script in index.html and toggled at
// runtime by useDarkMode; every consumer that must react to theme changes
// (echarts instances, code highlighting, canvas surfaces) subscribes here
// instead of instantiating its own useDarkMode state, which would never
// receive toggles made elsewhere.
const listeners = new Set<() => void>();
export function isDarkTheme(): boolean {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
}
export function publishThemeChange(): void {
listeners.forEach((l) => l());
}
export function subscribeTheme(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function useThemeDark(): boolean {
return useSyncExternalStore(subscribeTheme, isDarkTheme);
}
+9 -1
View File
@@ -6,10 +6,18 @@ export const TOOL_LABELS: Record<string, string> = {
write_file: "Generate code",
edit_file: "Edit code",
read_file: "Read file",
run_backtest: "Run backtest",
backtest: "Run backtest",
bash: "Run command",
read_url: "Read webpage",
read_document: "Read document",
get_market_data: "Get market data",
screen_market: "Screen market",
factor_analysis: "Analyze factors",
run_swarm: "Run agent team",
web_search: "Search the web",
financial_rigor: "Verify financial analysis",
render_shadow_report: "Create shadow report",
get_fundamentals: "Get fundamentals",
trading_connections: "List trading connectors",
trading_select_connection: "Select trading connector",
trading_check: "Check trading connector",
-10
View File
@@ -5,16 +5,6 @@ import { RouterProvider } from "react-router";
import { Toaster } from "sonner";
import { ErrorBoundary } from "./components/common/ErrorBoundary";
import { router } from "./router";
// Self-hosted fonts (VT-006): vendor the woff2 files locally instead of the
// Google Fonts CDN. Weights match tailwind.config.ts (Inter 400/500/600/700,
// JetBrains Mono 400/500/700).
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/700.css";
import "highlight.js/styles/github-dark-dimmed.min.css";
import "./index.css";
File diff suppressed because it is too large. Load diff
+79 -76
View File
@@ -40,7 +40,7 @@ import {
} from "@/lib/api";
import { echarts } from "@/lib/echarts";
import { getChartTheme } from "@/lib/chart-theme";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useThemeDark } from "@/lib/theme-store";
/* ---------- Constants ---------- */
@@ -49,7 +49,6 @@ interface ZooCard {
title: string;
description: string;
approxCount: number;
accent: string;
}
// IMPORTANT: The Kakushadze 101 zoo must use the author's name as the label.
@@ -61,7 +60,6 @@ const ZOO_CARDS: ZooCard[] = [
description:
"Microsoft Qlib's full 158-feature library covering momentum, volatility, volume and rolling statistical signals.",
approxCount: 154,
accent: "from-sky-500/20 to-sky-500/5",
},
{
id: "alpha101",
@@ -69,7 +67,6 @@ const ZOO_CARDS: ZooCard[] = [
description:
"The 101 formulaic alphas from Kakushadze (2015); short-horizon cross-sectional signals.",
approxCount: 101,
accent: "from-emerald-500/20 to-emerald-500/5",
},
{
id: "gtja191",
@@ -77,7 +74,6 @@ const ZOO_CARDS: ZooCard[] = [
description:
"Guotai Junan Securities' 191 alphas; technical and microstructure signals tuned to China A-share markets.",
approxCount: 191,
accent: "from-amber-500/20 to-amber-500/5",
},
{
id: "academic",
@@ -85,7 +81,6 @@ const ZOO_CARDS: ZooCard[] = [
description:
"Curated long-horizon anomalies from the academic literature (value, momentum, quality, low-vol, etc.).",
approxCount: 10,
accent: "from-violet-500/20 to-violet-500/5",
},
{
id: "fundamental",
@@ -93,7 +88,6 @@ const ZOO_CARDS: ZooCard[] = [
description:
"Quality and value factors computed from PIT-safe SEC company facts — earnings yield, ROE, gross profitability, asset growth (filed-date anchored).",
approxCount: 4,
accent: "from-rose-500/20 to-rose-500/5",
},
];
@@ -222,7 +216,7 @@ function BrowseView() {
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide">
<Layers className="h-3.5 w-3.5" aria-hidden="true" /> {i18n.t("alphaZoo.title")}
</div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
<h1 className="text-2xl font-semibold tracking-tight">
{loading
? i18n.t("alphaZoo.prebuiltAlphaLoading")
: i18n.t("alphaZoo.prebuiltAlpha", { count: total })}
@@ -242,10 +236,9 @@ function BrowseView() {
type="button"
onClick={() => setZooFilter(active ? "" : z.id)}
className={cn(
"text-left border rounded-xl p-4 space-y-2 transition bg-gradient-to-br",
z.accent,
"hover:border-primary/50",
active && "border-primary ring-1 ring-primary/30",
"text-left rounded-xl border border-border/60 bg-card p-4 space-y-2 shadow-sm transition",
"bg-none hover:bg-muted/60",
active && "border-primary/40 bg-primary/10 text-primary",
)}
>
<div className="flex items-center justify-between">
@@ -264,7 +257,7 @@ function BrowseView() {
</div>
{/* Filter bar */}
<div className="flex flex-col md:flex-row md:items-end gap-3 border rounded-xl p-4 bg-card">
<div className="flex flex-col md:flex-row md:items-end gap-3 rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex-1 min-w-0">
<label htmlFor="alpha-search" className="text-xs text-muted-foreground block mb-1">
{i18n.t("alphaZoo.search")}
@@ -282,7 +275,7 @@ function BrowseView() {
setVisibleCount(PAGE_SIZE);
}}
placeholder={i18n.t("alphaZoo.searchPlaceholder")}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
className="w-full pl-9 pr-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
@@ -292,7 +285,7 @@ function BrowseView() {
id="alpha-zoo-filter"
value={zooFilter}
onChange={(e) => setZooFilter(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">{i18n.t("alphaZoo.allZoos")}</option>
{ZOO_CARDS.map((z) => (
@@ -310,7 +303,7 @@ function BrowseView() {
id="alpha-theme-filter"
value={themeFilter}
onChange={(e) => setThemeFilter(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">{i18n.t("alphaZoo.allThemes")}</option>
{themeOptions.map((tname) => (
@@ -328,7 +321,7 @@ function BrowseView() {
id="alpha-universe-filter"
value={universeFilter}
onChange={(e) => setUniverseFilter(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">{i18n.t("alphaZoo.allUniverses")}</option>
{UNIVERSE_OPTIONS.map((u) => (
@@ -340,7 +333,7 @@ function BrowseView() {
</div>
<Link
to={compareHref}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium hover:bg-muted hover:text-foreground transition"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-border/60 text-sm font-medium hover:bg-muted/60 hover:text-foreground transition"
title={i18n.t("alphaZoo.compareTooltip")}
>
<ArrowLeftRight className="h-3.5 w-3.5" aria-hidden="true" /> {i18n.t("alphaZoo.compare")}
@@ -356,28 +349,28 @@ function BrowseView() {
{/* Table */}
{/* TODO(v0.2): switch to react-window if alpha count exceeds 5000 */}
<div className="border rounded-xl overflow-hidden">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label={i18n.t("alphaZoo.alphaCatalogue")}>
<caption className="sr-only">{i18n.t("alphaZoo.alphaCatalogue")}</caption>
<thead>
<tr className="border-b bg-muted/40">
<tr className="border-b bg-muted/40 text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="w-10 px-3 py-2.5">
<span className="sr-only">{i18n.t("alphaZoo.selectForCompare")}</span>
</th>
<th className="text-left px-4 py-2.5 text-muted-foreground">
<th className="text-left px-4 py-2.5">
{i18n.t("alphaZoo.id")}
</th>
<th className="text-left px-4 py-2.5 text-muted-foreground">
<th className="text-left px-4 py-2.5">
{i18n.t("alphaZoo.zoo")}
</th>
<th className="text-left px-4 py-2.5 text-muted-foreground">
<th className="text-left px-4 py-2.5">
{i18n.t("alphaZoo.theme")}
</th>
<th className="text-left px-4 py-2.5 text-muted-foreground hidden md:table-cell">
<th className="text-left px-4 py-2.5 hidden md:table-cell">
{i18n.t("alphaZoo.universe")}
</th>
<th className="text-right px-4 py-2.5 text-muted-foreground" title={i18n.t("alphaZoo.predictiveHalfLifeTitle")}>
<th className="text-right px-4 py-2.5" title={i18n.t("alphaZoo.predictiveHalfLifeTitle")}>
{i18n.t("alphaZoo.decayDays")}
</th>
</tr>
@@ -401,8 +394,8 @@ function BrowseView() {
<tr
key={`${a.zoo}:${a.id}`}
className={cn(
"border-b last:border-0 hover:bg-muted/20",
selected.has(a.id) && "bg-primary/5",
"border-b last:border-0 hover:bg-muted/40",
selected.has(a.id) && "bg-primary/10",
)}
>
<td className="px-3 py-2">
@@ -451,7 +444,7 @@ function BrowseView() {
<button
type="button"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
className="px-3 py-1 rounded-md border hover:bg-muted hover:text-foreground transition"
className="px-3 py-1 rounded-md border border-border/60 hover:bg-muted/60 hover:text-foreground transition"
>
{i18n.t("alphaZoo.loadMore")}
</button>
@@ -510,7 +503,7 @@ function DetailView({ alphaId }: DetailProps) {
<Link to="/alpha-zoo" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" /> {i18n.t("alphaZoo.backToAlphaZoo")}
</Link>
<div className="border rounded-xl p-6 bg-card">
<div className="rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<h2 className="font-semibold text-sm mb-1 flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning" aria-hidden="true" /> {i18n.t("alphaZoo.couldNotLoad")}
</h2>
@@ -553,7 +546,7 @@ function DetailView({ alphaId }: DetailProps) {
{/* Title */}
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-mono text-xl md:text-2xl font-bold tracking-tight">
<h1 className="font-mono text-2xl font-semibold tracking-tight">
{a.id}
</h1>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
@@ -567,16 +560,16 @@ function DetailView({ alphaId }: DetailProps) {
{/* Formula */}
<section className="space-y-2">
<h2 className="text-sm font-medium text-muted-foreground">{i18n.t("alphaZoo.formula")}</h2>
<pre className="border rounded-xl bg-muted/30 p-4 overflow-x-auto text-xs leading-relaxed">
<h2 className="text-sm font-semibold text-muted-foreground">{i18n.t("alphaZoo.formula")}</h2>
<pre className="overflow-x-auto rounded-xl border border-border/60 bg-muted/40 p-4 text-xs leading-relaxed shadow-sm">
<code>{formulaLatex || i18n.t("alphaZoo.noFormula")}</code>
</pre>
</section>
{/* Metadata */}
<section className="space-y-2">
<h2 className="text-sm font-medium text-muted-foreground">{i18n.t("alphaZoo.metadata")}</h2>
<div className="border rounded-xl overflow-hidden">
<h2 className="text-sm font-semibold text-muted-foreground">{i18n.t("alphaZoo.metadata")}</h2>
<div className="overflow-x-auto rounded-xl border border-border/60 bg-card shadow-sm">
<table className="w-full text-sm">
<tbody>
<MetaRow
@@ -612,12 +605,12 @@ function DetailView({ alphaId }: DetailProps) {
{/* Source code */}
<section className="space-y-2">
<h2 className="text-sm font-medium text-muted-foreground">{i18n.t("alphaZoo.sourceCode")}</h2>
<details className="border rounded-xl bg-card group">
<summary className="cursor-pointer px-4 py-3 text-sm font-medium hover:bg-muted/40 select-none">
<h2 className="text-sm font-semibold text-muted-foreground">{i18n.t("alphaZoo.sourceCode")}</h2>
<details className="group rounded-xl border border-border/60 bg-card shadow-sm">
<summary className="cursor-pointer px-4 py-3 text-sm font-semibold hover:bg-muted/40 select-none">
{i18n.t("alphaZoo.viewSource", { lines: (detail.source_code || "").split("\n").length })}
</summary>
<pre className="border-t bg-muted/30 p-4 overflow-x-auto text-xs leading-relaxed">
<pre className="border-t border-border/60 bg-muted/40 p-4 overflow-x-auto text-xs leading-relaxed">
<code>{detail.source_code || i18n.t("alphaZoo.noSource")}</code>
</pre>
</details>
@@ -628,7 +621,7 @@ function DetailView({ alphaId }: DetailProps) {
function MetaRow({ label, value, last }: { label: string; value: string; last?: boolean }) {
return (
<tr className={cn(!last && "border-b", "hover:bg-muted/20")}>
<tr className={cn(!last && "border-b", "hover:bg-muted/40")}>
<td className="px-4 py-2 text-xs text-muted-foreground w-1/3">{label}</td>
<td className="px-4 py-2 text-xs font-mono break-all">{value}</td>
</tr>
@@ -787,7 +780,7 @@ function BenchView() {
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide">
<Play className="h-3.5 w-3.5" aria-hidden="true" /> {i18n.t("alphaZoo.benchRunner")}
</div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
<h1 className="text-2xl font-semibold tracking-tight">
{i18n.t("alphaZoo.scoreZoo")}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
@@ -798,7 +791,7 @@ function BenchView() {
{/* Form */}
<form
onSubmit={startBench}
className="border rounded-xl p-4 bg-card grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 items-end"
className="grid grid-cols-1 items-end gap-3 rounded-xl border border-border/60 bg-card p-4 shadow-sm sm:grid-cols-2 lg:grid-cols-5"
>
<div>
<label htmlFor="bench-zoo" className="text-xs text-muted-foreground block mb-1">{i18n.t("alphaZoo.zoo")}</label>
@@ -807,7 +800,7 @@ function BenchView() {
value={zoo}
onChange={(e) => setZoo(e.target.value)}
disabled={busy}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
>
{ZOO_CARDS.map((z) => (
<option key={z.id} value={z.id}>
@@ -823,7 +816,7 @@ function BenchView() {
value={universe}
onChange={(e) => setUniverse(e.target.value)}
disabled={busy}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
>
{UNIVERSE_OPTIONS.map((u) => (
<option key={u.value} value={u.value}>
@@ -840,7 +833,7 @@ function BenchView() {
onChange={(e) => setPeriod(e.target.value)}
disabled={busy}
placeholder="2020-2025"
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
/>
</div>
<div>
@@ -857,7 +850,7 @@ function BenchView() {
setTop(e.target.value === "" ? 20 : Number(e.target.value))
}
disabled={busy}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-1">
@@ -909,7 +902,7 @@ function ProgressPanel({
? Math.min(100, Math.round((progress.n_done / progress.n_total) * 100))
: 0;
return (
<div className="border rounded-xl p-4 bg-card space-y-3">
<div className="space-y-3 rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
@@ -937,7 +930,7 @@ function ProgressPanel({
}
function ResultPanel({ result }: { result: AlphaBenchResult }) {
const { dark } = useDarkMode();
const dark = useThemeDark();
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -977,17 +970,25 @@ function ResultPanel({ result }: { result: AlphaBenchResult }) {
],
});
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(chartRef.current);
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [result, dark]);
const totals = [
{ label: i18n.t("alphaZoo.alive"), value: result.alive, icon: CheckCircle2, tone: "text-green-600 dark:text-green-400" },
{ label: i18n.t("alphaZoo.reversed"), value: result.reversed, icon: AlertTriangle, tone: "text-amber-600 dark:text-amber-400" },
{ label: i18n.t("alphaZoo.reversed"), value: result.reversed, icon: AlertTriangle, tone: "text-warning" },
{ label: i18n.t("alphaZoo.dead"), value: result.dead, icon: XCircle, tone: "text-red-600 dark:text-red-400" },
{ label: i18n.t("alphaZoo.skipped"), value: result.skipped ?? 0, icon: Loader2, tone: "text-muted-foreground" },
];
@@ -997,11 +998,11 @@ function ResultPanel({ result }: { result: AlphaBenchResult }) {
{/* Stat cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{totals.map(({ label, value, icon: Icon, tone }) => (
<div key={label} className="border rounded-xl p-4 bg-card flex items-center gap-3">
<div key={label} className="flex items-center gap-3 rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<Icon className={cn("h-5 w-5 shrink-0", tone)} aria-hidden="true" />
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xl font-bold tabular-nums">{value}</p>
<p className="font-mono text-xl font-semibold tabular-nums">{value}</p>
</div>
</div>
))}
@@ -1015,8 +1016,8 @@ function ResultPanel({ result }: { result: AlphaBenchResult }) {
{/* By-theme breakdown */}
{result.by_theme && Object.keys(result.by_theme).length > 0 && (
<div className="border rounded-xl p-4 bg-card">
<h3 className="text-sm font-medium text-muted-foreground mb-2">
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
{i18n.t("alphaZoo.byTheme")}
</h3>
<div ref={chartRef} style={{ height: 240 }} />
@@ -1028,28 +1029,29 @@ function ResultPanel({ result }: { result: AlphaBenchResult }) {
function TopTable({ title, rows }: { title: string; rows: AlphaBenchTopRow[] }) {
return (
<div className="border rounded-xl overflow-hidden bg-card">
<div className="px-4 py-2.5 border-b bg-muted/40">
<h3 className="text-sm font-medium">{title}</h3>
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
<div className="px-4 py-2.5 border-b border-border/60 bg-muted/40">
<h3 className="text-sm font-semibold text-muted-foreground">{title}</h3>
</div>
{rows.length === 0 ? (
<div className="px-4 py-6 text-xs text-muted-foreground text-center">
{i18n.t("alphaZoo.noRows")}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left px-4 py-2 text-xs text-muted-foreground font-medium">{i18n.t("alphaZoo.id")}</th>
<th className="text-right px-4 py-2 text-xs text-muted-foreground font-medium">{i18n.t("alphaZoo.meanIc")}</th>
<th className="text-right px-4 py-2 text-xs text-muted-foreground font-medium">{i18n.t("alphaZoo.ir")}</th>
<th className="text-left px-4 py-2 text-xs text-muted-foreground font-medium">{i18n.t("alphaZoo.theme")}</th>
<th className="text-left px-4 py-2 text-xs text-muted-foreground font-medium">{i18n.t("alphaZoo.category")}</th>
<tr className="border-b bg-muted/40 text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="text-left px-4 py-2 font-medium">{i18n.t("alphaZoo.id")}</th>
<th className="text-right px-4 py-2 font-medium">{i18n.t("alphaZoo.meanIc")}</th>
<th className="text-right px-4 py-2 font-medium">{i18n.t("alphaZoo.ir")}</th>
<th className="text-left px-4 py-2 font-medium">{i18n.t("alphaZoo.theme")}</th>
<th className="text-left px-4 py-2 font-medium">{i18n.t("alphaZoo.category")}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/20">
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="px-4 py-2">
<Link
to={`/alpha-zoo/${encodeURIComponent(r.id)}`}
@@ -1070,6 +1072,7 @@ function TopTable({ title, rows }: { title: string; rows: AlphaBenchTopRow[] })
))}
</tbody>
</table>
</div>
)}
</div>
);
@@ -1085,7 +1088,7 @@ function CategoryBadge({ category }: { category: AlphaBenchTopRow["category"] })
category === "alive"
? "bg-green-500/10 text-green-700 dark:text-green-300"
: category === "reversed"
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
? "bg-warning/10 text-warning"
: "bg-red-500/10 text-red-700 dark:text-red-300";
const label =
category === "alive"
@@ -1253,7 +1256,7 @@ function CompareView() {
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide">
<ArrowLeftRight className="h-3.5 w-3.5" aria-hidden="true" /> {i18n.t("alphaZoo.headToHeadCompare")}
</div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
<h1 className="text-2xl font-semibold tracking-tight">
{i18n.t("alphaZoo.compareAlphas")}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
@@ -1261,7 +1264,7 @@ function CompareView() {
</p>
</div>
<form onSubmit={startCompare} className="border rounded-xl p-4 bg-card space-y-3">
<form onSubmit={startCompare} className="space-y-3 rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div>
<label htmlFor="compare-ids" className="text-xs text-muted-foreground block mb-1">
{i18n.t("alphaZoo.alphaIds")}{ids.length > 0 ? ` (${ids.length} ${i18n.t("alphaZoo.selected")})` : ""}
@@ -1273,7 +1276,7 @@ function CompareView() {
disabled={busy}
rows={2}
placeholder="alpha101_1, alpha101_2, gtja191_5"
className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
/>
<p className="text-[11px] text-muted-foreground mt-1">
{i18n.t("alphaZoo.alphaIdsHint")}
@@ -1288,7 +1291,7 @@ function CompareView() {
value={universe}
onChange={(e) => setUniverse(e.target.value)}
disabled={busy}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
>
{UNIVERSE_OPTIONS.map((u) => (
<option key={u.value} value={u.value}>
@@ -1305,7 +1308,7 @@ function CompareView() {
onChange={(e) => setPeriod(e.target.value)}
disabled={busy}
placeholder="2020-2025"
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
/>
</div>
<div>
@@ -1315,7 +1318,7 @@ function CompareView() {
value={sort}
onChange={(e) => setSort(e.target.value)}
disabled={busy}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50"
>
{SORT_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
@@ -1368,7 +1371,7 @@ function CompareResultPanel({ result }: { result: AlphaCompareResult }) {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className="inline-flex items-center gap-1.5 font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1.5 font-medium text-success">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {i18n.t("alphaZoo.winner")}:{" "}
<span className="font-mono">{result.winner}</span>
</span>
@@ -1381,17 +1384,17 @@ function CompareResultPanel({ result }: { result: AlphaCompareResult }) {
})}
</span>
{result.n_skipped > 0 && (
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400">
<span className="inline-flex items-center gap-1 text-warning">
<AlertTriangle className="h-3.5 w-3.5" aria-hidden="true" /> {i18n.t("alphaZoo.skippedCount", { count: result.n_skipped })}
</span>
)}
</div>
<div className="border rounded-xl overflow-hidden">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label={i18n.t("alphaZoo.alphaComparisonRanking")}>
<thead>
<tr className="border-b bg-muted/40 text-muted-foreground text-xs">
<tr className="border-b bg-muted/40 text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="text-right px-3 py-2">#</th>
<th className="text-left px-3 py-2">{i18n.t("alphaZoo.alpha")}</th>
<th className="text-right px-3 py-2 hidden sm:table-cell">{i18n.t("alphaZoo.zoo")}</th>
@@ -1408,8 +1411,8 @@ function CompareResultPanel({ result }: { result: AlphaCompareResult }) {
<tr
key={`${r.zoo}:${r.id}`}
className={cn(
"border-b last:border-0 hover:bg-muted/20",
r.rank === 1 && "bg-emerald-500/5",
"border-b last:border-0 hover:bg-muted/40",
r.rank === 1 && "bg-success/5",
)}
>
<td className="px-3 py-2 text-right font-mono tabular-nums">{r.rank}</td>
+66 -25
View File
@@ -1,12 +1,13 @@
import i18n from '@/i18n';
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { GitCompare, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { api, type RunListItem, type RunData, type EquityPoint } from "@/lib/api";
import { echarts, CHART_GROUP, connectCharts } from "@/lib/echarts";
import { getChartTheme } from "@/lib/chart-theme";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useThemeDark } from "@/lib/theme-store";
import { SkeletonChart, SkeletonMetrics } from "@/components/common/Skeleton";
interface MetricDef {
@@ -25,12 +26,26 @@ function fmt(v: unknown, type: "pct" | "num" | "int" | "days" = "num"): string {
return n.toFixed(3);
}
function diffClass(a: unknown, b: unknown, higherIsBetter: boolean): string {
type DiffPresentation = {
className: string;
verdict: "better" | "worse" | null;
glyph: "\u2191" | "\u2193" | null;
};
function diffClass(a: unknown, b: unknown, higherIsBetter: boolean): DiffPresentation {
const na = Number(a), nb = Number(b);
if (!Number.isFinite(na) || !Number.isFinite(nb)) return "";
if (!Number.isFinite(na) || !Number.isFinite(nb)) {
return { className: "", verdict: null, glyph: null };
}
const better = higherIsBetter ? nb > na : nb < na;
const worse = higherIsBetter ? nb < na : nb > na;
return better ? "text-green-600 dark:text-green-400" : worse ? "text-red-600 dark:text-red-400" : "";
if (better) {
return { className: "text-green-600 dark:text-green-400", verdict: "better", glyph: "\u2191" };
}
if (worse) {
return { className: "text-red-600 dark:text-red-400", verdict: "worse", glyph: "\u2193" };
}
return { className: "", verdict: null, glyph: null };
}
function diffStr(a: unknown, b: unknown, type: "pct" | "num" | "int" | "days"): string {
@@ -101,7 +116,7 @@ interface EquityChartOverlayProps {
function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: EquityChartOverlayProps) {
const ref = useRef<HTMLDivElement>(null);
const { dark } = useDarkMode();
const dark = useThemeDark();
useEffect(() => {
if (!ref.current) return;
@@ -188,9 +203,20 @@ function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: Eq
],
});
const ro = new ResizeObserver(() => chart.resize());
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(ref.current!);
return () => { ro.disconnect(); chart.dispose(); };
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [leftCurve, rightCurve, leftLabel, rightLabel, dark]);
if (leftCurve.length === 0 && rightCurve.length === 0) return null;
@@ -199,6 +225,7 @@ function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: Eq
}
export function Compare() {
const { t } = useTranslation();
const [runs, setRuns] = useState<RunListItem[]>([]);
const [leftId, setLeftId] = useState("");
const [rightId, setRightId] = useState("");
@@ -290,15 +317,15 @@ export function Compare() {
return (
<div className="p-8 max-w-4xl space-y-6">
<h1 className="text-xl font-bold flex items-center gap-2">
<GitCompare className="h-5 w-5" /> Strategy Comparison
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<GitCompare className="h-5 w-5" aria-hidden="true" /> {t("compare.title")}
</h1>
{/* Selectors */}
<div className="flex gap-4 items-end">
<div className="flex gap-4 items-end rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex-1">
<label className="text-xs text-muted-foreground block mb-1">{i18n.t("compare.baseline")}</label>
<select value={leftId} onChange={(e) => setLeftId(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={leftRun?.prompt || leftId}>
<select value={leftId} onChange={(e) => setLeftId(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={leftRun?.prompt || leftId}>
<option value="">{i18n.t("compare.select")}</option>
{runs.map((r) => <option key={r.run_id} value={r.run_id}>{runLabel(r)} ({r.status})</option>)}
</select>
@@ -306,7 +333,7 @@ export function Compare() {
<ArrowRight className="h-5 w-5 text-muted-foreground mb-2 shrink-0" />
<div className="flex-1">
<label className="text-xs text-muted-foreground block mb-1">{i18n.t("compare.compare")}</label>
<select value={rightId} onChange={(e) => setRightId(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={rightRun?.prompt || rightId}>
<select value={rightId} onChange={(e) => setRightId(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={rightRun?.prompt || rightId}>
<option value="">{i18n.t("compare.select")}</option>
{runs.map((r) => <option key={r.run_id} value={r.run_id}>{runLabel(r)} ({r.status})</option>)}
</select>
@@ -316,11 +343,11 @@ export function Compare() {
{/* Loading state — show skeletons while a selected run's data is in flight */}
{loading && !hasData && (
<div className="space-y-6">
<div className="border rounded-xl p-4">
<h2 className="text-sm font-medium text-muted-foreground mb-2">{i18n.t("compare.equityDrawdown")}</h2>
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<h2 className="text-sm font-semibold text-muted-foreground mb-2">{i18n.t("compare.equityDrawdown")}</h2>
<SkeletonChart height={320} />
</div>
<div className="border rounded-xl overflow-hidden">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
<SkeletonMetrics />
</div>
</div>
@@ -328,8 +355,8 @@ export function Compare() {
{/* Equity curve overlay */}
{(leftCurve.length > 0 || rightCurve.length > 0) && (
<div className="border rounded-xl p-4">
<h2 className="text-sm font-medium text-muted-foreground mb-2">{i18n.t("compare.equityDrawdown")}</h2>
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<h2 className="text-sm font-semibold text-muted-foreground mb-2">{i18n.t("compare.equityDrawdown")}</h2>
<EquityChartOverlay
leftCurve={leftCurve}
rightCurve={rightCurve}
@@ -341,26 +368,40 @@ export function Compare() {
{/* Metrics table */}
{(leftData || rightData) && (
<div className="border rounded-xl overflow-hidden">
<div className="overflow-x-auto rounded-xl border border-border/60 bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left px-4 py-2.5 text-muted-foreground font-medium">{i18n.t("compare.metric")}</th>
<th className="text-right px-4 py-2.5 text-muted-foreground font-medium">{i18n.t("compare.baselineCol")}</th>
<th className="text-right px-4 py-2.5 text-muted-foreground font-medium">{i18n.t("compare.compareCol")}</th>
<th className="text-right px-4 py-2.5 text-muted-foreground font-medium">{i18n.t("compare.delta")}</th>
<tr className="border-b bg-muted/40 text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="text-left px-4 py-2.5 font-medium">{i18n.t("compare.metric")}</th>
<th className="text-right px-4 py-2.5 font-medium">{i18n.t("compare.baselineCol")}</th>
<th className="text-right px-4 py-2.5 font-medium">{i18n.t("compare.compareCol")}</th>
<th className="text-right px-4 py-2.5 font-medium">{i18n.t("compare.delta")}</th>
</tr>
</thead>
<tbody>
{METRICS.map(({ key, label, type, higherIsBetter }) => {
const lv = resolveMetric(leftData, key);
const rv = resolveMetric(rightData, key);
const diff = diffClass(lv, rv, higherIsBetter);
return (
<tr key={key} className="border-b last:border-0 hover:bg-muted/20">
<tr key={key} className="border-b last:border-0 hover:bg-muted/40">
<td className="px-4 py-2.5 font-medium">{label}</td>
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{fmt(lv, type)}</td>
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{fmt(rv, type)}</td>
<td className={cn("px-4 py-2.5 text-right font-mono tabular-nums font-semibold", diffClass(lv, rv, higherIsBetter))}>{diffStr(lv, rv, type)}</td>
<td className={cn("px-4 py-2.5 text-right font-mono tabular-nums font-semibold", diff.className)}>
{diff.verdict && (
<>
<span aria-hidden="true">{diff.glyph} </span>
<span className="sr-only">
{t(`compare.${diff.verdict}`, {
defaultValue: diff.verdict === "better" ? "Better" : "Worse",
})}
{": "}
</span>
</>
)}
{diffStr(lv, rv, type)}
</td>
</tr>
);
})}
+15 -10
View File
@@ -91,7 +91,7 @@ export function Reports() {
{t("reports.badge")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("reports.title")}</h1>
<h1 className="text-2xl font-semibold tracking-tight">{t("reports.title")}</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{t("reports.subtitle")}</p>
</div>
</div>
@@ -108,18 +108,20 @@ export function Reports() {
<section className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_160px_150px_150px_170px]">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("reports.searchPlaceholder")}
className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm outline-none transition focus:border-primary"
aria-label={t("alphaZoo.search")}
className="w-full rounded-md border bg-background py-2 ps-9 pe-3 text-sm outline-none transition focus:border-primary"
/>
</label>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm"
aria-label={t("settings.status")}
>
{statusOptions.map((status) => (
<option key={status} value={status}>
@@ -167,8 +169,8 @@ export function Reports() {
) : null}
{!loading && error ? (
<section className="rounded-md border border-amber-500/30 bg-amber-500/5 p-5">
<div className="flex items-center gap-2 font-medium text-amber-700 dark:text-amber-300">
<section className="rounded-md border border-warning/30 bg-warning/5 p-5">
<div className="flex items-center gap-2 font-medium text-warning">
<AlertTriangle className="h-5 w-5" />
{t("reports.unavailable")}
</div>
@@ -209,7 +211,9 @@ function ReportRow({ run }: { run: RunListItem }) {
<Link to={`/runs/${run.run_id}`} className="truncate font-mono text-sm font-medium hover:text-primary">
{run.run_id}
</Link>
<span className="text-xs text-muted-foreground">{formatRunDate(run.created_at)}</span>
<span className="text-xs text-muted-foreground">
{formatRunDate(run.created_at, t("reports.unknown", { defaultValue: "Unknown" }))}
</span>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">{run.prompt || t("reports.noPrompt")}</p>
<div className="flex flex-wrap gap-1.5">
@@ -227,7 +231,7 @@ function ReportRow({ run }: { run: RunListItem }) {
</div>
<div className="flex flex-col gap-3 lg:items-end">
<div className="grid grid-cols-2 gap-2 text-right sm:flex sm:flex-wrap sm:justify-end">
<div className="grid grid-cols-2 gap-2 text-end sm:flex sm:flex-wrap sm:justify-end">
<MetricPill label={t("reports.return")} value={formatOptionalMetric("total_return", run.total_return)} />
<MetricPill label={t("reports.sharpe")} value={formatOptionalMetric("sharpe", run.sharpe)} />
</div>
@@ -253,6 +257,7 @@ function ReportRow({ run }: { run: RunListItem }) {
}
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const normalized = status.toLowerCase();
const ok = ["success", "done", "completed", "complete"].includes(normalized);
return (
@@ -263,7 +268,7 @@ function StatusBadge({ status }: { status: string }) {
)}
>
{ok ? <CheckCircle2 className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
{status || "unknown"}
{status || t("reports.unknown", { defaultValue: "Unknown" })}
</span>
);
}
@@ -301,9 +306,9 @@ function dateMs(value: string): number {
return Number.isFinite(parsed) ? parsed : 0;
}
function formatRunDate(value: string): string {
function formatRunDate(value: string, unknownLabel: string): string {
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) return value || "unknown";
if (!Number.isFinite(parsed.getTime())) return value || unknownLabel;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
+62 -42
View File
@@ -1,6 +1,7 @@
import i18n from '@/i18n';
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useParams, useNavigate } from "react-router";
import {
AlertTriangle,
@@ -93,6 +94,7 @@ function yieldToBrowser(): Promise<void> {
export function RunDetail() {
const { runId } = useParams<{ runId: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [run, setRun] = useState<RunData | null>(null);
const [code, setCode] = useState<Record<string, string>>({});
const [tab, setTab] = useState<Tab>("chart");
@@ -284,41 +286,55 @@ export function RunDetail() {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b p-4 space-y-3">
<div className="border-b border-border/60 p-4 space-y-3">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
className="p-1 rounded-md hover:bg-muted/60 transition-colors text-muted-foreground hover:text-foreground"
title={i18n.t("runDetail.goBack")}
>
<ArrowLeft className="h-4 w-4" />
</button>
{ok ? <CheckCircle2 className="h-5 w-5 text-success" /> : <XCircle className="h-5 w-5 text-danger" />}
<h1 className="font-mono text-sm font-medium">{runId}</h1>
{ok ? (
<>
<CheckCircle2 className="h-5 w-5 text-success" aria-hidden="true" />
<span className="sr-only">{t("swarm.status.completed")}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-danger" aria-hidden="true" />
<span className="sr-only">{t("swarm.status.failed")}</span>
</>
)}
<h1 className="font-mono text-2xl font-semibold">{runId}</h1>
{run.elapsed_seconds && <span className="text-xs text-muted-foreground">{run.elapsed_seconds.toFixed(1)}s</span>}
</div>
{run.prompt && <p className="text-sm text-muted-foreground">{run.prompt}</p>}
{run.metrics && <MetricsCard metrics={run.metrics as Record<string, number>} />}
<div className="flex items-center gap-1">
{TABS.filter(t => !t.hidden).map(({ id, label, icon: Icon }) => (
<div className="flex flex-wrap items-center gap-1">
<div role="tablist" className="flex flex-wrap items-center gap-1">
{TABS.filter(tabItem => !tabItem.hidden).map(({ id, label, icon: Icon }) => (
<button
key={id}
role="tab"
aria-selected={tab === id}
onClick={() => setTab(id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
tab === id ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
tab === id ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:bg-muted/60"
)}
>
<Icon className="h-3.5 w-3.5" /> {label}
<Icon className="h-3.5 w-3.5" aria-hidden="true" /> {label}
</button>
))}
</div>
<div className="ml-auto flex gap-1">
<div className="ml-auto flex flex-wrap gap-1">
{run.trade_log && run.trade_log.length > 0 && (
<button
onClick={() => downloadCsv(`trades_${runId}.csv`, buildTradesCsv(run.trade_log!))}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted/60 transition-colors"
title={i18n.t("runDetail.downloadTradesCsv")}
>
<Download className="h-3.5 w-3.5" /> {i18n.t("runDetail.downloadTradesCsv")}
@@ -327,7 +343,7 @@ export function RunDetail() {
{run.metrics && (
<button
onClick={() => downloadCsv(`metrics_${runId}.csv`, buildMetricsCsv(run.metrics!))}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted/60 transition-colors"
title={i18n.t("runDetail.downloadMetricsCsv")}
>
<Download className="h-3.5 w-3.5" /> {i18n.t("runDetail.downloadMetricsCsv")}
@@ -384,8 +400,8 @@ function RunCardTab({ card }: { card: RunCard }) {
</div>
{warnings.length > 0 && (
<section className="rounded-md border border-amber-500/25 bg-amber-500/5 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-300">
<section className="rounded-xl border border-warning/25 bg-warning/5 p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-warning">
<AlertTriangle className="h-4 w-4" />
{i18n.t("runDetail.warnings")}
</div>
@@ -424,17 +440,17 @@ function RunCardTab({ card }: { card: RunCard }) {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{i18n.t("runDetail.path")}</th>
<tr className="border-b bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="py-2 ps-4 pr-4">{i18n.t("runDetail.path")}</th>
<th className="py-2 pr-4">{i18n.t("runDetail.size")}</th>
<th className="py-2">{i18n.t("runDetail.sha256")}</th>
</tr>
</thead>
<tbody>
{artifacts.map((artifact) => (
<tr key={`${artifact.path}-${artifact.sha256}`} className="border-b last:border-0">
<td className="py-2 pr-4 font-mono text-xs">{artifact.path}</td>
<td className="py-2 pr-4 tabular-nums text-muted-foreground">{formatBytes(artifact.size_bytes)}</td>
<tr key={`${artifact.path}-${artifact.sha256}`} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-2 ps-4 pr-4 font-mono text-xs">{artifact.path}</td>
<td className="py-2 pr-4 font-mono tabular-nums text-muted-foreground">{formatBytes(artifact.size_bytes)}</td>
<td className="py-2 font-mono text-xs text-muted-foreground">{shortHash(artifact.sha256)}</td>
</tr>
))}
@@ -451,17 +467,17 @@ function RunCardTab({ card }: { card: RunCard }) {
function RunCardStat({ label, value, tone = "normal" }: { label: string; value: string; tone?: "normal" | "warning" }) {
return (
<div className="rounded-md border bg-card p-3">
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={cn("mt-1 truncate text-sm font-medium", tone === "warning" ? "text-amber-700 dark:text-amber-300" : "")}>{value}</div>
<div className={cn("mt-1 truncate text-sm font-medium", tone === "warning" ? "text-warning" : "")}>{value}</div>
</div>
);
}
function RunCardPanel({ title, icon: Icon, children }: { title: string; icon: typeof FileCheck2; children: ReactNode }) {
return (
<section className="rounded-md border bg-card p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<section className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Icon className="h-4 w-4 text-muted-foreground" />
{title}
</div>
@@ -476,16 +492,18 @@ function KeyValueTable({ data, empty, monospaceValues = false }: { data: Record<
return <p className="text-sm text-muted-foreground">{empty}</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full table-fixed text-sm">
<tbody>
{entries.map(([key, value]) => (
<tr key={key} className="border-b last:border-0">
<td className="w-36 py-2 pr-4 align-top text-muted-foreground">{key}</td>
<tr key={key} className="border-b last:border-0 hover:bg-muted/40">
<td className="w-36 py-2 ps-4 pr-4 align-top text-muted-foreground">{key}</td>
<td className={cn("py-2 align-top", monospaceValues ? "break-all font-mono text-xs" : "break-words text-right tabular-nums")}>{formatRunCardValue(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -555,7 +573,7 @@ function ChartTab({
return (
<div className="p-4 space-y-4">
{chartSymbols.length > 0 && (
<div className="rounded-md border bg-card p-3">
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="chart-symbol-select">
{i18n.t("runDetail.symbol")}
@@ -564,7 +582,7 @@ function ChartTab({
id="chart-symbol-select"
value={chartPickerSymbol}
onChange={(event) => onPickSymbol(event.target.value)}
className="h-8 rounded-md border bg-background px-2 text-sm"
className="h-8 rounded-md border border-border/60 bg-background px-2 text-sm"
>
{chartSymbols.map((symbol) => (
<option key={symbol} value={symbol}>{symbol}</option>
@@ -572,7 +590,7 @@ function ChartTab({
</select>
<button
onClick={() => onCurrentOnly(chartPickerSymbol)}
className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
className="rounded-md border border-border/60 px-3 py-1.5 text-xs font-medium hover:bg-muted/60"
disabled={!chartPickerSymbol || !!loadingSymbols[chartPickerSymbol]}
>
{loadingSymbols[chartPickerSymbol] ? <Loader2 className="mr-1 inline h-3.5 w-3.5 animate-spin" /> : null}
@@ -580,14 +598,14 @@ function ChartTab({
</button>
<button
onClick={() => onAddSymbol(chartPickerSymbol)}
className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
className="rounded-md border border-border/60 px-3 py-1.5 text-xs font-medium hover:bg-muted/60"
disabled={!chartPickerSymbol || !!loadingSymbols[chartPickerSymbol]}
>
{i18n.t("runDetail.addSymbol")}
</button>
<button
onClick={() => void onLoadAll()}
className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
className="rounded-md border border-border/60 px-3 py-1.5 text-xs font-medium hover:bg-muted/60"
disabled={bulkLoading}
>
{bulkLoading ? <Loader2 className="mr-1 inline h-3.5 w-3.5 animate-spin" /> : null}
@@ -596,7 +614,7 @@ function ChartTab({
{bulkLoading && (
<button
onClick={onCancelLoadAll}
className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
className="rounded-md border border-border/60 px-3 py-1.5 text-xs font-medium hover:bg-muted/60"
>
{i18n.t("runDetail.cancelLoad")}
</button>
@@ -608,7 +626,7 @@ function ChartTab({
<button
key={symbol}
onClick={() => onRemoveSymbol(symbol)}
className="rounded-md bg-muted px-2 py-1 text-xs hover:bg-muted/80"
className="rounded-md bg-muted/40 px-2 py-1 text-xs hover:bg-muted/60"
>
{symbol} x
</button>
@@ -629,19 +647,19 @@ function ChartTab({
</div>
)}
{entries.length === 0 && (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
<div className="rounded-xl border border-dashed border-border/60 bg-card p-5 text-center text-sm text-muted-foreground shadow-sm">
{Object.keys(loadingSymbols).length > 0 ? i18n.t("runDetail.loadingSelectedChart") : i18n.t("runDetail.pickSymbolToLoad")}
</div>
)}
{entries.map(([sym, bars]) => (
<div key={sym}>
<h3 className="text-sm font-medium mb-1">{sym}</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">{sym}</h3>
<CandlestickChart data={bars} markers={chartCache[sym]?.trade_markers?.filter(m => m.code === sym)} indicators={chartCache[sym]?.indicator_series?.[sym]} height={500} />
</div>
))}
{hasEquity && (
<div>
<h3 className="text-sm font-medium mb-1">{i18n.t("runDetail.equityDrawdown")}</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">{i18n.t("runDetail.equityDrawdown")}</h3>
<EquityChart data={run.equity_curve!} height={280} />
</div>
)}
@@ -654,10 +672,11 @@ function TradesTab({ run }: { run: RunData }) {
if (trades.length === 0) return <div className="p-8 text-muted-foreground text-sm">{i18n.t("runDetail.noTrades")}</div>;
return (
<div className="p-4">
<div className="overflow-x-auto rounded-xl border border-border/60 bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{i18n.t("runDetail.time")}</th>
<tr className="border-b bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="py-2 ps-4 pr-4">{i18n.t("runDetail.time")}</th>
<th className="py-2 pr-4">{i18n.t("runDetail.code2")}</th>
<th className="py-2 pr-4">{i18n.t("runDetail.side")}</th>
<th className="py-2 pr-4">{i18n.t("runDetail.price")}</th>
@@ -667,18 +686,19 @@ function TradesTab({ run }: { run: RunData }) {
</thead>
<tbody>
{trades.map((tr, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/20">
<td className="py-2 pr-4 font-mono text-xs">{tr.time || tr.timestamp}</td>
<tr key={i} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-2 ps-4 pr-4 font-mono text-xs">{tr.time || tr.timestamp}</td>
<td className="py-2 pr-4">{tr.code}</td>
<td className={cn("py-2 pr-4 font-medium", tr.side === "BUY" ? "text-success" : "text-danger")}>{tr.side}</td>
<td className="py-2 pr-4 tabular-nums">{tr.price}</td>
<td className="py-2 pr-4 tabular-nums">{tr.qty}</td>
<td className="py-2 pr-4 font-mono tabular-nums">{tr.price}</td>
<td className="py-2 pr-4 font-mono tabular-nums">{tr.qty}</td>
<td className="py-2 text-muted-foreground">{tr.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -688,9 +708,9 @@ function CodeTab({ code }: { code: Record<string, string> }) {
if (files.length === 0) return <div className="p-8 text-muted-foreground text-sm">{i18n.t("runDetail.noCodeFiles")}</div>;
return (
<div className="flex flex-col h-full">
<div className="flex gap-1 p-2 border-b">
<div className="flex gap-1 p-2 border-b border-border/60">
{files.map(([name]) => (
<button key={name} onClick={() => setActive(name)} className={cn("px-3 py-1 rounded text-xs font-mono", active === name ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>{name}</button>
<button key={name} onClick={() => setActive(name)} className={cn("px-3 py-1 rounded text-xs font-mono", active === name ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted/60")}>{name}</button>
))}
</div>
<div className="flex-1 overflow-auto p-3 text-[11px] leading-relaxed bg-muted/20 [&_pre]:m-0 [&_pre]:bg-transparent [&_code]:text-[11px]">
+20 -20
View File
@@ -85,14 +85,14 @@ export function Runtime() {
return (
<div className="min-h-screen p-6 lg:p-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<section className="flex flex-col gap-4 border-b pb-6 lg:flex-row lg:items-end lg:justify-between">
<section className="flex flex-col gap-4 border-b border-border/60 pb-6 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium text-muted-foreground">
<div className="inline-flex items-center gap-2 rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium text-muted-foreground">
<Activity className="h-3.5 w-3.5" />
{t("runtime.monitorBadge")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("runtime.title")}</h1>
<h1 className="text-2xl font-semibold tracking-tight">{t("runtime.title")}</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
{t("runtime.subtitlePre")} <span className="font-mono">/live/status</span>
{t("runtime.subtitlePost")}
@@ -103,7 +103,7 @@ export function Runtime() {
type="button"
onClick={() => loadStatus("refresh")}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition hover:bg-muted disabled:opacity-50"
className="inline-flex items-center gap-2 rounded-md border border-border/60 px-4 py-2 text-sm font-medium transition hover:bg-muted/60 disabled:opacity-50"
>
{refreshing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t("runtime.refresh")}
@@ -113,14 +113,14 @@ export function Runtime() {
{loading ? (
<div className="grid gap-3 md:grid-cols-4">
{[1, 2, 3, 4].map((item) => (
<div key={item} className="h-24 animate-pulse rounded-md border bg-muted/40" />
<div key={item} className="h-24 animate-pulse rounded-xl border border-border/60 bg-card shadow-sm" />
))}
</div>
) : null}
{!loading && error ? (
<section className="rounded-md border border-amber-500/30 bg-amber-500/5 p-5">
<div className="flex items-center gap-2 font-medium text-amber-700 dark:text-amber-300">
<section className="rounded-xl border border-warning/30 bg-warning/5 p-5 shadow-sm">
<div className="flex items-center gap-2 font-medium text-warning">
<AlertTriangle className="h-5 w-5" />
{t("runtime.unavailableTitle")}
</div>
@@ -154,9 +154,9 @@ export function Runtime() {
</section>
{status.brokers.length === 0 ? (
<section className="rounded-md border border-dashed p-8 text-center">
<section className="rounded-xl border border-dashed border-border/60 bg-card p-5 text-center shadow-sm">
<ShieldOff className="mx-auto h-8 w-8 text-muted-foreground" />
<h2 className="mt-3 font-medium">{t("runtime.noProfilesTitle")}</h2>
<h2 className="mt-3 text-sm font-semibold">{t("runtime.noProfilesTitle")}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t("runtime.noProfilesBody")}</p>
</section>
) : (
@@ -197,9 +197,9 @@ function isCurrentStatusRequest(
function SummaryTile({ label, value, tone, icon: Icon }: SummaryTileProps) {
return (
<div className="rounded-md border p-4">
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium uppercase text-muted-foreground">{label}</span>
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
<Icon
className={cn(
"h-4 w-4",
@@ -247,11 +247,11 @@ function BrokerRuntimeCard({
}
return (
<article className="rounded-md border p-4">
<article className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold capitalize">{brokerKey}</h2>
<h2 className="text-sm font-semibold capitalize">{brokerKey}</h2>
<StatusPill
label={broker.auth.oauth_token_present ? t("runtime.authPresent") : t("runtime.authMissing")}
tone={broker.auth.oauth_token_present ? "success" : "neutral"}
@@ -318,11 +318,11 @@ function SdkBrokerRuntimeCard({ broker, t, onRefresh }: { broker: LiveBrokerStat
}, [onRefresh, profileId, t, verifying]);
return (
<article className="rounded-md border p-4">
<article className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold capitalize">{auth.broker}</h2>
<h2 className="text-sm font-semibold capitalize">{auth.broker}</h2>
<StatusPill label={state.label} tone={state.tone} />
</div>
{isReadOnlyCompatible(auth) ? (
@@ -334,7 +334,7 @@ function SdkBrokerRuntimeCard({ broker, t, onRefresh }: { broker: LiveBrokerStat
type="button"
onClick={verify}
disabled={verifying}
className="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition hover:bg-muted disabled:opacity-50"
className="inline-flex items-center gap-2 rounded-md border border-border/60 px-4 py-2 text-sm font-medium transition hover:bg-muted/60 disabled:opacity-50"
>
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t(state.action)}
@@ -343,7 +343,7 @@ function SdkBrokerRuntimeCard({ broker, t, onRefresh }: { broker: LiveBrokerStat
</div>
{state.kind === "not_configured" ? (
<section className="mt-4 rounded-md border border-dashed p-3">
<section className="mt-4 rounded-xl border border-dashed border-border/60 bg-muted/40 p-4 shadow-sm">
<p className="text-sm text-muted-foreground">{t("runtime.missingLongbridgeVariables")}</p>
<ul className="mt-2 grid gap-1 font-mono text-sm">
<li>LONGBRIDGE_APP_KEY</li>
@@ -449,8 +449,8 @@ function formatCapabilities(auth: LiveBrokerStatus["auth"], t: TFunction): strin
function RuntimePanel({ title, icon: Icon, children }: { title: string; icon: typeof Activity; children: ReactNode }) {
return (
<section className="rounded-md border bg-muted/20 p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-medium uppercase text-muted-foreground">
<section className="rounded-xl border border-border/60 bg-muted/40 p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{title}
</div>
@@ -475,7 +475,7 @@ function StatusPill({ label, tone }: { label: string; tone: "success" | "danger"
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium",
tone === "success" && "bg-success/10 text-success",
tone === "danger" && "bg-danger/10 text-danger",
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
tone === "warning" && "bg-warning/10 text-warning",
tone === "neutral" && "bg-muted text-muted-foreground",
)}
>
+98 -75
View File
@@ -1,4 +1,3 @@
import i18n from "@/i18n";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Database, KeyRound, Loader2, MessageSquareMore, Play, RefreshCw, RotateCcw, Save, Server, SlidersHorizontal, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -67,31 +66,37 @@ export function Settings() {
setSettings(llmResult.value);
setForm(toForm(llmResult.value));
} else {
const message = llmResult.reason instanceof Error ? llmResult.reason.message : "Unknown error";
const message = llmResult.reason instanceof Error
? llmResult.reason.message
: t("settings.unknownError", { defaultValue: "Unknown error" });
setSettingsLoadError(message);
if (isAuthRequiredError(llmResult.reason)) {
toast.error(message);
} else {
toast.error(`Failed to load LLM settings: ${message}`);
toast.error(t("settings.loadLlmSettingsFailed", { message }));
}
}
if (dataSourceResult.status === "fulfilled") {
setDataSettings(dataSourceResult.value);
} else {
const message = dataSourceResult.reason instanceof Error ? dataSourceResult.reason.message : "Unknown error";
const message = dataSourceResult.reason instanceof Error
? dataSourceResult.reason.message
: t("settings.unknownError", { defaultValue: "Unknown error" });
setSettingsLoadError(message);
if (isAuthRequiredError(dataSourceResult.reason)) {
toast.error(message);
} else {
toast.error(`Failed to load data source settings: ${message}`);
toast.error(t("settings.loadDataSourceSettingsFailed", { message }));
}
}
if (channelResult.status === "fulfilled") {
setChannelStatus(channelResult.value);
} else {
const message = channelResult.reason instanceof Error ? channelResult.reason.message : "Unknown error";
const message = channelResult.reason instanceof Error
? channelResult.reason.message
: t("settings.unknownError", { defaultValue: "Unknown error" });
toast.error(`${t("settings.channels.refreshFailed")}: ${message}`);
setChannelStatus(null);
}
@@ -110,7 +115,7 @@ export function Settings() {
try {
setChannelStatus(await api.getChannelStatus());
} catch (error) {
toast.error(`${t("settings.channels.refreshFailed")}: ${error instanceof Error ? error.message : "Unknown error"}`);
toast.error(`${t("settings.channels.refreshFailed")}: ${error instanceof Error ? error.message : t("settings.unknownError", { defaultValue: "Unknown error" })}`);
} finally {
setChannelRefreshing(false);
}
@@ -123,7 +128,7 @@ export function Settings() {
setChannelStatus(updated);
toast.success(action === "start" ? t("settings.channels.started") : t("settings.channels.stoppedToast"));
} catch (error) {
toast.error(`${action === "start" ? t("settings.channels.startFailed") : t("settings.channels.stopFailed")}: ${error instanceof Error ? error.message : "Unknown error"}`);
toast.error(`${action === "start" ? t("settings.channels.startFailed") : t("settings.channels.stopFailed")}: ${error instanceof Error ? error.message : t("settings.unknownError", { defaultValue: "Unknown error" })}`);
} finally {
setChannelAction(null);
}
@@ -160,7 +165,7 @@ export function Settings() {
const submitLocalApiKey = (event: FormEvent) => {
event.preventDefault();
setApiAuthKey(localApiKey);
toast.success("Local API key saved");
toast.success(t("settings.localApiKeySaved"));
window.location.reload();
};
@@ -178,9 +183,13 @@ export function Settings() {
setForm(toForm(updated));
setApiKey("");
setClearApiKey(false);
toast.success("LLM settings saved");
toast.success(t("settings.llmSettingsSaved"));
} catch (error) {
toast.error(`Failed to save LLM settings: ${error instanceof Error ? error.message : "Unknown error"}`);
toast.error(t("settings.saveLlmSettingsFailed", {
message: error instanceof Error
? error.message
: t("settings.unknownError", { defaultValue: "Unknown error" }),
}));
} finally {
setSaving(false);
}
@@ -197,9 +206,13 @@ export function Settings() {
setDataSettings(updated);
setTushareToken("");
setClearTushareToken(false);
toast.success("Data source settings saved");
toast.success(t("settings.dataSourceSettingsSaved"));
} catch (error) {
toast.error(`Failed to save data source settings: ${error instanceof Error ? error.message : "Unknown error"}`);
toast.error(t("settings.saveDataSourceSettingsFailed", {
message: error instanceof Error
? error.message
: t("settings.unknownError", { defaultValue: "Unknown error" }),
}));
} finally {
setDataSaving(false);
}
@@ -210,19 +223,19 @@ export function Settings() {
<div className="mb-4 space-y-1">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-primary" />
<h2 className="text-base font-semibold">{"Local API access"}</h2>
<h2 className="text-base font-semibold">{t("settings.localApiAccess")}</h2>
</div>
<p className="text-sm text-muted-foreground">{"For remote or private Web UI deployments, enter the server API key once in this browser. Localhost use can stay blank."}</p>
<p className="text-sm text-muted-foreground">{t("settings.localApiAccessDesc")}</p>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<label className="grid gap-2">
<span className={labelClass}>{"Server API key"}</span>
<span className={labelClass}>{t("settings.serverApiKey")}</span>
<input
type="password"
value={localApiKey}
onChange={(event) => setLocalApiKeyState(event.target.value)}
className={fieldClass}
placeholder={"Stored only in this browser. Leave blank to clear it."}
placeholder={t("settings.storedInBrowser")}
autoComplete="current-password"
/>
</label>
@@ -231,10 +244,10 @@ export function Settings() {
className="inline-flex items-center justify-center gap-2 self-end rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90"
>
<Save className="h-4 w-4" />
{i18n.t("settings.save")}
{t("settings.save")}
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">{"Stored only in this browser. Leave blank to clear it."}</p>
<p className="mt-2 text-xs text-muted-foreground">{t("settings.storedInBrowser")}</p>
</form>
);
@@ -242,8 +255,8 @@ export function Settings() {
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{"Settings"}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{"Configure model credentials and market data source tokens for this local project."}</p>
<h1 className="text-2xl font-semibold tracking-tight">{t("settings.title")}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t("settings.subtitle")}</p>
</div>
{localApiAccessSection}
{/* QVERIS-INTEGRATION */}
@@ -251,13 +264,13 @@ export function Settings() {
<div className="flex min-h-32 items-center justify-center rounded-lg border bg-card p-5 text-sm text-muted-foreground">
{settingsLoadError ? (
<div className="text-center">
<div className="font-medium text-foreground">{"Settings are unavailable"}</div>
<div className="font-medium text-foreground">{t("settings.unavailable")}</div>
<div className="mt-1">{settingsLoadError}</div>
</div>
) : (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{"Loading..."}
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t("settings.loading")}
</>
)}
</div>
@@ -266,16 +279,16 @@ export function Settings() {
}
const keyStatus = settings.api_key_configured
? "Configured"
? t("settings.configured")
: settings.api_key_required
? "Leave blank to keep the current key"
? t("settings.keepCurrentKey")
: selectedProvider?.auth_type === "oauth" && selectedProvider.login_command
? `This provider uses OAuth. Run: ${selectedProvider.login_command}`
: "This provider does not require an API key.";
? t("settings.providerUsesOauth", { command: selectedProvider.login_command })
: t("settings.noApiKeyRequired");
const apiKeyDisabled = !selectedProvider?.api_key_required || clearApiKey;
const tushareStatus = dataSettings.tushare_token_configured
? "Configured"
: "Leave blank to keep the current token";
? t("settings.configured")
: t("settings.keepCurrentToken");
const channelRows = channelStatus
? Object.entries(channelStatus.channels ?? {}).sort(([a], [b]) => a.localeCompare(b))
: [];
@@ -350,9 +363,9 @@ export function Settings() {
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">{t("settings.channels.channel")}</th>
<th className="px-3 py-2 text-left font-medium">{t("settings.channels.state")}</th>
<th className="px-3 py-2 text-left font-medium">{t("settings.channels.recovery")}</th>
<th className="px-3 py-2 text-start font-medium">{t("settings.channels.channel")}</th>
<th className="px-3 py-2 text-start font-medium">{t("settings.channels.state")}</th>
<th className="px-3 py-2 text-start font-medium">{t("settings.channels.recovery")}</th>
</tr>
</thead>
<tbody>
@@ -395,8 +408,8 @@ export function Settings() {
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{"Settings"}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{"Configure model credentials and market data source tokens for this local project."}</p>
<h1 className="text-2xl font-semibold tracking-tight">{t("settings.title")}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t("settings.subtitle")}</p>
</div>
{localApiAccessSection}
@@ -407,20 +420,20 @@ export function Settings() {
{channelsSection}
<div className="space-y-2">
<h2 className="text-lg font-semibold tracking-tight">{"LLM Settings"}</h2>
<p className="max-w-3xl text-sm text-muted-foreground">{"Choose the model used by the agent and save it to the project-local agent/.env file."}</p>
<h2 className="text-lg font-semibold tracking-tight">{t("settings.llmSettings")}</h2>
<p className="max-w-3xl text-sm text-muted-foreground">{t("settings.llmSettingsDesc")}</p>
</div>
<form onSubmit={submit} className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.8fr)]">
<section className="rounded-lg border bg-card p-5 shadow-sm">
<div className="mb-5 flex items-center gap-2">
<Server className="h-4 w-4 text-primary" />
<h2 className="text-base font-semibold">{"Connection"}</h2>
<h2 className="text-base font-semibold">{t("settings.connection")}</h2>
</div>
<div className="grid gap-4">
<label className="grid gap-2">
<span className={labelClass}>{i18n.t("settings.provider")}</span>
<span className={labelClass}>{t("settings.provider")}</span>
<select
value={form.provider}
onChange={(event) => onProviderChange(event.target.value)}
@@ -430,11 +443,15 @@ export function Settings() {
<option key={provider.name} value={provider.name}>{provider.label}</option>
))}
</select>
<span className={hintClass}>{"Changing providers updates the recommended model and endpoint."}</span>
<span className={hintClass}>
{t("settings.providerChangeHint", {
defaultValue: "Changing providers updates the recommended model and endpoint.",
})}
</span>
</label>
<label className="grid gap-2">
<span className={labelClass}>{"Model"}</span>
<span className={labelClass}>{t("settings.model")}</span>
<div className="flex gap-2">
<input
value={form.model_name}
@@ -446,17 +463,17 @@ export function Settings() {
type="button"
onClick={() => applyProviderDefaults()}
className="inline-flex shrink-0 items-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground"
title={"Use provider defaults"}
title={t("settings.useProviderDefaults")}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{"Use provider defaults"}</span>
<span className="hidden sm:inline">{t("settings.useProviderDefaults")}</span>
</button>
</div>
<span className={hintClass}>{"Use the exact model id required by your provider."}</span>
<span className={hintClass}>{t("settings.modelIdHint")}</span>
</label>
<label className="grid gap-2">
<span className={labelClass}>{i18n.t("settings.baseUrl")}</span>
<span className={labelClass}>{t("settings.baseUrl")}</span>
<input
value={form.base_url}
onChange={(event) => setForm({ ...form, base_url: event.target.value })}
@@ -476,15 +493,17 @@ export function Settings() {
<label className="grid gap-2">
<span className={labelClass}>
{selectedProvider?.auth_type === "oauth" ? "OAuth" : "API key"}
{selectedProvider?.auth_type === "oauth"
? t("settings.oauth", { defaultValue: "OAuth" })
: t("settings.apiKey", { defaultValue: "API key" })}
</span>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<KeyRound className="pointer-events-none absolute start-3 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
className={`${fieldClass} pl-9`}
className={`${fieldClass} ps-9`}
placeholder={keyStatus}
autoComplete="current-password"
disabled={apiKeyDisabled}
@@ -503,7 +522,7 @@ export function Settings() {
}}
className="h-3.5 w-3.5 accent-primary"
/>
{"Clear saved API key"}
{t("settings.clearApiKey")}
</label>
) : null}
</div>
@@ -514,12 +533,12 @@ export function Settings() {
<section className="rounded-lg border bg-card p-5 shadow-sm">
<div className="mb-5 flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-primary" />
<h2 className="text-base font-semibold">{"Generation"}</h2>
<h2 className="text-base font-semibold">{t("settings.generation")}</h2>
</div>
<div className="grid gap-4">
<label className="grid gap-2">
<span className={labelClass}>{i18n.t("settings.temperature")}</span>
<span className={labelClass}>{t("settings.temperature")}</span>
<input
type="number"
min={0}
@@ -532,7 +551,7 @@ export function Settings() {
</label>
<label className="grid gap-2">
<span className={labelClass}>{i18n.t("settings.timeoutSeconds")}</span>
<span className={labelClass}>{t("settings.timeoutSeconds")}</span>
<input
type="number"
min={1}
@@ -545,7 +564,7 @@ export function Settings() {
</label>
<label className="grid gap-2">
<span className={labelClass}>{"Max retries"}</span>
<span className={labelClass}>{t("settings.maxRetries")}</span>
<input
type="number"
min={0}
@@ -558,23 +577,23 @@ export function Settings() {
</label>
<label className="grid gap-2">
<span className={labelClass}>{i18n.t("settings.reasoningEffort")}</span>
<span className={labelClass}>{t("settings.reasoningEffort")}</span>
<select
value={form.reasoning_effort}
onChange={(event) => setForm({ ...form, reasoning_effort: event.target.value })}
className={fieldClass}
>
<option value="">{"Off"}</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="max">max</option>
<option value="">{t("settings.off")}</option>
<option value="low">{t("settings.reasoningEffortLow")}</option>
<option value="medium">{t("settings.reasoningEffortMedium")}</option>
<option value="high">{t("settings.reasoningEffortHigh")}</option>
<option value="max">{t("settings.reasoningEffortMax")}</option>
</select>
<span className={hintClass}>{"How hard the model thinks before answering. Higher is more thorough but slower; leave Off for fastest replies."}</span>
<span className={hintClass}>{t("settings.reasoningEffortDesc")}</span>
</label>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{i18n.t("settings.saved")}: </span>
<span className="font-medium text-foreground">{t("settings.saved")}: </span>
<span className="break-all font-mono">{settings.env_path}</span>
</div>
@@ -584,7 +603,7 @@ export function Settings() {
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? i18n.t("settings.saving") : i18n.t("settings.save")}
{saving ? t("settings.saving") : t("settings.save")}
</button>
</div>
</section>
@@ -594,29 +613,33 @@ export function Settings() {
<div className="mb-5 space-y-1">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<h2 className="text-base font-semibold">{"Data Source Settings"}</h2>
<h2 className="text-base font-semibold">{t("settings.dataSourceSettings")}</h2>
</div>
<p className="text-sm text-muted-foreground">{"Configure optional market data credentials used by backtests and research agents."}</p>
<p className="text-sm text-muted-foreground">{t("settings.dataSourceSettingsDesc")}</p>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(280px,0.9fr)]">
<div className="grid gap-4">
<label className="grid gap-2">
<span className={labelClass}>{"Tushare token"}</span>
<span className={labelClass}>{t("settings.tushareToken")}</span>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<KeyRound className="pointer-events-none absolute start-3 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="password"
value={tushareToken}
onChange={(event) => setTushareToken(event.target.value)}
className={`${fieldClass} pl-9`}
className={`${fieldClass} ps-9`}
placeholder={tushareStatus}
autoComplete="current-password"
disabled={clearTushareToken}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className={hintClass}>{"Used for China A-share, futures, fund, and macro data. If unset, the project falls back to AKShare where available."}</span>
<span className={hintClass}>
{t("settings.tushareTokenDesc", {
defaultValue: "Used for China A-share, futures, fund, and macro data. If unset, the project falls back to AKShare where available.",
})}
</span>
<label className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
@@ -627,13 +650,13 @@ export function Settings() {
}}
className="h-3.5 w-3.5 accent-primary"
/>
{"Clear saved Tushare token"}
{t("settings.clearTushareToken")}
</label>
</div>
</label>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{i18n.t("settings.saved")}: </span>
<span className="font-medium text-foreground">{t("settings.saved")}: </span>
<span className="break-all font-mono">{dataSettings.env_path}</span>
</div>
@@ -643,23 +666,23 @@ export function Settings() {
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-70"
>
{dataSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{dataSaving ? i18n.t("settings.saving") : "Save data source settings"}
{dataSaving ? t("settings.saving") : t("settings.saveDataSourceSettings")}
</button>
</div>
<div className="rounded-md border bg-muted/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium">{"BaoStock"}</span>
<span className="text-sm font-medium">{t("settings.baostock")}</span>
<span className={`rounded-full px-2 py-0.5 text-xs ${dataSettings.baostock_supported ? "bg-success/10 text-success" : "bg-warning/10 text-warning"}`}>
{dataSettings.baostock_supported ? "Loader available" : "No project loader"}
{dataSettings.baostock_supported ? t("settings.loaderAvailable") : t("settings.noProjectLoader")}
</span>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<p>{dataSettings.baostock_message}</p>
<p>
{dataSettings.baostock_installed
? "Python package installed"
: "Python package not installed"}
? t("settings.pythonPackageInstalled")
: t("settings.pythonPackageNotInstalled")}
</p>
</div>
</div>
@@ -93,4 +93,23 @@ describe("Compare page", () => {
});
expect(await screen.findByText("20.00%")).toBeInTheDocument();
});
it("localizes the title and gives metric deltas non-color verdicts", async () => {
apiMock.getRun.mockImplementation((runId: string) => Promise.resolve({
status: "success",
run_id: runId,
metrics: runId === "right"
? { total_return: 0.2, volatility: 0.2 }
: { total_return: 0.1, volatility: 0.1 },
}));
render(<Compare />);
expect(screen.getByRole("heading", { level: 1, name: "Strategy Comparison" })).toHaveClass("text-2xl", "font-semibold");
expect(await screen.findByText("Better:")).toHaveClass("sr-only");
expect(screen.getByText("Worse:")).toHaveClass("sr-only");
expect(screen.getByText("\u2191")).toHaveAttribute("aria-hidden", "true");
expect(screen.getByText("\u2193")).toHaveAttribute("aria-hidden", "true");
expect(screen.getByRole("table").parentElement).toHaveClass("overflow-x-auto");
});
});
@@ -55,6 +55,8 @@ describe("Reports page", () => {
const fullReportLinks = screen.getAllByRole("link", { name: "Full Report" });
expect(fullReportLinks[0]).toHaveAttribute("href", "/runs/new-report");
expect(fullReportLinks[1]).toHaveAttribute("href", "/runs/old-report");
expect(screen.getByRole("textbox", { name: "Search" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
});
it("filters reports by search text", async () => {
@@ -80,7 +82,7 @@ describe("Reports page", () => {
render(<Reports />, { wrapper: MemoryRouter });
await screen.findByText("aapl-report");
fireEvent.change(screen.getByPlaceholderText("Search run id, prompt, symbol, status..."), {
fireEvent.change(screen.getByRole("textbox", { name: "Search" }), {
target: { value: "MSFT" },
});
@@ -66,7 +66,7 @@ describe("RunDetail page", () => {
expect(screen.getByText("New run")).toBeInTheDocument();
expect(screen.queryByText("Old run")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Code" }));
fireEvent.click(screen.getByRole("tab", { name: "Code" }));
expect(await screen.findByText("NEW_CODE")).toBeInTheDocument();
expect(screen.queryByText("OLD_CODE")).not.toBeInTheDocument();
});
@@ -103,7 +103,69 @@ describe("RunDetail page", () => {
expect(screen.getByText("New run")).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "OLD" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Trades" }));
fireEvent.click(screen.getByRole("tab", { name: "Trades" }));
expect(screen.queryByText("OLD TRADE")).not.toBeInTheDocument();
});
it("exposes run status and tab state while keeping the trades table scrollable", async () => {
apiMock.getRun.mockResolvedValue({
status: "success",
run_id: "accessible",
prompt: "Accessible run",
trade_log: [{
time: "2026-07-29",
code: "AAPL",
side: "BUY",
price: "200",
qty: "2",
reason: "signal",
}],
});
apiMock.getRunCode.mockResolvedValue({});
renderRunDetail("/runs/accessible");
await screen.findByText("Accessible run");
expect(screen.getByText("Completed")).toHaveClass("sr-only");
expect(screen.getByRole("heading", { level: 1, name: "accessible" })).toHaveClass("text-2xl", "font-semibold");
expect(screen.getByRole("tablist")).toBeInTheDocument();
const chartTab = screen.getByRole("tab", { name: "Chart" });
const tradesTab = screen.getByRole("tab", { name: "Trades" });
expect(chartTab).toHaveAttribute("aria-selected", "true");
expect(chartTab).toHaveClass("font-medium");
expect(tradesTab).toHaveAttribute("aria-selected", "false");
fireEvent.click(tradesTab);
expect(tradesTab).toHaveAttribute("aria-selected", "true");
expect(tradesTab).toHaveClass("font-medium");
const table = screen.getByRole("table");
expect(table.parentElement).toHaveClass("overflow-x-auto", "rounded-xl", "border");
expect(screen.getByRole("columnheader", { name: "Time" })).toHaveClass("ps-4");
});
it("pads and scroll-wraps run-card key/value and artifact tables", async () => {
apiMock.getRun.mockResolvedValue({
status: "success",
run_id: "card",
prompt: "Run card",
run_card: {
backtest: { engine: "vectorized" },
artifacts: [{ path: "artifacts/result.json", size_bytes: 42, sha256: "abc123" }],
} as NonNullable<RunData["run_card"]>,
});
apiMock.getRunCode.mockResolvedValue({});
renderRunDetail("/runs/card");
await screen.findByText("Run card");
fireEvent.click(screen.getByRole("tab", { name: "Run Card" }));
const keyCell = await screen.findByText("engine");
expect(keyCell).toHaveClass("ps-4");
expect(keyCell.closest("table")?.parentElement).toHaveClass("overflow-x-auto");
expect(screen.getByRole("columnheader", { name: "Path" })).toHaveClass("ps-4");
expect(screen.getByText("artifacts/result.json")).toHaveClass("ps-4");
});
});
+2 -1
View File
@@ -46,7 +46,8 @@ export const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ path: "/", element: wrap(Home) },
{ path: "/", element: wrap(Agent) },
{ path: "/about", element: wrap(Home) },
{ path: "/agent", element: wrap(Agent) },
{ path: "/runtime", element: wrap(Runtime) },
{ path: "/reports", element: wrap(Reports) },
@@ -1,5 +1,6 @@
import { useAgentStore } from "../agent";
import { makeMessage, makeToolCall, resetFactories } from "@/tests/helpers/factories";
import type { SwarmRunStatus } from "@/types/agent";
beforeEach(() => {
useAgentStore.getState().reset();
@@ -15,6 +16,7 @@ describe("agent store — initial state", () => {
expect(s.streamingText).toBe("");
expect(s.streamingSessionId).toBeNull();
expect(s.toolCalls).toEqual([]);
expect(s.swarmRuns).toEqual({});
expect(s.sseStatus).toBe("disconnected");
expect(s.sseRetryAttempt).toBe(0);
expect(s.sessionLoading).toBe(false);
@@ -81,6 +83,18 @@ describe("setStatus", () => {
useAgentStore.getState().setStatus("streaming");
expect(useAgentStore.getState().streamingSessionId).toBeNull();
});
it("clears only the matching replay session spinner", () => {
const store = useAgentStore.getState();
store.setSessionId("sess-1");
store.setStatus("streaming");
store.switchSession("sess-2");
store.clearStreamingSession("sess-2");
expect(useAgentStore.getState().streamingSessionId).toBe("sess-1");
store.clearStreamingSession("sess-1");
expect(useAgentStore.getState().streamingSessionId).toBeNull();
});
});
describe("setSessionId / loadHistory", () => {
@@ -120,6 +134,60 @@ describe("tool calls", () => {
useAgentStore.getState().updateToolCall("nonexistent", { status: "error" });
expect(useAgentStore.getState().toolCalls[0].status).toBe("running");
});
it("updates repeated running tool calls in FIFO order", () => {
const store = useAgentStore.getState();
store.addToolCall(makeToolCall({ id: "search#1", tool: "search" }));
store.addToolCall(makeToolCall({ id: "search#2", tool: "search" }));
store.updateOldestRunningToolCall("search", { status: "ok" });
expect(useAgentStore.getState().toolCalls.map((tc) => tc.status)).toEqual([
"ok",
"running",
]);
store.updateOldestRunningToolCall("search", { status: "error" });
expect(useAgentStore.getState().toolCalls.map((tc) => tc.status)).toEqual([
"ok",
"error",
]);
});
});
describe("swarm status ordering", () => {
it("keeps a stable placeholder message while status updates in its own slice", () => {
const now = vi.spyOn(Date, "now");
now.mockReturnValue(100);
const status: SwarmRunStatus = {
runId: "swarm-1",
preset: "research",
status: "running",
currentLayer: 0,
totalLayers: 2,
startedAt: 100,
agents: [],
};
const store = useAgentStore.getState();
store.upsertSwarmStatus(status);
const placeholderMessages = useAgentStore.getState().messages;
now.mockReturnValue(200);
store.upsertSwarmStatus({ ...status, currentLayer: 1 });
store.updateSwarmStatus("swarm-1", (current) => ({
...current,
status: "completed",
}));
expect(useAgentStore.getState().messages).toHaveLength(1);
expect(useAgentStore.getState().messages).toBe(placeholderMessages);
expect(useAgentStore.getState().messages[0].timestamp).toBe(100);
expect(useAgentStore.getState().messages[0].swarmStatus).toBeUndefined();
expect(useAgentStore.getState().swarmRuns["swarm-1"]).toMatchObject({
currentLayer: 1,
status: "completed",
});
now.mockRestore();
});
});
describe("session cache", () => {
@@ -154,6 +222,30 @@ describe("session cache", () => {
expect(useAgentStore.getState().getCachedSession("sess-2")).toBeUndefined();
expect(useAgentStore.getState().getCachedSession("sess-1")).toBeDefined();
});
it("restores cached swarm state without mutating placeholder messages", () => {
const status: SwarmRunStatus = {
runId: "swarm-cache",
preset: "research",
status: "running",
currentLayer: 1,
totalLayers: 2,
startedAt: 100,
agents: [],
};
const store = useAgentStore.getState();
store.upsertSwarmStatus(status);
store.cacheSession("swarm-session", useAgentStore.getState().messages);
store.switchSession("other-session");
store.switchSession(
"swarm-session",
useAgentStore.getState().getCachedSession("swarm-session"),
);
expect(useAgentStore.getState().swarmRuns["swarm-cache"]).toEqual(status);
expect(useAgentStore.getState().messages[0].swarmStatus).toBeUndefined();
});
});
describe("setSseStatus", () => {
@@ -183,6 +275,7 @@ describe("switchSession", () => {
expect(s.sessionId).toBe("new-sess");
expect(s.messages).toEqual([]);
expect(s.toolCalls).toEqual([]);
expect(s.swarmRuns).toEqual({});
expect(s.streamingText).toBe("");
expect(s.status).toBe("idle");
expect(s.sessionLoading).toBe(true);
@@ -229,6 +322,7 @@ describe("reset", () => {
expect(s.sessionId).toBeNull();
expect(s.streamingText).toBe("");
expect(s.toolCalls).toEqual([]);
expect(s.swarmRuns).toEqual({});
expect(s.status).toBe("idle");
expect(s.streamingSessionId).toBeNull();
expect(s.sessionLoading).toBe(false);
+75 -22
View File
@@ -2,10 +2,21 @@ import { create } from "zustand";
import type { AgentMessage, SwarmRunStatus, ToolCallEntry } from "@/types/agent";
const SESSION_CACHE_MAX = 5;
const _sessionCache = new Map<string, AgentMessage[]>();
export interface AgentMessageMeta {
attachment?: { filename: string };
swarmMode?: boolean;
goalMode?: boolean;
requestText?: string;
}
export type StoredAgentMessage = AgentMessage & { meta?: AgentMessageMeta };
const _sessionCache = new Map<string, StoredAgentMessage[]>();
const _swarmSessionCache = new Map<string, Record<string, SwarmRunStatus>>();
interface AgentState {
messages: AgentMessage[];
messages: StoredAgentMessage[];
sessionId: string | null;
status: "idle" | "streaming" | "error";
streamingText: string;
@@ -15,29 +26,32 @@ interface AgentState {
streamingSessionId: string | null;
toolCalls: ToolCallEntry[];
swarmRuns: Record<string, SwarmRunStatus>;
sseStatus: "disconnected" | "connected" | "reconnecting";
sseRetryAttempt: number;
addMessage: (msg: Omit<AgentMessage, "id"> & { id?: string }) => void;
addMessage: (msg: Omit<StoredAgentMessage, "id"> & { id?: string }) => void;
appendDelta: (delta: string) => void;
setStatus: (s: AgentState["status"]) => void;
setSessionId: (id: string | null) => void;
loadHistory: (msgs: AgentMessage[]) => void;
loadHistory: (msgs: StoredAgentMessage[]) => void;
addToolCall: (entry: ToolCallEntry) => void;
updateToolCall: (id: string, update: Partial<ToolCallEntry>) => void;
updateOldestRunningToolCall: (tool: string, update: Partial<ToolCallEntry>) => void;
upsertSwarmStatus: (status: SwarmRunStatus) => void;
updateSwarmStatus: (runId: string, updater: (status: SwarmRunStatus) => SwarmRunStatus) => void;
cacheSession: (sid: string, msgs: AgentMessage[]) => void;
getCachedSession: (sid: string) => AgentMessage[] | undefined;
cacheSession: (sid: string, msgs: StoredAgentMessage[]) => void;
getCachedSession: (sid: string) => StoredAgentMessage[] | undefined;
clearStreaming: () => void;
clearStreamingSession: (sid: string) => void;
setSseStatus: (s: AgentState["sseStatus"], retryAttempt?: number) => void;
switchSession: (sid: string, msgs?: AgentMessage[]) => void;
switchSession: (sid: string, msgs?: StoredAgentMessage[]) => void;
sessionLoading: boolean;
setSessionLoading: (v: boolean) => void;
@@ -54,6 +68,7 @@ export const useAgentStore = create<AgentState>((set) => ({
streamingText: "",
streamingSessionId: null,
toolCalls: [],
swarmRuns: {},
sseStatus: "disconnected",
sseRetryAttempt: 0,
sessionLoading: false,
@@ -75,7 +90,22 @@ export const useAgentStore = create<AgentState>((set) => ({
return patch;
}),
setSessionId: (sessionId) => set({ sessionId }),
loadHistory: (msgs) => set({ messages: msgs }),
loadHistory: (msgs) =>
set((s) => {
const historicalSwarmIds = new Set(
msgs.flatMap((msg) => msg.swarmRunId ? [msg.swarmRunId] : []),
);
const liveSwarmPlaceholders = s.messages.filter((msg) => (
msg.type === "swarm_status" &&
msg.swarmRunId &&
s.swarmRuns[msg.swarmRunId] &&
!historicalSwarmIds.has(msg.swarmRunId)
));
return {
messages: [...msgs, ...liveSwarmPlaceholders]
.sort((a, b) => a.timestamp - b.timestamp),
};
}),
addToolCall: (entry) =>
set((s) => ({ toolCalls: [...s.toolCalls, entry] })),
@@ -83,15 +113,30 @@ export const useAgentStore = create<AgentState>((set) => ({
set((s) => ({
toolCalls: s.toolCalls.map((tc) => tc.id === id ? { ...tc, ...update } : tc),
})),
updateOldestRunningToolCall: (tool, update) =>
set((s) => {
let idx = -1;
for (let i = 0; i < s.toolCalls.length; i += 1) {
if (s.toolCalls[i].tool === tool && s.toolCalls[i].status === "running") {
idx = i;
break;
}
}
if (idx < 0) return {};
const toolCalls = [...s.toolCalls];
toolCalls[idx] = { ...toolCalls[idx], ...update };
return { toolCalls };
}),
upsertSwarmStatus: (swarmStatus) =>
set((s) => {
const idx = s.messages.findIndex((m) => m.type === "swarm_status" && m.swarmRunId === swarmStatus.runId);
if (idx >= 0) {
const messages = [...s.messages];
messages[idx] = { ...messages[idx], swarmStatus, timestamp: Date.now() };
return { messages };
return {
swarmRuns: { ...s.swarmRuns, [swarmStatus.runId]: swarmStatus },
};
}
return {
swarmRuns: { ...s.swarmRuns, [swarmStatus.runId]: swarmStatus },
messages: [
...s.messages,
{
@@ -99,7 +144,6 @@ export const useAgentStore = create<AgentState>((set) => ({
type: "swarm_status",
content: "",
swarmRunId: swarmStatus.runId,
swarmStatus,
timestamp: Date.now(),
},
],
@@ -107,25 +151,33 @@ export const useAgentStore = create<AgentState>((set) => ({
}),
updateSwarmStatus: (runId, updater) =>
set((s) => {
const idx = s.messages.findIndex((m) => m.type === "swarm_status" && m.swarmRunId === runId && m.swarmStatus);
if (idx < 0) return {};
const messages = [...s.messages];
const current = messages[idx].swarmStatus!;
messages[idx] = { ...messages[idx], swarmStatus: updater(current), timestamp: Date.now() };
return { messages };
const current = s.swarmRuns[runId];
if (!current) return {};
return {
swarmRuns: { ...s.swarmRuns, [runId]: updater(current) },
};
}),
cacheSession: (sid, msgs) => {
cacheSession: (sid, msgs) =>
set((s) => {
_sessionCache.delete(sid);
_sessionCache.set(sid, msgs);
_swarmSessionCache.delete(sid);
_swarmSessionCache.set(sid, s.swarmRuns);
if (_sessionCache.size > SESSION_CACHE_MAX) {
const oldest = _sessionCache.keys().next().value;
if (oldest) _sessionCache.delete(oldest);
if (oldest) {
_sessionCache.delete(oldest);
_swarmSessionCache.delete(oldest);
}
},
}
return {};
}),
getCachedSession: (sid) => _sessionCache.get(sid),
clearStreaming: () => set({ streamingText: "" }),
clearStreamingSession: (sid) =>
set((s) => s.streamingSessionId === sid ? { streamingSessionId: null } : {}),
setSseStatus: (sseStatus, retryAttempt) =>
set({ sseStatus, sseRetryAttempt: retryAttempt ?? 0 }),
@@ -138,6 +190,7 @@ export const useAgentStore = create<AgentState>((set) => ({
status: "idle",
streamingText: "",
toolCalls: [],
swarmRuns: msgs ? (_swarmSessionCache.get(sid) ?? {}) : {},
sessionLoading: !msgs,
// Preserve streamingSessionId so the sidebar spinner stays visible
// when switching away from a running session.
@@ -151,7 +204,7 @@ export const useAgentStore = create<AgentState>((set) => ({
_id = 0;
set({
messages: [], status: "idle", streamingText: "",
sessionId: null, toolCalls: [], sessionLoading: false,
sessionId: null, toolCalls: [], swarmRuns: {}, sessionLoading: false,
streamingSessionId: null,
});
},
+3
View File
@@ -21,6 +21,9 @@ export default {
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
// Display serif for the hero greeting only — system stack, no webfont.
// Deliberately NOT used for answers/tables (numeric content stays sans/mono).
serif: ["Georgia", "Songti SC", "Noto Serif SC", "serif"],
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
},
borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)" },