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.
432 lines
20 KiB
TypeScript
432 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|