diff --git a/NOTICE b/NOTICE index 183d9899..1ea0b48d 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/frontend/index.html b/frontend/index.html index 165043ba..daaa6790 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,10 +3,142 @@ + Vibe-Trading — vibe trading with your professional financial agent team - + + + + @@ -14,5 +146,3 @@ - - diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 1c3b507f..075fe5f7 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,8 +1,8 @@ - + - + - - + + diff --git a/frontend/public/fonts/LICENSE b/frontend/public/fonts/LICENSE new file mode 100644 index 00000000..83d26f5c --- /dev/null +++ b/frontend/public/fonts/LICENSE @@ -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. diff --git a/frontend/public/fonts/inter-latin-400-normal.woff2 b/frontend/public/fonts/inter-latin-400-normal.woff2 new file mode 100644 index 00000000..f15b025d Binary files /dev/null and b/frontend/public/fonts/inter-latin-400-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-500-normal.woff2 b/frontend/public/fonts/inter-latin-500-normal.woff2 new file mode 100644 index 00000000..54f0a595 Binary files /dev/null and b/frontend/public/fonts/inter-latin-500-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-600-normal.woff2 b/frontend/public/fonts/inter-latin-600-normal.woff2 new file mode 100644 index 00000000..d1897949 Binary files /dev/null and b/frontend/public/fonts/inter-latin-600-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-700-normal.woff2 b/frontend/public/fonts/inter-latin-700-normal.woff2 new file mode 100644 index 00000000..a68fb101 Binary files /dev/null and b/frontend/public/fonts/inter-latin-700-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-ext-400-normal.woff2 b/frontend/public/fonts/inter-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..5243e4b2 Binary files /dev/null and b/frontend/public/fonts/inter-latin-ext-400-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-ext-500-normal.woff2 b/frontend/public/fonts/inter-latin-ext-500-normal.woff2 new file mode 100644 index 00000000..2ab7ebf6 Binary files /dev/null and b/frontend/public/fonts/inter-latin-ext-500-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-ext-600-normal.woff2 b/frontend/public/fonts/inter-latin-ext-600-normal.woff2 new file mode 100644 index 00000000..bad65d4b Binary files /dev/null and b/frontend/public/fonts/inter-latin-ext-600-normal.woff2 differ diff --git a/frontend/public/fonts/inter-latin-ext-700-normal.woff2 b/frontend/public/fonts/inter-latin-ext-700-normal.woff2 new file mode 100644 index 00000000..c9507e3f Binary files /dev/null and b/frontend/public/fonts/inter-latin-ext-700-normal.woff2 differ diff --git a/frontend/public/fonts/jetbrains-mono-latin-400-normal.woff2 b/frontend/public/fonts/jetbrains-mono-latin-400-normal.woff2 new file mode 100644 index 00000000..58588733 Binary files /dev/null and b/frontend/public/fonts/jetbrains-mono-latin-400-normal.woff2 differ diff --git a/frontend/public/fonts/jetbrains-mono-latin-500-normal.woff2 b/frontend/public/fonts/jetbrains-mono-latin-500-normal.woff2 new file mode 100644 index 00000000..be878e68 Binary files /dev/null and b/frontend/public/fonts/jetbrains-mono-latin-500-normal.woff2 differ diff --git a/frontend/public/fonts/jetbrains-mono-latin-700-normal.woff2 b/frontend/public/fonts/jetbrains-mono-latin-700-normal.woff2 new file mode 100644 index 00000000..3a4e333f Binary files /dev/null and b/frontend/public/fonts/jetbrains-mono-latin-700-normal.woff2 differ diff --git a/frontend/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 b/frontend/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..9d97c9b7 Binary files /dev/null and b/frontend/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 differ diff --git a/frontend/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 b/frontend/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 new file mode 100644 index 00000000..5cc89b16 Binary files /dev/null and b/frontend/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 differ diff --git a/frontend/public/fonts/jetbrains-mono-latin-ext-700-normal.woff2 b/frontend/public/fonts/jetbrains-mono-latin-ext-700-normal.woff2 new file mode 100644 index 00000000..6dd7e693 Binary files /dev/null and b/frontend/public/fonts/jetbrains-mono-latin-ext-700-normal.woff2 differ diff --git a/frontend/public/theme-boot.js b/frontend/public/theme-boot.js new file mode 100644 index 00000000..637d5a9a --- /dev/null +++ b/frontend/public/theme-boot.js @@ -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"; +})(); diff --git a/frontend/src/components/charts/CandlestickChart.tsx b/frontend/src/components/charts/CandlestickChart.tsx index ee1c43a5..1bbc30d8 100644 --- a/frontend/src/components/charts/CandlestickChart.tsx +++ b/frontend/src/components/charts/CandlestickChart.tsx @@ -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("ALL"); const [overlays, setOverlays] = useState>(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 diff --git a/frontend/src/components/charts/CorrelationMatrix.tsx b/frontend/src/components/charts/CorrelationMatrix.tsx index 6e77b54a..0eb29455 100644 --- a/frontend/src/components/charts/CorrelationMatrix.tsx +++ b/frontend/src/components/charts/CorrelationMatrix.tsx @@ -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(null); + const dark = useThemeDark(); useEffect(() => { if (!ref.current || labels.length === 0 || matrix.length === 0) return; @@ -97,13 +99,24 @@ 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
{i18n.t("charts.noCorrelationData")}
; } return
; -} \ No newline at end of file +} diff --git a/frontend/src/components/charts/EquityChart.tsx b/frontend/src/components/charts/EquityChart.tsx index 0bb5774c..d5dfa258 100644 --- a/frontend/src/components/charts/EquityChart.tsx +++ b/frontend/src/components/charts/EquityChart.tsx @@ -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(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) { diff --git a/frontend/src/components/charts/MiniEquityChart.tsx b/frontend/src/components/charts/MiniEquityChart.tsx index 3a20fb9b..379f4a4c 100644 --- a/frontend/src/components/charts/MiniEquityChart.tsx +++ b/frontend/src/components/charts/MiniEquityChart.tsx @@ -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(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; diff --git a/frontend/src/components/charts/RegimeTimeline.tsx b/frontend/src/components/charts/RegimeTimeline.tsx index 80150e47..8b95dbb4 100644 --- a/frontend/src/components/charts/RegimeTimeline.tsx +++ b/frontend/src/components/charts/RegimeTimeline.tsx @@ -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(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; diff --git a/frontend/src/components/chat/AgentAvatar.tsx b/frontend/src/components/chat/AgentAvatar.tsx index fb07e813..4742b434 100644 --- a/frontend/src/components/chat/AgentAvatar.tsx +++ b/frontend/src/components/chat/AgentAvatar.tsx @@ -1,7 +1,9 @@ +import { BrandMark } from "@/components/common/BrandMark"; + export function AgentAvatar() { return ( -
- P + ); } diff --git a/frontend/src/components/chat/ConversationTimeline.tsx b/frontend/src/components/chat/ConversationTimeline.tsx index 53b1a58d..39fdc07a 100644 --- a/frontend/src/components/chat/ConversationTimeline.tsx +++ b/frontend/src/components/chat/ConversationTimeline.tsx @@ -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; diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx index f97c0723..94a8de20 100644 --- a/frontend/src/components/chat/MessageBubble.tsx +++ b/frontend/src/components/chat/MessageBubble.tsx @@ -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 ( +
+ + + ); + }, + a: ({ node, ...props }) => { + void node; + return ; + }, +}; +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 { + 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 {this.props.content}; + } + 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 ( +
+ + + {normalized} + + + {showCursor && ( + + )} +
+ ); +}); 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 ( ); } @@ -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 ( -
-
+
+
+ {meta && (meta.attachment || meta.swarmMode || meta.goalMode) && ( +
+ {meta.attachment && ( + + + {i18n.t("agent.attachmentChip" as never)}: + {meta.attachment.filename} + + )} + {meta.swarmMode && ( + + + {i18n.t("agent.swarmModeChip" as never)} + + )} + {meta.goalMode && ( + + + {i18n.t("agent.goalModeChip" as never)} + + )} +
+ )} {msg.content} - {ts && {ts}} -
-
-
); @@ -75,14 +185,11 @@ export const MessageBubble = memo(function MessageBubble({ msg, onRetry }: Props if (msg.type === "answer") { return ( -
+
-
+
-
- {normalizeMathDelimiters(msg.content)} -
- {ts && {ts}} +
); diff --git a/frontend/src/components/chat/MetricsCard.tsx b/frontend/src/components/chat/MetricsCard.tsx index 07d68875..e09de188 100644 --- a/frontend/src/components/chat/MetricsCard.tsx +++ b/frontend/src/components/chat/MetricsCard.tsx @@ -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> = { positive: "▲", negative: "▼" }; +const SENTIMENT_SR_KEY: Partial> = { positive: "metrics.good", negative: "metrics.poor" }; + interface Props { metrics: Record; compact?: boolean; @@ -33,10 +40,18 @@ export const MetricsCard = memo(function MetricsCard({ metrics, compact = false {getMetricLabel(k)}

+ {SENTIMENT_GLYPH[metricSentiment(k, v)] != null && ( + + )} {formatMetricVal(k, v)} + {SENTIMENT_SR_KEY[metricSentiment(k, v)] != null && ( + {i18n.t(SENTIMENT_SR_KEY[metricSentiment(k, v)]! as never)} + )}

))} diff --git a/frontend/src/components/chat/PineScriptViewer.tsx b/frontend/src/components/chat/PineScriptViewer.tsx index c5369fb2..20a27dc2 100644 --- a/frontend/src/components/chat/PineScriptViewer.tsx +++ b/frontend/src/components/chat/PineScriptViewer.tsx @@ -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(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(
e.stopPropagation()} > @@ -59,6 +79,9 @@ export const PineScriptViewer = memo(function PineScriptViewer({ code, onClose } {t("pineViewer.docs")}
-
+
, + document.body, ); }); diff --git a/frontend/src/components/chat/RunCompleteCard.tsx b/frontend/src/components/chat/RunCompleteCard.tsx index b07abbdb..d1e2ea5b 100644 --- a/frontend/src/components/chat/RunCompleteCard.tsx +++ b/frontend/src/components/chat/RunCompleteCard.tsx @@ -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
; +} + 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(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) { - api.getRun(msg.runId).then(r => { - if (r.equity_curve) setCurve(r.equity_curve.map(e => ({ time: e.time, equity: e.equity }))); - }).catch(() => {}); - } + if (curve || !msg.runId) return; + + let cancelled = false; + setCurveLoading(true); + api.getRun(msg.runId).then(r => { + 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 && ( )} - {curve && curve.length > 1 && ( - - )} + {curveLoading ? ( + + ) : curve && curve.length > 1 ? ( + }> + + + ) : null}
{msg.runId && ( ; + case "failed": + return ; + case "blocked": + return ; + case "retry": + return ; + case "running": + return ( + + ); + case "cancelled": + return ; + case "waiting": + default: + return ; + } +} + +function StatusPill({ + agentStatus, + animate, + t, +}: { + agentStatus: SwarmAgentDisplayStatus; + animate: boolean; + t: ManifestTranslation; +}) { + return ( + + + + + {localizeStatus(agentStatus, t)} + + ); +} + +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 ( +
+
+
+ {agent.agentId} +
+ {agent.role && ( +
+ {agent.role} +
+ )} +
+
+ +
+
+ {agent.tool ? localizeToolName(agent.tool, agent.tool) : "-"} +
+
+
{formatElapsed(agent.elapsed_s, t)}
+ {agent.iterations != null && ( +
+
+ )} +
+
+ {output} +
+
+ ); +} + +function NarrowAgentCard({ + agent, + isActive, + t, +}: { + agent: SwarmAgentStatus; + isActive: boolean; + t: ManifestTranslation; +}) { + return ( +
+
+
+
+ {agent.agentId} +
+ {agent.role && ( +
+ {agent.role} +
+ )} +
+ +
+
+ + {t("swarmStatus.tool")} + {agent.tool ? localizeToolName(agent.tool, agent.tool) : "-"} + + + {t("swarmStatus.time")} + {formatElapsed(agent.elapsed_s, t)} + +
+
+ ); +} + +export const SwarmDashboard = memo(function SwarmDashboard({ + agents, + emptyStatus, + isActive = true, +}: Props) { + const { t: typedT } = useTranslation(); + const t = typedT as unknown as ManifestTranslation; + + return ( +
+ {agents.length === 0 ? ( +
+ {isActive ? ( + t("swarmStatus.teamStarting") + ) : ( + <> + {emptyStatus || localizeStatus("unknown", t)} + {" — "} + {t("swarmStatus.detailsUnavailable")} + + )} +
+ ) : ( + <> +
+
+ + {t("swarmStatus.agent")} + + + {t("swarmStatus.status")} + + + {t("swarmStatus.tool")} + + + {t("swarmStatus.time")} + + + {t("swarmStatus.output")} + +
+
+ {agents.map((agent) => ( + + ))} +
+
+ +
+ {agents.map((agent) => ( + + ))} +
+ + )} +
+ ); +}); diff --git a/frontend/src/components/chat/SwarmStatusCard.tsx b/frontend/src/components/chat/SwarmStatusCard.tsx index a0fdb3cd..3b4291d4 100644 --- a/frontend/src/components/chat/SwarmStatusCard.tsx +++ b/frontend/src/components/chat/SwarmStatusCard.tsx @@ -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"; -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"; - } -} + if (!hasLayerTotal) { + if (!isActive) return null; -function StatusIcon({ status }: { status: SwarmAgentDisplayStatus }) { - switch (status) { - case "done": - return ; - case "failed": - return ; - case "blocked": - return ; - case "retry": - return ; - case "running": - return ; - case "cancelled": - return ; - case "waiting": - default: - return ; + return ( +
+ +
+
+
+ + +
+ ); } + + 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"; + + return ( +
+ {Array.from({ length: layerTotal }, (_, index) => { + const isDone = isRunComplete || index < layerCurrent - 1; + const isCurrent = !isRunComplete && index === layerCurrent - 1; + + return ( +
+ ); } 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 (
@@ -98,78 +141,47 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
- {status.preset} - - {status.status.replace(/_/g, " ")} + + {localizePreset(status.preset, t)} + + + {localizeStatus(status.status, t)}
-
- - {t('swarmStatus.agents', { done, total: total || 0 })} -
-
- -
- -
- {t('swarmStatus.layer', { current: layerCurrent, total: layerTotal })} -
-
- -
-
-
- {t('swarmStatus.agent')} - {t('swarmStatus.status')} - {t('swarmStatus.tool')} - {t('swarmStatus.time')} - {t('swarmStatus.iters')} - {t('swarmStatus.output')} -
-
- {status.agents.map((agent) => ( -
-
-
{agent.agentId}
- {agent.role &&
{agent.role}
} -
-
- - - {agent.status} - -
-
- {agent.tool ? localizeToolName(agent.tool, agent.tool) : "-"} -
-
- {formatElapsed(agent.elapsed_s)} -
-
- {agent.iterations ?? "-"} -
-
- {agent.error || agent.lastText || "-"} -
-
- ))} - {status.agents.length === 0 && ( -
- {t('swarmStatus.waitingForEvents')} -
+ {(hasAgentTotal || isActive) && ( +
+ + {hasAgentTotal ? ( + {t("swarmStatus.agents", { done, total })} + ) : ( + + )}
-
+ )}
+ + {(status.totalLayers > 0 || isActive) && ( +
+ +
+ )} + +
); diff --git a/frontend/src/components/chat/ThinkingTimeline.tsx b/frontend/src/components/chat/ThinkingTimeline.tsx index 60ebb992..e1c0428f 100644 --- a/frontend/src/components/chat/ThinkingTimeline.tsx +++ b/frontend/src/components/chat/ThinkingTimeline.tsx @@ -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(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 (
{/* Summary bar */} @@ -92,52 +88,54 @@ export const ThinkingTimeline = memo(function ThinkingTimeline({ messages, isLat
)} - {/* Expanded step list */} - {expanded && steps.length > 0 && ( -
- {steps.map((step, i) => ( -
- {/* Tree connector */} - - {i < steps.length - 1 ? "├" : "└"} - + + ))} +
+ )} - {/* Expanded: show thinking content if any (for Q&A without tools) */} - {expanded && steps.length === 0 && latestThinking && ( -
-

- {latestThinking} -

-
- )} + {/* Expanded: show thinking content if any (for Q&A without tools) */} + {expanded && steps.length === 0 && latestThinking && ( +
+

+ {latestThinking} +

+
+ )} +
); }); diff --git a/frontend/src/components/chat/ToolProgressIndicator.tsx b/frontend/src/components/chat/ToolProgressIndicator.tsx index d69e92e1..4145da30 100644 --- a/frontend/src/components/chat/ToolProgressIndicator.tsx +++ b/frontend/src/components/chat/ToolProgressIndicator.tsx @@ -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 : ; 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 (
{/* Primary row */} -
+
{connector !== "none" && (
@@ -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>(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(); + 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(); + + 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 ? - : ; - - // 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 + ? + : ; /* ---------- render ---------- */ - if (running.length === 1) { - const only = running[0]; - const eta = computeEta(only); + if (toolCalls.length === 1) { + const only = toolCalls[0]; return ( -
+
+ + {coarseStatus} +
); } - // 2+ running — header + indented rows. + // 2+ calls — header + indented rows. return ( -
+
+ + {coarseStatus} + {/* Header row */} -
+ {/* Indented rows */}
- {rows.map((tc, i) => ( + {rows.map(({ entry, index }, i) => ( ))} {overflow > 0 && (
- … +{overflow} more +
)}
); -} +}); diff --git a/frontend/src/components/chat/WelcomeScreen.tsx b/frontend/src/components/chat/WelcomeScreen.tsx index 54b93395..45459178 100644 --- a/frontend/src/components/chat/WelcomeScreen.tsx +++ b/frontend/src/components/chat/WelcomeScreen.tsx @@ -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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - color: "text-emerald-400 border-emerald-500/30 hover:border-emerald-500/60 hover:bg-emerald-500/5", examples: [ { titleKey: "welcome.examples.trainShadow", @@ -178,89 +171,166 @@ 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:
- + - - - - - @@ -401,8 +394,8 @@ function BrowseView() { + + {rows.map((r) => ( + + + + + + + + ))} + +
{i18n.t("alphaZoo.alphaCatalogue")}
{i18n.t("alphaZoo.selectForCompare")} + {i18n.t("alphaZoo.id")} + {i18n.t("alphaZoo.zoo")} + {i18n.t("alphaZoo.theme")} + {i18n.t("alphaZoo.universe")} + {i18n.t("alphaZoo.decayDays")}
@@ -451,7 +444,7 @@ function BrowseView() { @@ -510,7 +503,7 @@ function DetailView({ alphaId }: DetailProps) {
+ + {r.id} + + {fmtNum(r.ic_mean)}{fmtNum(r.ir)} + {(r.theme || []).map((t) => i18n.t("alphaZoo.themes." + t, { defaultValue: t })).join(", ") || "—"} + + +
+
)}
); @@ -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() {
-

+

{i18n.t("alphaZoo.compareAlphas")}

@@ -1261,7 +1264,7 @@ function CompareView() {

- +
@@ -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) => (