Files
Auto-Trader/frontend/src/components/layout/Layout.tsx
T
Haozhe Wu 71933ecf31 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.
2026-07-29 23:07:35 +08:00

432 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useSearchParams } from "react-router";
import { Activity, BarChart3, Bot, Check, ChevronDown, FileText, Languages, Moon, Sun, Plus, Trash2, Pencil, MessageSquare, ChevronsLeft, ChevronsRight, Settings, Layers, Loader2 } from "lucide-react";
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";
// APP_VERSION is sourced from i18n locale files (app.version key) to keep a
// single source of truth across the footer and every localised README.
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: 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();
const { dark, toggle } = useDarkMode();
const [sessions, setSessions] = useState<SessionItem[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const sseStatus = useAgentStore(s => s.sseStatus);
const sseRetryAttempt = useAgentStore(s => s.sseRetryAttempt);
const [collapsed, setCollapsed] = useState(() => safeGet("qa-sidebar") === "collapsed");
const activeSessionId = searchParams.get("session");
const streamingSessionId = useAgentStore(s => s.streamingSessionId);
useEffect(() => {
safeSet("qa-sidebar", collapsed ? "collapsed" : "expanded");
}, [collapsed]);
const loadSessions = () => {
api.listSessions()
.then((list) => setSessions(Array.isArray(list) ? list : []))
.catch(() => {})
.finally(() => setSessionsLoading(false));
};
// Load sessions on mount. Also refresh when navigating TO /agent or when
// the active session changes (covers new session creation from Agent).
const isAgentPage = pathname.startsWith("/agent");
useEffect(() => { loadSessions(); }, [isAgentPage, activeSessionId]);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const deleteSession = async (sid: string) => {
try {
await api.deleteSession(sid);
setSessions((prev) => prev.filter((s) => s.session_id !== sid));
} catch { /* ignore */ }
setDeleteTarget(null);
};
const renameSession = async (sid: string) => {
if (!renameValue.trim()) { setRenameTarget(null); return; }
try {
await api.renameSession(sid, renameValue.trim());
setSessions((prev) => prev.map((s) => s.session_id === sid ? { ...s, title: renameValue.trim() } : s));
} catch { /* ignore */ }
setRenameTarget(null);
};
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
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 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
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-[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/60 hover:text-foreground"
)}
title={collapsed ? text : undefined}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
{!collapsed && <span className="max-md:hidden">{text}</span>}
</Link>
);
})}
</nav>
{/* Sessions — hidden when collapsed */}
{!collapsed && (
<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" />
{t('layout.sessions')}
</span>
<Link
to="/agent"
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" aria-hidden="true" />
</Link>
</div>
<div className="px-2 pb-2 space-y-0.5 overflow-auto flex-1">
{sessionsLoading ? (
<div className="space-y-1.5 px-2 py-1">
{[1, 2, 3].map((i) => (
<div key={i} className="h-7 rounded-md bg-muted/50 animate-pulse" />
))}
</div>
) : sessions.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground/60">{t('layout.noSessions')}</p>
) : null}
{sessions.map((s) => {
const isActive = s.session_id === activeSessionId;
const isDeleting = deleteTarget === s.session_id;
const isRenaming = renameTarget === s.session_id;
return (
<div key={s.session_id} className="group relative flex items-center">
{isRenaming ? (
<input
autoFocus
value={renameValue}
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)}
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
to={`/agent?session=${s.session_id}`}
className={cn(
"flex-1 min-w-0 ps-3 pe-14 py-1.5 rounded-md text-xs transition-colors truncate block border-s-2",
isActive
? "border-s-primary bg-primary/10 text-primary font-medium"
: "border-s-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={s.title || s.session_id}
>
<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" />
) : (
<span className={cn(
"h-1.5 w-1.5 rounded-full shrink-0",
isActive ? "bg-primary/70" : "bg-muted-foreground/40"
)} />
)}
<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.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 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.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.5 text-muted-foreground hover:text-danger rounded"
title={t('layout.delete')}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
{/* Spacer when collapsed */}
{collapsed && <div className="flex-1" />}
{/* Footer */}
<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')}>
{dark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
<button onClick={() => setCollapsed(false)} className="p-1.5 text-muted-foreground hover:text-foreground rounded transition-colors" title={t('layout.expand')}>
<ChevronsRight className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<div className="flex items-center justify-between max-md:flex-col">
<button
onClick={toggle}
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" />}
<span className="max-md:hidden">{dark ? t('layout.light') : t('layout.dark')}</span>
</button>
<div className="flex items-center gap-1 max-md:hidden">
<button
onClick={() => setCollapsed(true)}
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 max-md:items-center">
<LanguageSwitcher />
<p className="text-[10px] text-muted-foreground/60 max-md:hidden">{t('app.version')}</p>
</div>
</>
)}
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
<ConnectionBanner status={sseStatus} retryAttempt={sseRetryAttempt} />
<main id="main" className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Language switcher — dropdown listing every language registered in
// src/i18n/index.ts. Persists the choice via i18next's localStorage detector
// and emits the `languageChanged` event handled in the i18n module to flip
// <html dir/lang> for RTL languages.
//
// Positioning: the menu uses `position: fixed` and is placed at
// `(triggerLeft, triggerTop - gap)`. This bypasses every ancestor's
// `overflow: hidden/auto/scroll`, stacking contexts, and CSS direction
// rules, so the dropdown is *always* fully visible regardless of where
// the trigger sits in the layout or which language is active. We measure
// the trigger with getBoundingClientRect() and update on resize/scroll.
// ---------------------------------------------------------------------------
function LanguageSwitcher() {
const { i18n, t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [menuStyle, setMenuStyle] = useState<{ left: number; bottom: number; minWidth: number } | null>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent | TouchEvent) => {
if (
triggerRef.current &&
!triggerRef.current.contains(e.target as Node) &&
!(e.target as HTMLElement).closest?.("[data-lang-menu]")
) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("touchstart", onClick, { passive: true });
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("touchstart", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Recompute the menu's fixed coordinates whenever it opens, or whenever
// the viewport changes (resize / scroll / language switch). The menu is
// anchored to the trigger's *left edge* and sits *above* the trigger.
useEffect(() => {
if (!open || !triggerRef.current) return;
const place = () => {
const r = triggerRef.current?.getBoundingClientRect();
if (!r) return;
// Anchor: align the menu's right edge with the trigger's right edge,
// then clamp to the viewport so the menu never overflows the screen.
const menuWidth = 160; // px — approx longest label "العربية" + padding
const gap = 4; // mb-1
const desiredLeft = r.right - menuWidth;
const maxLeft = window.innerWidth - menuWidth - 8;
const minLeft = 8;
const left = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
setMenuStyle({
left,
// distance from viewport bottom: viewport height − trigger top + gap
bottom: window.innerHeight - r.top + gap,
minWidth: menuWidth,
});
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
// i18n.language (singular) is the primary active language. We try an exact
// match first against SUPPORTED_LANGUAGES. If that fails (e.g. a regional
// variant like "ja-JP"), we fall back to i18n.languages (plural) which
// includes both the detected and resolved codes. NOTE: i18n.languages
// always contains the fallback language ("en"), so it must NOT be the
// primary match — otherwise "en" being first in SUPPORTED_LANGUAGES
// would always win and the switcher would never show any other language.
const current =
SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ??
SUPPORTED_LANGUAGES.find((l) => i18n.languages?.includes(l.code)) ??
SUPPORTED_LANGUAGES[0];
return (
<div>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={t("layout.language")}
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 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
data-lang-menu
aria-label="Select language"
style={{
position: "fixed",
left: menuStyle.left,
bottom: menuStyle.bottom,
minWidth: menuStyle.minWidth,
zIndex: 60,
}}
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;
return (
<li key={lang.code}>
<button
type="button"
onClick={() => {
i18n.changeLanguage(lang.code).catch(console.error);
setOpen(false);
}}
aria-current={active || undefined}
className={cn(
"w-full flex items-center gap-2 px-2.5 py-1.5 text-xs hover:bg-muted hover:text-foreground transition-colors",
active && "text-foreground",
)}
>
<span className="flex-1 text-start whitespace-nowrap">{lang.label}</span>
{active && <Check className="h-3 w-3 shrink-0" />}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}