Merge remote-tracking branch 'origin/main' into refactor/use-ds-primitives
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # web/src/components/BottomPickSheet.tsx # web/src/components/SidebarFooter.tsx # web/src/components/ui/card.tsx # web/src/components/ui/confirm-dialog.tsx # web/src/pages/ChatPage.tsx
This commit is contained in:
@ -17,9 +17,14 @@ python -m hermes_cli.main web --no-open
|
||||
|
||||
# In another terminal, start the Vite dev server (with HMR + API proxy)
|
||||
cd web/
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the **Vite URL** printed in the terminal (usually `http://localhost:5173`). That is the live-reload UI.
|
||||
|
||||
`hermes dashboard` on port 9119 serves the **built** bundle from `hermes_cli/web_dist/`, not the Vite dev server — changes in `web/src/` will not appear there until you run `npm run build` and restart the dashboard (or use `web --no-open` + Vite as above).
|
||||
|
||||
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
|
||||
|
||||
## Build
|
||||
@ -46,3 +51,54 @@ src/
|
||||
├── main.tsx # React entry point
|
||||
└── index.css # Tailwind imports and theme variables
|
||||
```
|
||||
|
||||
## Typography & contrast rules
|
||||
|
||||
Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of.
|
||||
|
||||
### Text size floor
|
||||
|
||||
- **Minimum body size: `text-xs` (12px / 0.75rem).** Do not use arbitrary `text-[0.6rem]`, `text-[0.65rem]`, `text-[9px]`, `text-[10px]`, or `text-[11px]` on copy, hints, labels, counts, or badges. Use the standard scale: `text-xs`, `text-sm`, `text-base`.
|
||||
- Smaller sizes are only acceptable on **decorative overlays** (chart stripes, empty-state icons) — never on text the user is meant to read.
|
||||
|
||||
### Opacity floor on text
|
||||
|
||||
- **Never apply opacity below 0.7 to text.** No `opacity-30`, `opacity-50`, `opacity-60` on `<span>`s, `<p>`s, labels, etc.
|
||||
- **Do not stack opacity tokens.** Patterns like `text-muted-foreground/60`, `text-midground/70`, `text-foreground/50` create unpredictable WCAG failures because the parent token already has alpha.
|
||||
- Use the **semantic text tokens** from `@nous-research/ui`'s `globals.css`:
|
||||
- `text-text-primary` — default body text.
|
||||
- `text-text-secondary` — subtitles, meta, inactive nav.
|
||||
- `text-text-tertiary` — small chrome labels, counts, footnotes.
|
||||
- `text-text-disabled` — disabled states.
|
||||
- `text-text-on-accent` — text on filled accent surfaces.
|
||||
|
||||
### Brand uppercase via `text-display`, not raw `uppercase`
|
||||
|
||||
- The dashboard preserves the Nous brand uppercase aesthetic, but it is **opt-in per element, not global**.
|
||||
- Apply uppercase via the DS utility `text-display` on **brand chrome only** — page titles, nav section headings, badges, brand wordmark. DS components (`Button`, `Badge`, `Tabs`, `Segmented`, etc.) already self-apply `text-display`.
|
||||
- **Do not introduce new `uppercase`** (the literal Tailwind class) in `hermes-agent/web/src`. Prefer `text-display` for new brand chrome. Legacy `uppercase` call sites (e.g. `components/ui/label.tsx`, `card.tsx`) remain until migrated.
|
||||
- The app shell no longer forces uppercase globally, so blanket `normal-case` opt-outs are unnecessary. Use `normal-case` only where a DS component applies `text-display` but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) **or** fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”).
|
||||
|
||||
### Fonts
|
||||
|
||||
Typography is **opt-in per surface**, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set.
|
||||
|
||||
| Tier | Classes | Use for |
|
||||
|------|---------|---------|
|
||||
| Brand chrome | `font-mondwest text-display` (or `themedChrome`) | Sidebar nav, card section headers (`CardTitle`), Segmented filter buttons, filter panel headings |
|
||||
| Themed body | `font-mondwest normal-case` (or `themedBody`) | Card content (`Card`, `CardDescription`), session/platform rows, analytics tables — **scoped to the component** |
|
||||
| Page chrome | `font-expanded` | Page header h1 (`PageHeaderProvider`) — sentence case, not `text-display` |
|
||||
| Wordmark | `Typography` + size/tracking only | Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no `text-display` |
|
||||
| Technical | `font-mono-ui` / `font-mono` / `font-courier` | Model slugs, env keys, schedules, YAML, repo URLs |
|
||||
|
||||
- Do **not** put `themedBody` or `themedFont` on `<main>`, `App`, or other layout wrappers — it overrides component-scoped styles.
|
||||
- **`Card`** applies `themedBody`; **`CardTitle`** uses `text-display` (uppercase chrome); **`CardDescription`** uses `themedBody`.
|
||||
- **`NouiTypography`** defaults to `font-sans` unless a font prop is passed.
|
||||
- Do **not** use raw `font-sans` or `font-display` (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate.
|
||||
|
||||
### Color tokens
|
||||
|
||||
- Prefer **semantic tokens** (`text-text-*`, `bg-card`, `border-border`, `text-foreground`, `text-destructive`, `text-success`, `text-warning`) over raw layer references (`text-midground`, `text-foreground`).
|
||||
- `text-muted-foreground` is now wired to `--color-text-secondary`, so existing call sites stay correct, but new code should prefer the semantic name.
|
||||
- When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at `≥ 0.7` for any text.
|
||||
|
||||
|
||||
2849
web/package-lock.json
generated
2849
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "^0.14.2",
|
||||
"@nous-research/ui": "0.18.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
509
web/src/App.tsx
509
web/src/App.tsx
@ -2,10 +2,12 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
@ -31,6 +33,8 @@ import {
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Package,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Puzzle,
|
||||
RotateCw,
|
||||
Settings,
|
||||
@ -44,14 +48,16 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
@ -76,6 +82,7 @@ import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
import { api } from "@/lib/api";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
@ -305,6 +312,8 @@ function buildRoutes(
|
||||
return routes;
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "hermes-sidebar-collapsed";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
@ -312,6 +321,27 @@ export default function App() {
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
||||
} catch { /* localStorage may be unavailable in private browsing */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const isMobile = useBelowBreakpoint(1024);
|
||||
const isDesktopCollapsed = collapsed && !isMobile;
|
||||
const tooltipWarmRef = useRef(0);
|
||||
const sidebarStatus = useSidebarStatus();
|
||||
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||
const isChatRoute = normalizedPath === "/chat";
|
||||
@ -326,7 +356,9 @@ export default function App() {
|
||||
api
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
|
||||
const dash = (cfg?.dashboard ?? {}) as {
|
||||
show_token_analytics?: unknown;
|
||||
};
|
||||
setShowTokenAnalytics(dash.show_token_analytics === true);
|
||||
})
|
||||
.catch(() => setShowTokenAnalytics(false));
|
||||
@ -366,7 +398,9 @@ export default function App() {
|
||||
const base = embeddedChat
|
||||
? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST]
|
||||
: BUILTIN_NAV_REST;
|
||||
return showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics");
|
||||
return showTokenAnalytics
|
||||
? base
|
||||
: base.filter((n) => n.path !== "/analytics");
|
||||
}, [embeddedChat, showTokenAnalytics]);
|
||||
|
||||
const sidebarNav = useMemo(
|
||||
@ -416,7 +450,7 @@ export default function App() {
|
||||
return (
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
|
||||
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
|
||||
>
|
||||
<SelectionSwitcher />
|
||||
<Backdrop />
|
||||
@ -442,7 +476,7 @@ export default function App() {
|
||||
aria-label={t.app.openNavigation}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="app-sidebar"
|
||||
className="text-midground/70 hover:text-midground"
|
||||
className="text-text-secondary hover:text-midground"
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
@ -478,9 +512,11 @@ export default function App() {
|
||||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||
"border-r border-current/20",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"transition-[transform] duration-200 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden",
|
||||
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
|
||||
collapsed && "lg:w-14",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-sidebar-background)",
|
||||
@ -490,15 +526,21 @@ export default function App() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 px-4",
|
||||
"flex h-14 shrink-0 items-center gap-2",
|
||||
"border-b border-current/20",
|
||||
collapsed ? "lg:justify-center lg:px-0" : "px-4 justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="header-left" />
|
||||
|
||||
<Typography
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
@ -512,10 +554,26 @@ export default function App() {
|
||||
size="icon"
|
||||
onClick={closeMobile}
|
||||
aria-label={t.app.closeNavigation}
|
||||
className="lg:hidden text-midground/70 hover:text-midground"
|
||||
className="lg:hidden text-text-secondary hover:text-midground"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={toggleCollapsed}
|
||||
aria-label={
|
||||
collapsed ? t.common.expand : t.common.collapse
|
||||
}
|
||||
className="hidden lg:flex text-text-secondary hover:text-midground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
@ -526,9 +584,11 @@ export default function App() {
|
||||
{sidebarNav.coreItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
collapsed={isDesktopCollapsed}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -542,7 +602,8 @@ export default function App() {
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-2.5 pb-1",
|
||||
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
id="hermes-sidebar-plugin-nav-heading"
|
||||
>
|
||||
@ -553,9 +614,11 @@ export default function App() {
|
||||
{sidebarNav.pluginItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
collapsed={isDesktopCollapsed}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -563,23 +626,58 @@ export default function App() {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<SidebarSystemActions onNavigate={closeMobile} />
|
||||
<SidebarSystemActions
|
||||
collapsed={isDesktopCollapsed}
|
||||
onNavigate={closeMobile}
|
||||
status={sidebarStatus}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"px-3 py-2",
|
||||
"border-t border-current/20",
|
||||
isDesktopCollapsed
|
||||
? "lg:flex-col lg:items-start lg:gap-3 lg:py-3"
|
||||
: "justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2",
|
||||
isDesktopCollapsed && "lg:flex-col lg:items-start",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher dropUp />
|
||||
<LanguageSwitcher dropUp />
|
||||
|
||||
<SidebarIconWithTooltip
|
||||
collapsed={isDesktopCollapsed}
|
||||
label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
>
|
||||
<ThemeSwitcher collapsed={isDesktopCollapsed} dropUp />
|
||||
</SidebarIconWithTooltip>
|
||||
|
||||
<SidebarIconWithTooltip
|
||||
collapsed={isDesktopCollapsed}
|
||||
label={t.language.switchTo}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
>
|
||||
<LanguageSwitcher collapsed={isDesktopCollapsed} dropUp />
|
||||
</SidebarIconWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarFooter />
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<AuthWidget />
|
||||
<SidebarFooter status={sidebarStatus} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
||||
@ -654,27 +752,44 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
function SidebarNavLink({
|
||||
closeMobile,
|
||||
collapsed,
|
||||
item,
|
||||
tooltipWarmRef,
|
||||
t,
|
||||
}: SidebarNavLinkProps) {
|
||||
const { path, label, labelKey, icon: Icon } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const navLabel = labelKey
|
||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||
: label;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
aria-label={collapsed ? navLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"group/nav relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
||||
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive ? "text-midground" : "opacity-60 hover:opacity-100",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "text-text-secondary hover:text-midground",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
@ -684,11 +799,19 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{navLabel}</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"truncate transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-0" : "lg:opacity-100",
|
||||
)}
|
||||
>
|
||||
{navLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/nav:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
@ -701,11 +824,20 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
function SidebarSystemActions({
|
||||
collapsed,
|
||||
onNavigate,
|
||||
status,
|
||||
tooltipWarmRef,
|
||||
}: SidebarSystemActionsProps) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
@ -746,76 +878,249 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-0.5 pb-0.5",
|
||||
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
{t.app.system}
|
||||
</span>
|
||||
|
||||
<SidebarStatusStrip />
|
||||
<div className={cn(collapsed && "lg:hidden")}>
|
||||
<SidebarStatusStrip status={status} />
|
||||
</div>
|
||||
|
||||
<GatewayDot collapsed={collapsed} status={status} tooltipWarmRef={tooltipWarmRef} />
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
|
||||
const isPending = pendingAction === action;
|
||||
const isActionRunning =
|
||||
activeAction === action && isRunning && !isPending;
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
const disabled = isBusy && !busy;
|
||||
|
||||
return (
|
||||
<li key={action}>
|
||||
<ListItem
|
||||
onClick={() => handleClick(action)}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
active={busy}
|
||||
className={cn(
|
||||
"gap-3 px-5 py-1.5 whitespace-nowrap",
|
||||
"font-mondwest text-[0.75rem] tracking-[0.1em]",
|
||||
"transition-opacity",
|
||||
busy
|
||||
? "text-midground opacity-100"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
"disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : isActionRunning && spin ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => (
|
||||
<SystemActionButton
|
||||
key={item.action}
|
||||
collapsed={collapsed}
|
||||
disabled={isBusy && !(pendingAction === item.action || (activeAction === item.action && isRunning))}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
isPending={pendingAction === item.action}
|
||||
isRunning={activeAction === item.action && isRunning && pendingAction !== item.action}
|
||||
item={item}
|
||||
onClick={() => handleClick(item.action)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemActionButton({
|
||||
collapsed,
|
||||
disabled,
|
||||
isPending,
|
||||
isRunning: isActionRunning,
|
||||
item,
|
||||
onClick,
|
||||
tooltipWarmRef,
|
||||
}: SystemActionButtonProps) {
|
||||
const { icon: Icon, label, runningLabel, spin } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
aria-label={collapsed ? displayLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/action relative flex w-full items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
busy
|
||||
? "text-midground"
|
||||
: "text-text-secondary hover:text-midground",
|
||||
"disabled:text-text-disabled disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : isActionRunning && spin ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className={cn(
|
||||
"truncate transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-0" : "lg:opacity-100",
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/action:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarIconWithTooltip({
|
||||
children,
|
||||
collapsed,
|
||||
label,
|
||||
tooltipWarmRef,
|
||||
}: SidebarIconWithTooltipProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-fit",
|
||||
collapsed && "group/icon",
|
||||
)}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{children}
|
||||
|
||||
{collapsed && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 inset-x-[-0.375rem] bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/icon:opacity-5 hidden lg:block"
|
||||
/>
|
||||
)}
|
||||
|
||||
{collapsed && hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
||||
const { t } = useI18n();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const toneToColor: Record<string, string> = {
|
||||
"text-success": "bg-success",
|
||||
"text-warning": "bg-warning",
|
||||
"text-destructive": "bg-destructive",
|
||||
"text-muted-foreground": "bg-muted-foreground",
|
||||
};
|
||||
|
||||
let color: string;
|
||||
let label: string;
|
||||
|
||||
if (!status) {
|
||||
color = "bg-midground/20";
|
||||
label = t.status.gateway;
|
||||
} else {
|
||||
const gw = gatewayLine(status, t);
|
||||
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
|
||||
label = `${t.status.gateway} ${gw.label}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
|
||||
)}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-1.5 w-1.5 rounded-full", color)}
|
||||
/>
|
||||
|
||||
{hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const sidebar = document.getElementById("app-sidebar");
|
||||
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
|
||||
|
||||
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
return () => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
};
|
||||
}, [warmRef]);
|
||||
|
||||
return createPortal(
|
||||
<span
|
||||
className={cn(
|
||||
"fixed z-[100] pointer-events-none",
|
||||
"px-2 py-1",
|
||||
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top + rect.height / 2,
|
||||
left: sidebarRight + 8,
|
||||
transform: "translateY(-50%)",
|
||||
opacity: isWarm ? 1 : undefined,
|
||||
animation: isWarm ? "none" : "sidebar-tooltip-in 120ms ease-out",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipWarmRef = React.RefObject<number>;
|
||||
|
||||
interface GatewayDotProps {
|
||||
collapsed: boolean;
|
||||
status: StatusResponse | null;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
@ -823,10 +1128,42 @@ interface NavItem {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarIconWithTooltipProps {
|
||||
children: ReactNode;
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarNavLinkProps {
|
||||
closeMobile: () => void;
|
||||
collapsed: boolean;
|
||||
item: NavItem;
|
||||
t: Translations;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarSystemActionsProps {
|
||||
collapsed: boolean;
|
||||
onNavigate: () => void;
|
||||
status: StatusResponse | null;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarTooltipProps {
|
||||
anchor: HTMLElement;
|
||||
label: string;
|
||||
warmRef?: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SystemActionButtonProps {
|
||||
collapsed: boolean;
|
||||
disabled: boolean;
|
||||
isPending: boolean;
|
||||
isRunning: boolean;
|
||||
item: SystemActionItem;
|
||||
onClick: () => void;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SystemActionItem {
|
||||
|
||||
150
web/src/components/AuthWidget.tsx
Normal file
150
web/src/components/AuthWidget.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* AuthWidget — sidebar "Logged in as …" affordance for the dashboard
|
||||
* OAuth gate (Phase 7 of .hermes/plans/2026-05-21-dashboard-oauth-auth.md).
|
||||
*
|
||||
* Renders nothing in loopback / --insecure mode. In gated mode, fetches
|
||||
* /api/auth/me on mount and surfaces:
|
||||
*
|
||||
* - the user_id (truncated to 14 chars + ellipsis) since the Nous Portal
|
||||
* contract V1 doesn't emit email/display_name claims (Contract Anchor
|
||||
* C4 in the plan; the API responds with empty strings for those
|
||||
* fields, so we use user_id as the display value)
|
||||
* - the provider's display_name (looked up from /api/auth/providers,
|
||||
* defaults to the bare provider key)
|
||||
* - a logout button that POSTs /auth/logout and full-page-navigates to
|
||||
* /login (the dashboard becomes inaccessible again)
|
||||
*
|
||||
* Failure modes:
|
||||
* - 401 from /api/auth/me means we're not gated (or the gate is on but
|
||||
* we have no cookie — in that case the gate's middleware would have
|
||||
* redirected us before App.tsx renders, so we won't see this). The
|
||||
* widget renders nothing.
|
||||
* - Network error: shows a minimal "auth status unavailable" message
|
||||
* so the user knows the widget tried.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AuthMeResponse } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
interface AuthWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Truncate ``user_id`` to fit a small UI without revealing the full
|
||||
* opaque identifier. 14 chars is enough to disambiguate users in a
|
||||
* small org and short enough to fit a single sidebar row. */
|
||||
function truncateUserId(id: string): string {
|
||||
if (id.length <= 14) return id;
|
||||
return `${id.slice(0, 14)}…`;
|
||||
}
|
||||
|
||||
export function AuthWidget({ className }: AuthWidgetProps) {
|
||||
const [me, setMe] = useState<AuthMeResponse | null>(null);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAuthMe()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setMe(data);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
// 401 from /api/auth/me means the gate isn't engaged in this
|
||||
// process (loopback mode) — render nothing. fetchJSON throws an
|
||||
// Error with the status code as a prefix; the global 401
|
||||
// handler only redirects on the structured envelope, so a plain
|
||||
// 401 from /api/auth/me with no envelope bubbles up here.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.startsWith("401:") || msg.startsWith("403:")) {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
setError("auth status unavailable");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
// Loading. Reserve the row height so the sidebar doesn't flicker
|
||||
// when the data arrives.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
|
||||
className,
|
||||
)}
|
||||
aria-busy="true"
|
||||
>
|
||||
…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void api.logout();
|
||||
};
|
||||
|
||||
// Prefer display_name → email → truncated user_id. Contract V1 only
|
||||
// populates user_id; the fallthroughs are forward-compat for a future
|
||||
// Portal that adds a userinfo endpoint (OQ-C1 in the plan).
|
||||
const label = me.display_name || me.email || truncateUserId(me.user_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"px-5 py-2",
|
||||
"border-t border-current/10",
|
||||
"text-[0.65rem] tracking-[0.05em]",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Logged in as ${label}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground/70">
|
||||
via {me.provider}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-1.5 text-muted-foreground/70",
|
||||
"transition-colors hover:bg-current/10 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
|
||||
)}
|
||||
aria-label="Log out"
|
||||
title="Log out"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,8 +11,8 @@ function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; sch
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
|
||||
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
|
||||
{keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
|
||||
{description && <span className="text-xs text-text-secondary">{description}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ import { Card } from "@nous-research/ui/ui/components/card";
|
||||
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||
import { HERMES_BASE_PATH } from "@/lib/api";
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
|
||||
@ -152,36 +152,44 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
// JSON-RPC sidecar so the sidebar matches its documented best-effort
|
||||
// UX and the user always has a reconnect affordance.
|
||||
useEffect(() => {
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
|
||||
if (!token || !channel) {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ token, channel });
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
|
||||
);
|
||||
|
||||
// `unmounting` suppresses the banner during cleanup — `ws.close()`
|
||||
// from the effect's return fires a close event with code 1005 that
|
||||
// would otherwise look like an unexpected drop.
|
||||
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
|
||||
// In loopback mode the legacy ?token=<session> path is fine; in gated
|
||||
// mode we have to mint a single-use ticket from the cookie. The IIFE
|
||||
// keeps the outer effect synchronous so its ``return cleanup`` stays
|
||||
// at the top level; the local ``ws`` is hoisted to a closed-over
|
||||
// binding the cleanup reads via ``wsRef``.
|
||||
let unmounting = false;
|
||||
const surface = (msg: string) => !unmounting && setError(msg);
|
||||
|
||||
ws.addEventListener("error", () => surface(DISCONNECTED));
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
if (ev.code === 4401 || ev.code === 4403) {
|
||||
surface(`events feed rejected (${ev.code}) — reload the page`);
|
||||
} else if (ev.code !== 1000) {
|
||||
surface(DISCONNECTED);
|
||||
let ws: WebSocket | null = null;
|
||||
void (async () => {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
if (!authValue || unmounting) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ [authName]: authValue, channel });
|
||||
ws = new WebSocket(
|
||||
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
|
||||
);
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
// `unmounting` suppresses the banner during cleanup — `ws.close()`
|
||||
// from the effect's return fires a close event with code 1005 that
|
||||
// would otherwise look like an unexpected drop.
|
||||
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
|
||||
const surface = (msg: string) => !unmounting && setError(msg);
|
||||
|
||||
ws.addEventListener("error", () => surface(DISCONNECTED));
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
if (ev.code === 4401 || ev.code === 4403) {
|
||||
surface(`events feed rejected (${ev.code}) — reload the page`);
|
||||
} else if (ev.code !== 1000) {
|
||||
surface(DISCONNECTED);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
let frame: RpcEnvelope;
|
||||
|
||||
try {
|
||||
@ -265,11 +273,12 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
ws.close();
|
||||
ws?.close();
|
||||
};
|
||||
}, [channel, version]);
|
||||
|
||||
@ -304,13 +313,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 normal-case lg:w-80",
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<div className="text-display text-xs tracking-wider text-text-tertiary">
|
||||
model
|
||||
</div>
|
||||
|
||||
@ -321,7 +330,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
onClick={() => setModelOpen(true)}
|
||||
suffix={
|
||||
canPickModel ? (
|
||||
<ChevronDown className="opacity-60" />
|
||||
<ChevronDown className="text-text-secondary" />
|
||||
) : undefined
|
||||
}
|
||||
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
|
||||
@ -357,13 +366,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
)}
|
||||
|
||||
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
|
||||
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary">
|
||||
tools
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-1.5">
|
||||
{tools.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
<div className="px-2 py-4 text-center text-xs text-text-secondary">
|
||||
no tool calls yet
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
import { LOCALE_META } from "@/i18n";
|
||||
@ -25,10 +27,11 @@ import { cn } from "@/lib/utils";
|
||||
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
|
||||
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
|
||||
*/
|
||||
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
@ -41,15 +44,14 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
if (dropdownRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
@ -69,12 +71,15 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
aria-label={t.language.switchTo}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
|
||||
className={cn(
|
||||
"px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
|
||||
collapsed && "hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{locale === "en" ? "EN" : current.name}
|
||||
</Typography>
|
||||
@ -99,23 +104,33 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{open && !useMobileSheet && (() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
style={
|
||||
dropUp && rect
|
||||
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -134,10 +149,12 @@ function LanguageSwitcherOptions({
|
||||
return (
|
||||
<button
|
||||
aria-selected={selected}
|
||||
className={
|
||||
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
||||
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
|
||||
"font-mondwest text-display text-xs tracking-[0.08em]",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
selected ? "font-semibold text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setLocale(code);
|
||||
@ -148,7 +165,7 @@ function LanguageSwitcherOptions({
|
||||
>
|
||||
<span className="truncate">{meta.name}</span>
|
||||
|
||||
{selected && <span className="ml-auto text-xs">✓</span>}
|
||||
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -164,5 +181,6 @@ interface LanguageSwitcherOptionsProps {
|
||||
}
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
||||
@ -324,11 +324,24 @@ function InlineContent({
|
||||
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||
</em>
|
||||
);
|
||||
case "link":
|
||||
case "link": {
|
||||
// Security: only render http(s)/mailto links. Other schemes
|
||||
// (javascript:, data:, vbscript:) are dropped to plain text so a
|
||||
// crafted link in agent/message content can't execute on click.
|
||||
const href = node.href.trim();
|
||||
if (!/^(https?:|mailto:)/i.test(href)) {
|
||||
return (
|
||||
<HighlightedText
|
||||
key={i}
|
||||
text={node.text}
|
||||
terms={highlightTerms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={node.href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
|
||||
@ -336,6 +349,7 @@ function InlineContent({
|
||||
{node.text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
case "br":
|
||||
return <br key={i} />;
|
||||
}
|
||||
|
||||
@ -60,11 +60,11 @@ export function ModelInfoCard({
|
||||
{formatTokenCount(info.effective_context_length)}
|
||||
</span>
|
||||
{info.config_context_length > 0 ? (
|
||||
<span className="text-amber-500/80 text-[10px]">
|
||||
<span className="text-amber-500 text-xs">
|
||||
(override — auto: {formatTokenCount(info.auto_context_length)})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-[10px]">
|
||||
<span className="text-text-tertiary text-xs">
|
||||
auto-detected
|
||||
</span>
|
||||
)}
|
||||
@ -86,22 +86,22 @@ export function ModelInfoCard({
|
||||
{hasCaps && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{caps.supports_tools && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||
</span>
|
||||
)}
|
||||
{caps.supports_vision && (
|
||||
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
<Eye className="h-2.5 w-2.5" /> Vision
|
||||
</span>
|
||||
)}
|
||||
{caps.supports_reasoning && (
|
||||
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
|
||||
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
<Brain className="h-2.5 w-2.5" /> Reasoning
|
||||
</span>
|
||||
)}
|
||||
{caps.model_family && (
|
||||
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-xs font-medium text-text-secondary">
|
||||
{caps.model_family}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -8,6 +8,7 @@ import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Two-stage model picker modal.
|
||||
@ -212,7 +213,7 @@ export function ModelPickerDialog(props: Props) {
|
||||
aria-modal="true"
|
||||
aria-labelledby="model-picker-title"
|
||||
>
|
||||
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
||||
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
@ -226,7 +227,7 @@ export function ModelPickerDialog(props: Props) {
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="model-picker-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
@ -295,7 +296,7 @@ export function ModelPickerDialog(props: Props) {
|
||||
/>
|
||||
|
||||
<Label
|
||||
className="font-sans normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
|
||||
className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
|
||||
htmlFor="model-picker-persist-global"
|
||||
>
|
||||
Persist globally (otherwise this session only)
|
||||
@ -375,7 +376,7 @@ function ProviderColumn({
|
||||
<span className="font-medium truncate">{p.name}</span>
|
||||
{p.is_current && <CurrentTag />}
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
|
||||
<div className="text-xs text-text-secondary font-mono truncate">
|
||||
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
||||
</div>
|
||||
</div>
|
||||
@ -462,7 +463,7 @@ function ModelColumn({
|
||||
|
||||
function CurrentTag() {
|
||||
return (
|
||||
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
|
||||
<span className="text-display text-xs tracking-wider text-primary shrink-0">
|
||||
current
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
provider: OAuthProvider;
|
||||
@ -169,7 +170,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-modal-title"
|
||||
>
|
||||
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
|
||||
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
||||
@ -4,9 +4,7 @@ import {
|
||||
ShieldOff,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Terminal,
|
||||
LogIn,
|
||||
} from "lucide-react";
|
||||
import { api, type OAuthProvider } from "@/lib/api";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
@ -105,13 +103,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
prefix={loading ? <Spinner /> : <RefreshCw />}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
{t.common.refresh}
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
@ -154,46 +153,57 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
<span className="font-medium text-sm">{p.name}</span>
|
||||
<Badge
|
||||
tone="outline"
|
||||
className="text-[11px] uppercase tracking-wide"
|
||||
className="text-xs tracking-wide"
|
||||
>
|
||||
{t.oauth.flowLabels[p.flow]}
|
||||
</Badge>
|
||||
{p.status.logged_in && (
|
||||
<Badge tone="success" className="text-[11px]">
|
||||
<Badge tone="success" className="text-xs">
|
||||
{t.oauth.connected}
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel === "expired" && (
|
||||
<Badge tone="destructive" className="text-[11px]">
|
||||
<Badge tone="destructive" className="text-xs">
|
||||
{t.oauth.expired}
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel && expiresLabel !== "expired" && (
|
||||
<Badge tone="outline" className="text-[11px]">
|
||||
<Badge tone="outline" className="text-xs">
|
||||
{expiresLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{p.status.logged_in && p.status.token_preview && (
|
||||
<code className="text-xs font-mono-ui truncate">
|
||||
<span className="opacity-50">token </span>
|
||||
<span className="truncate text-xs font-mono-ui text-text-secondary">
|
||||
<span className="text-text-tertiary">token </span>
|
||||
{p.status.token_preview}
|
||||
{p.status.source_label && (
|
||||
<span className="opacity-40">
|
||||
<span className="text-text-tertiary">
|
||||
{" "}
|
||||
· {p.status.source_label}
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
{t.oauth.notConnected.split("{command}")[0]}
|
||||
<code className="text-foreground bg-secondary/40 px-1">
|
||||
{p.cli_command}
|
||||
</code>
|
||||
{t.oauth.notConnected.split("{command}")[1]}
|
||||
</span>
|
||||
<>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t.oauth.notConnected.split("{command}")[0].trimEnd()}
|
||||
{t.oauth.notConnected.split("{command}")[1] ?? ""}
|
||||
</span>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<code className="font-courier truncate text-xs opacity-60">
|
||||
{p.cli_command}
|
||||
</code>
|
||||
|
||||
<CopyButton
|
||||
text={p.cli_command}
|
||||
label={t.oauth.cli}
|
||||
copiedLabel={t.oauth.copied}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{p.status.error && (
|
||||
<span className="text-xs text-destructive">
|
||||
@ -220,32 +230,26 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
{!p.status.logged_in && p.flow !== "external" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => setLoginFor(p)}
|
||||
prefix={<LogIn />}
|
||||
>
|
||||
{t.oauth.login}
|
||||
</Button>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
<CopyButton
|
||||
text={p.cli_command}
|
||||
label={t.oauth.cli}
|
||||
copiedLabel={t.oauth.copied}
|
||||
/>
|
||||
)}
|
||||
{p.status.logged_in && p.flow !== "external" && (
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
onClick={() => setDisconnectTarget(p)}
|
||||
disabled={isBusy}
|
||||
prefix={isBusy ? <Spinner /> : <LogOut />}
|
||||
prefix={isBusy ? <Spinner /> : undefined}
|
||||
>
|
||||
{t.oauth.disconnect}
|
||||
</Button>
|
||||
)}
|
||||
{p.status.logged_in && p.flow === "external" && (
|
||||
<span className="text-[11px] text-muted-foreground italic px-2">
|
||||
<span className="text-xs text-text-tertiary italic px-2">
|
||||
<Terminal className="h-3 w-3 inline mr-0.5" />
|
||||
{t.oauth.managedExternally}
|
||||
</span>
|
||||
|
||||
@ -57,18 +57,18 @@ export function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium capitalize truncate">
|
||||
<span className="font-mondwest normal-case text-sm font-medium capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{info.error_message && (
|
||||
<span className="text-xs text-destructive">
|
||||
<span className="font-mondwest normal-case text-xs text-destructive">
|
||||
{info.error_message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{info.updated_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-mondwest normal-case text-xs text-muted-foreground">
|
||||
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function SidebarFooter() {
|
||||
const status = useSidebarStatus();
|
||||
export function SidebarFooter({ status }: SidebarFooterProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
@ -16,8 +15,7 @@ export function SidebarFooter() {
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
mondwest
|
||||
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70 lowercase"
|
||||
className="font-mono-ui text-xs tabular-nums tracking-[0.08em] text-text-tertiary lowercase"
|
||||
>
|
||||
{status?.version != null ? `v${status.version}` : "—"}
|
||||
</Typography>
|
||||
@ -27,7 +25,7 @@ export function SidebarFooter() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"font-mondwest text-[0.65rem] tracking-[0.15em] text-midground",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-midground",
|
||||
"transition-opacity hover:opacity-90",
|
||||
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
|
||||
)}
|
||||
@ -38,3 +36,7 @@ export function SidebarFooter() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarFooterProps {
|
||||
status: StatusResponse | null;
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
|
||||
export function SidebarStatusStrip() {
|
||||
const status = useSidebarStatus();
|
||||
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (status === null) {
|
||||
@ -27,21 +25,21 @@ export function SidebarStatusStrip() {
|
||||
className={cn(
|
||||
"block text-left",
|
||||
"px-5 pb-2 pt-0.5",
|
||||
"text-muted-foreground/70",
|
||||
"transition-colors hover:text-muted-foreground/90",
|
||||
"text-text-secondary",
|
||||
"transition-colors hover:text-midground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
|
||||
"focus-visible:ring-inset",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 font-mondwest text-[0.55rem] leading-snug tracking-[0.12em]">
|
||||
<div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
|
||||
<p className="break-words">
|
||||
<span className="text-muted-foreground/50">{gatewayStatusLabel}</span>{" "}
|
||||
<span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
|
||||
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
|
||||
</p>
|
||||
|
||||
<p className="break-words">
|
||||
<span className="text-muted-foreground/50">{activeSessionsLabel}</span>{" "}
|
||||
<span className="tabular-nums text-muted-foreground/70">
|
||||
<span className="text-text-tertiary">{activeSessionsLabel}</span>{" "}
|
||||
<span className="tabular-nums text-text-secondary">
|
||||
{status.active_sessions}
|
||||
</span>
|
||||
</p>
|
||||
@ -50,7 +48,7 @@ export function SidebarStatusStrip() {
|
||||
);
|
||||
}
|
||||
|
||||
function gatewayLine(
|
||||
export function gatewayLine(
|
||||
status: StatusResponse,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): { label: string; tone: string } {
|
||||
@ -68,3 +66,7 @@ function gatewayLine(
|
||||
? { label: g.running, tone: "text-success" }
|
||||
: { label: g.off, tone: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
interface SidebarStatusStripProps {
|
||||
status: StatusResponse | null;
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
|
||||
</span>
|
||||
|
||||
{it.meta && (
|
||||
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
|
||||
<span className="text-xs text-text-tertiary truncate ml-auto">
|
||||
{it.meta}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import type { DashboardTheme, ThemeListEntry } from "@/themes";
|
||||
@ -23,11 +24,12 @@ import { cn } from "@/lib/utils";
|
||||
* bottom sheet portaled to `document.body` so the picker is not clipped by
|
||||
* the sidebar (same idea as a responsive Drawer).
|
||||
*/
|
||||
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
@ -45,12 +47,10 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
close();
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (wrapperRef.current?.contains(target)) return;
|
||||
if (dropdownRef.current?.contains(target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
@ -64,9 +64,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<Button
|
||||
ghost
|
||||
size={collapsed ? "icon" : undefined}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
|
||||
title={t.theme?.switchTheme ?? "Switch theme"}
|
||||
className={cn(
|
||||
collapsed
|
||||
? "text-text-secondary hover:text-foreground hover:bg-transparent"
|
||||
: "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
|
||||
)}
|
||||
title={`${t.theme?.switchTheme ?? "Switch theme"}: ${label}`}
|
||||
aria-label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
@ -74,12 +79,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@ -101,34 +108,44 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||
>
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
{open && !useMobileSheet && (() => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
style={
|
||||
dropUp && rect
|
||||
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -166,12 +183,12 @@ function ThemeSwitcherOptions({
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="truncate text-[0.75rem] tracking-wide uppercase"
|
||||
className="truncate text-display text-xs tracking-wide"
|
||||
>
|
||||
{th.label}
|
||||
</Typography>
|
||||
{th.description && (
|
||||
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||
<Typography className="truncate text-xs tracking-normal text-text-tertiary">
|
||||
{th.description}
|
||||
</Typography>
|
||||
)}
|
||||
@ -221,5 +238,6 @@ interface ThemeSwitcherOptionsProps {
|
||||
}
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
||||
@ -104,7 +104,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
|
||||
|
||||
<span className="font-mono font-medium shrink-0">{tool.name}</span>
|
||||
|
||||
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
|
||||
<span className="font-mono text-text-secondary truncate min-w-0 flex-1">
|
||||
{tool.context ?? ""}
|
||||
</span>
|
||||
|
||||
@ -128,7 +128,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
|
||||
)}
|
||||
|
||||
{elapsed && (
|
||||
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
|
||||
<span className="font-mono text-xs text-text-tertiary tabular-nums shrink-0">
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
@ -186,8 +186,8 @@ function Section({
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
|
||||
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
|
||||
className={`text-display font-mondwest tracking-wider text-xs shrink-0 w-20 pt-0.5 ${
|
||||
tone === "error" ? "text-destructive" : "text-text-tertiary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@ -224,5 +224,5 @@ function diffLineClass(line: string): string {
|
||||
if (line.startsWith("-") && !line.startsWith("---"))
|
||||
return "text-destructive";
|
||||
if (line.startsWith("@@")) return "text-primary";
|
||||
return "text-muted-foreground/80";
|
||||
return "text-text-secondary";
|
||||
}
|
||||
|
||||
44
web/src/hooks/useModalBehavior.ts
Normal file
44
web/src/hooks/useModalBehavior.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook that adds standard modal behaviors when `open` is true:
|
||||
* - Escape key calls `onClose`
|
||||
* - Body scroll is locked
|
||||
* - Focus is restored to the previously focused element on close
|
||||
*
|
||||
* Returns a ref to attach to the modal container (for optional future focus trapping).
|
||||
*/
|
||||
export function useModalBehavior({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const prevActive = document.activeElement as HTMLElement | null;
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
prevActive?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
@ -127,6 +127,8 @@ export const af: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessies",
|
||||
history: "Geskiedenis",
|
||||
overview: "Oorsig",
|
||||
searchPlaceholder: "Soek boodskap-inhoud...",
|
||||
noSessions: "Nog geen sessies nie",
|
||||
noMatch: "Geen sessies stem ooreen met jou soektog nie",
|
||||
@ -269,7 +271,7 @@ export const af: Translations = {
|
||||
"Ontdek, installeer, aktiveer en werk Hermes-inproppe op (`hermes plugins` ekwivalent).",
|
||||
identifierLabel: "Git-URL of owner/repo",
|
||||
inactive: "onaktief",
|
||||
installBtn: "Installeer vanaf Git",
|
||||
installBtn: "Installeer",
|
||||
installHeading: "Installeer vanaf GitHub / Git-URL",
|
||||
installHint: "Gebruik owner/repo-kortvorm of 'n volledige https:// of git@ kloon-URL.",
|
||||
memoryProviderLabel: "Geheueverskaffer",
|
||||
@ -367,6 +369,8 @@ export const af: Translations = {
|
||||
description: "Bestuur API-sleutels en geheime gestoor in",
|
||||
hideAdvanced: "Versteek Gevorderd",
|
||||
showAdvanced: "Wys Gevorderd",
|
||||
showLess: "Wys minder",
|
||||
showMore: "Wys meer",
|
||||
llmProviders: "LLM-verskaffers",
|
||||
providersConfigured: "{configured} van {total} verskaffers gekonfigureer",
|
||||
getKey: "Kry sleutel",
|
||||
@ -392,7 +396,7 @@ export const af: Translations = {
|
||||
disconnect: "Ontkoppel",
|
||||
managedExternally: "Ekstern bestuur",
|
||||
copied: "Gekopieer ✓",
|
||||
cli: "CLI",
|
||||
cli: "Kopieer",
|
||||
copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
|
||||
connect: "Koppel",
|
||||
sessionExpires: "Sessie verval oor {time}",
|
||||
@ -419,7 +423,7 @@ export const af: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Skakel oor na Engels",
|
||||
switchTo: "Verander taal",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const de: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sitzungen",
|
||||
history: "Verlauf",
|
||||
overview: "Übersicht",
|
||||
searchPlaceholder: "Nachrichteninhalt suchen...",
|
||||
noSessions: "Noch keine Sitzungen",
|
||||
noMatch: "Keine Sitzungen entsprechen deiner Suche",
|
||||
@ -269,7 +271,7 @@ export const de: Translations = {
|
||||
"Hermes-Plugins entdecken, installieren, aktivieren und aktualisieren (entspricht `hermes plugins`).",
|
||||
identifierLabel: "Git-URL oder owner/repo",
|
||||
inactive: "inaktiv",
|
||||
installBtn: "Aus Git installieren",
|
||||
installBtn: "Installieren",
|
||||
installHeading: "Aus GitHub / Git-URL installieren",
|
||||
installHint: "Verwende owner/repo-Kurzform oder eine vollständige https:// oder git@ Klon-URL.",
|
||||
memoryProviderLabel: "Speicheranbieter",
|
||||
@ -367,6 +369,8 @@ export const de: Translations = {
|
||||
description: "Verwalte API-Schlüssel und Geheimnisse, die hier gespeichert sind",
|
||||
hideAdvanced: "Erweitert ausblenden",
|
||||
showAdvanced: "Erweitert anzeigen",
|
||||
showLess: "Weniger anzeigen",
|
||||
showMore: "Mehr anzeigen",
|
||||
llmProviders: "LLM-Anbieter",
|
||||
providersConfigured: "{configured} von {total} Anbietern konfiguriert",
|
||||
getKey: "Schlüssel holen",
|
||||
@ -392,7 +396,7 @@ export const de: Translations = {
|
||||
disconnect: "Trennen",
|
||||
managedExternally: "Extern verwaltet",
|
||||
copied: "Kopiert ✓",
|
||||
cli: "CLI",
|
||||
cli: "Kopieren",
|
||||
copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
|
||||
connect: "Verbinden",
|
||||
sessionExpires: "Sitzung läuft in {time} ab",
|
||||
@ -419,7 +423,7 @@ export const de: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Zu Englisch wechseln",
|
||||
switchTo: "Sprache wechseln",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const en: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
history: "History",
|
||||
overview: "Overview",
|
||||
searchPlaceholder: "Search message content...",
|
||||
noSessions: "No sessions yet",
|
||||
noMatch: "No sessions match your search",
|
||||
@ -269,7 +271,7 @@ export const en: Translations = {
|
||||
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
|
||||
identifierLabel: "Git URL or owner/repo",
|
||||
inactive: "inactive",
|
||||
installBtn: "Install from Git",
|
||||
installBtn: "Install",
|
||||
installHeading: "Install from GitHub / Git URL",
|
||||
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
|
||||
memoryProviderLabel: "Memory provider",
|
||||
@ -367,6 +369,8 @@ export const en: Translations = {
|
||||
description: "Manage API keys and secrets stored in",
|
||||
hideAdvanced: "Hide Advanced",
|
||||
showAdvanced: "Show Advanced",
|
||||
showLess: "Show less",
|
||||
showMore: "Show more",
|
||||
llmProviders: "LLM Providers",
|
||||
providersConfigured: "{configured} of {total} providers configured",
|
||||
getKey: "Get key",
|
||||
@ -392,7 +396,7 @@ export const en: Translations = {
|
||||
disconnect: "Disconnect",
|
||||
managedExternally: "Managed externally",
|
||||
copied: "Copied ✓",
|
||||
cli: "CLI",
|
||||
cli: "Copy",
|
||||
copyCliCommand: "Copy CLI command (for external / fallback)",
|
||||
connect: "Connect",
|
||||
sessionExpires: "Session expires in {time}",
|
||||
@ -419,7 +423,7 @@ export const en: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Switch to Chinese",
|
||||
switchTo: "Switch language",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const es: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sesiones",
|
||||
history: "Historial",
|
||||
overview: "Resumen",
|
||||
searchPlaceholder: "Buscar contenido de mensajes...",
|
||||
noSessions: "Aún no hay sesiones",
|
||||
noMatch: "Ninguna sesión coincide con tu búsqueda",
|
||||
@ -269,7 +271,7 @@ export const es: Translations = {
|
||||
"Descubre, instala, habilita y actualiza complementos de Hermes (equivalente a `hermes plugins`).",
|
||||
identifierLabel: "URL de Git u owner/repo",
|
||||
inactive: "inactivo",
|
||||
installBtn: "Instalar desde Git",
|
||||
installBtn: "Instalar",
|
||||
installHeading: "Instalar desde GitHub / URL de Git",
|
||||
installHint: "Usa la forma corta owner/repo o una URL de clonación https:// o git@ completa.",
|
||||
memoryProviderLabel: "Proveedor de memoria",
|
||||
@ -367,6 +369,8 @@ export const es: Translations = {
|
||||
description: "Gestiona claves API y secretos almacenados en",
|
||||
hideAdvanced: "Ocultar avanzado",
|
||||
showAdvanced: "Mostrar avanzado",
|
||||
showLess: "Mostrar menos",
|
||||
showMore: "Mostrar más",
|
||||
llmProviders: "Proveedores LLM",
|
||||
providersConfigured: "{configured} de {total} proveedores configurados",
|
||||
getKey: "Obtener clave",
|
||||
@ -392,7 +396,7 @@ export const es: Translations = {
|
||||
disconnect: "Desconectar",
|
||||
managedExternally: "Gestionado externamente",
|
||||
copied: "Copiado ✓",
|
||||
cli: "CLI",
|
||||
cli: "Copiar",
|
||||
copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
|
||||
connect: "Conectar",
|
||||
sessionExpires: "La sesión caduca en {time}",
|
||||
@ -419,7 +423,7 @@ export const es: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Cambiar a inglés",
|
||||
switchTo: "Cambiar idioma",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const fr: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
history: "Historique",
|
||||
overview: "Aperçu",
|
||||
searchPlaceholder: "Rechercher dans les messages...",
|
||||
noSessions: "Aucune session pour l'instant",
|
||||
noMatch: "Aucune session ne correspond à votre recherche",
|
||||
@ -269,7 +271,7 @@ export const fr: Translations = {
|
||||
"Découvrez, installez, activez et mettez à jour les plugins Hermes (parité avec `hermes plugins`).",
|
||||
identifierLabel: "URL Git ou owner/repo",
|
||||
inactive: "inactif",
|
||||
installBtn: "Installer depuis Git",
|
||||
installBtn: "Installer",
|
||||
installHeading: "Installer depuis GitHub / URL Git",
|
||||
installHint: "Utilisez le raccourci owner/repo ou une URL de clonage complète https:// ou git@.",
|
||||
memoryProviderLabel: "Fournisseur de mémoire",
|
||||
@ -367,6 +369,8 @@ export const fr: Translations = {
|
||||
description: "Gérer les clés API et les secrets stockés dans",
|
||||
hideAdvanced: "Masquer les options avancées",
|
||||
showAdvanced: "Afficher les options avancées",
|
||||
showLess: "Afficher moins",
|
||||
showMore: "Afficher plus",
|
||||
llmProviders: "Fournisseurs LLM",
|
||||
providersConfigured: "{configured} sur {total} fournisseurs configurés",
|
||||
getKey: "Obtenir la clé",
|
||||
@ -392,7 +396,7 @@ export const fr: Translations = {
|
||||
disconnect: "Déconnecter",
|
||||
managedExternally: "Géré en externe",
|
||||
copied: "Copié ✓",
|
||||
cli: "CLI",
|
||||
cli: "Copier",
|
||||
copyCliCommand: "Copier la commande CLI (pour externe / repli)",
|
||||
connect: "Connecter",
|
||||
sessionExpires: "La session expire dans {time}",
|
||||
@ -419,7 +423,7 @@ export const fr: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Passer à l'anglais",
|
||||
switchTo: "Changer de langue",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const ga: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Seisiúin",
|
||||
history: "Stair",
|
||||
overview: "Forbhreathnú",
|
||||
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
|
||||
noSessions: "Gan seisiúin go fóill",
|
||||
noMatch: "Níl seisiún ar bith ag teacht le do chuardach",
|
||||
@ -269,7 +271,7 @@ export const ga: Translations = {
|
||||
"Faigh, suiteáil, cumasaigh agus nuashonraigh plugins Hermes (paireacht le `hermes plugins`).",
|
||||
identifierLabel: "URL Git nó owner/repo",
|
||||
inactive: "neamhghníomhach",
|
||||
installBtn: "Suiteáil ó Git",
|
||||
installBtn: "Suiteáil",
|
||||
installHeading: "Suiteáil ó GitHub / URL Git",
|
||||
installHint: "Úsáid an gearrshamhail owner/repo nó URL clóin iomlán https:// nó git@.",
|
||||
memoryProviderLabel: "Soláthraí cuimhne",
|
||||
@ -367,6 +369,8 @@ export const ga: Translations = {
|
||||
description: "Bainistigh eochracha API agus rúin atá stóráilte i",
|
||||
hideAdvanced: "Folaigh Ardroghanna",
|
||||
showAdvanced: "Taispeáin Ardroghanna",
|
||||
showLess: "Taispeáin níos lú",
|
||||
showMore: "Taispeáin tuilleadh",
|
||||
llmProviders: "Soláthraithe LLM",
|
||||
providersConfigured: "{configured} as {total} soláthraí cumraithe",
|
||||
getKey: "Faigh eochair",
|
||||
@ -392,7 +396,7 @@ export const ga: Translations = {
|
||||
disconnect: "Dícheangail",
|
||||
managedExternally: "Bainistithe go seachtrach",
|
||||
copied: "Cóipeáilte ✓",
|
||||
cli: "CLI",
|
||||
cli: "Cóipeáil",
|
||||
copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)",
|
||||
connect: "Ceangail",
|
||||
sessionExpires: "Téann an seisiún as feidhm i {time}",
|
||||
@ -419,7 +423,7 @@ export const ga: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Athraigh go Béarla",
|
||||
switchTo: "Athraigh teanga",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const hu: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Munkamenetek",
|
||||
history: "Előzmények",
|
||||
overview: "Áttekintés",
|
||||
searchPlaceholder: "Keresés üzenettartalomban...",
|
||||
noSessions: "Még nincsenek munkamenetek",
|
||||
noMatch: "Nincs a keresésnek megfelelő munkamenet",
|
||||
@ -269,7 +271,7 @@ export const hu: Translations = {
|
||||
"Hermes-bővítmények felfedezése, telepítése, engedélyezése és frissítése (a `hermes plugins` paritás).",
|
||||
identifierLabel: "Git URL vagy owner/repo",
|
||||
inactive: "inaktív",
|
||||
installBtn: "Telepítés Gitből",
|
||||
installBtn: "Telepítés",
|
||||
installHeading: "Telepítés GitHubról / Git URL-ről",
|
||||
installHint: "Használjon owner/repo rövidítést vagy teljes https:// vagy git@ klónozási URL-t.",
|
||||
memoryProviderLabel: "Memória-szolgáltató",
|
||||
@ -367,6 +369,8 @@ export const hu: Translations = {
|
||||
description: "API-kulcsok és titkok kezelése a következő helyen:",
|
||||
hideAdvanced: "Speciális elrejtése",
|
||||
showAdvanced: "Speciális megjelenítése",
|
||||
showLess: "Kevesebb",
|
||||
showMore: "Több",
|
||||
llmProviders: "LLM-szolgáltatók",
|
||||
providersConfigured: "{configured} / {total} szolgáltató beállítva",
|
||||
getKey: "Kulcs lekérése",
|
||||
@ -392,7 +396,7 @@ export const hu: Translations = {
|
||||
disconnect: "Lecsatlakozás",
|
||||
managedExternally: "Külsőleg kezelt",
|
||||
copied: "Másolva ✓",
|
||||
cli: "CLI",
|
||||
cli: "Másolás",
|
||||
copyCliCommand: "CLI-parancs másolása (külső / tartalék)",
|
||||
connect: "Csatlakozás",
|
||||
sessionExpires: "A munkamenet {time} múlva lejár",
|
||||
@ -419,7 +423,7 @@ export const hu: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Váltás angolra",
|
||||
switchTo: "Nyelv váltása",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const it: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessioni",
|
||||
history: "Cronologia",
|
||||
overview: "Panoramica",
|
||||
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
|
||||
noSessions: "Nessuna sessione",
|
||||
noMatch: "Nessuna sessione corrisponde alla ricerca",
|
||||
@ -269,7 +271,7 @@ export const it: Translations = {
|
||||
"Scopri, installa, abilita e aggiorna i plugin Hermes (parità con `hermes plugins`).",
|
||||
identifierLabel: "URL Git o owner/repo",
|
||||
inactive: "inattivo",
|
||||
installBtn: "Installa da Git",
|
||||
installBtn: "Installa",
|
||||
installHeading: "Installa da GitHub / URL Git",
|
||||
installHint: "Usa la forma breve owner/repo o un URL clone https:// o git@ completo.",
|
||||
memoryProviderLabel: "Provider di memoria",
|
||||
@ -367,6 +369,8 @@ export const it: Translations = {
|
||||
description: "Gestisci chiavi API e segreti memorizzati in",
|
||||
hideAdvanced: "Nascondi avanzate",
|
||||
showAdvanced: "Mostra avanzate",
|
||||
showLess: "Mostra meno",
|
||||
showMore: "Mostra di più",
|
||||
llmProviders: "Provider LLM",
|
||||
providersConfigured: "{configured} di {total} provider configurati",
|
||||
getKey: "Ottieni chiave",
|
||||
@ -392,7 +396,7 @@ export const it: Translations = {
|
||||
disconnect: "Disconnetti",
|
||||
managedExternally: "Gestito esternamente",
|
||||
copied: "Copiato ✓",
|
||||
cli: "CLI",
|
||||
cli: "Copia",
|
||||
copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
|
||||
connect: "Connetti",
|
||||
sessionExpires: "La sessione scade tra {time}",
|
||||
@ -419,7 +423,7 @@ export const it: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Passa all'inglese",
|
||||
switchTo: "Cambia lingua",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const ja: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "セッション",
|
||||
history: "履歴",
|
||||
overview: "概要",
|
||||
searchPlaceholder: "メッセージ内容を検索...",
|
||||
noSessions: "まだセッションがありません",
|
||||
noMatch: "検索条件に一致するセッションはありません",
|
||||
@ -269,7 +271,7 @@ export const ja: Translations = {
|
||||
"Hermes プラグインを発見、インストール、有効化、更新します (`hermes plugins` 相当)。",
|
||||
identifierLabel: "Git URL または owner/repo",
|
||||
inactive: "非アクティブ",
|
||||
installBtn: "Git からインストール",
|
||||
installBtn: "インストール",
|
||||
installHeading: "GitHub / Git URL からインストール",
|
||||
installHint: "owner/repo の短縮形、または完全な https:// もしくは git@ クローン URL を使用してください。",
|
||||
memoryProviderLabel: "メモリプロバイダー",
|
||||
@ -367,6 +369,8 @@ export const ja: Translations = {
|
||||
description: "API キーとシークレットを管理します。保存先:",
|
||||
hideAdvanced: "詳細設定を隠す",
|
||||
showAdvanced: "詳細設定を表示",
|
||||
showLess: "表示を減らす",
|
||||
showMore: "もっと見る",
|
||||
llmProviders: "LLM プロバイダー",
|
||||
providersConfigured: "{configured} / {total} プロバイダーが設定済み",
|
||||
getKey: "キーを取得",
|
||||
@ -392,7 +396,7 @@ export const ja: Translations = {
|
||||
disconnect: "切断",
|
||||
managedExternally: "外部で管理",
|
||||
copied: "コピーしました ✓",
|
||||
cli: "CLI",
|
||||
cli: "コピー",
|
||||
copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
|
||||
connect: "接続",
|
||||
sessionExpires: "セッションは {time} 後に期限切れになります",
|
||||
@ -419,7 +423,7 @@ export const ja: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "英語に切り替え",
|
||||
switchTo: "言語を切り替え",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const ko: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "세션",
|
||||
history: "기록",
|
||||
overview: "개요",
|
||||
searchPlaceholder: "메시지 내용 검색...",
|
||||
noSessions: "아직 세션이 없습니다",
|
||||
noMatch: "검색과 일치하는 세션이 없습니다",
|
||||
@ -269,7 +271,7 @@ export const ko: Translations = {
|
||||
"Hermes 플러그인을 검색, 설치, 활성화 및 업데이트합니다 (`hermes plugins` 동등).",
|
||||
identifierLabel: "Git URL 또는 owner/repo",
|
||||
inactive: "비활성",
|
||||
installBtn: "Git에서 설치",
|
||||
installBtn: "설치",
|
||||
installHeading: "GitHub / Git URL에서 설치",
|
||||
installHint: "owner/repo 약어 또는 전체 https:// 또는 git@ 클론 URL을 사용하세요.",
|
||||
memoryProviderLabel: "메모리 제공자",
|
||||
@ -367,6 +369,8 @@ export const ko: Translations = {
|
||||
description: "다음 위치에 저장된 API 키와 비밀을 관리합니다",
|
||||
hideAdvanced: "고급 숨기기",
|
||||
showAdvanced: "고급 표시",
|
||||
showLess: "간략히",
|
||||
showMore: "더 보기",
|
||||
llmProviders: "LLM 제공자",
|
||||
providersConfigured: "{configured}/{total} 제공자가 구성됨",
|
||||
getKey: "키 받기",
|
||||
@ -392,7 +396,7 @@ export const ko: Translations = {
|
||||
disconnect: "연결 해제",
|
||||
managedExternally: "외부에서 관리됨",
|
||||
copied: "복사됨 ✓",
|
||||
cli: "CLI",
|
||||
cli: "복사",
|
||||
copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
|
||||
connect: "연결",
|
||||
sessionExpires: "세션이 {time} 후 만료됩니다",
|
||||
@ -419,7 +423,7 @@ export const ko: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "영어로 전환",
|
||||
switchTo: "언어 변경",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const pt: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessões",
|
||||
history: "Histórico",
|
||||
overview: "Visão geral",
|
||||
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
|
||||
noSessions: "Ainda não há sessões",
|
||||
noMatch: "Nenhuma sessão corresponde à pesquisa",
|
||||
@ -269,7 +271,7 @@ export const pt: Translations = {
|
||||
"Descobrir, instalar, ativar e atualizar plugins Hermes (paridade com `hermes plugins`).",
|
||||
identifierLabel: "URL Git ou owner/repo",
|
||||
inactive: "inativo",
|
||||
installBtn: "Instalar a partir do Git",
|
||||
installBtn: "Instalar",
|
||||
installHeading: "Instalar a partir de GitHub / URL Git",
|
||||
installHint: "Use a forma curta owner/repo ou um URL completo de clone https:// ou git@.",
|
||||
memoryProviderLabel: "Fornecedor de memória",
|
||||
@ -367,6 +369,8 @@ export const pt: Translations = {
|
||||
description: "Gerir chaves de API e segredos armazenados em",
|
||||
hideAdvanced: "Ocultar avançadas",
|
||||
showAdvanced: "Mostrar avançadas",
|
||||
showLess: "Mostrar menos",
|
||||
showMore: "Mostrar mais",
|
||||
llmProviders: "Fornecedores LLM",
|
||||
providersConfigured: "{configured} de {total} fornecedores configurados",
|
||||
getKey: "Obter chave",
|
||||
@ -392,7 +396,7 @@ export const pt: Translations = {
|
||||
disconnect: "Desligar",
|
||||
managedExternally: "Gerido externamente",
|
||||
copied: "Copiado ✓",
|
||||
cli: "CLI",
|
||||
cli: "Copiar",
|
||||
copyCliCommand: "Copiar comando CLI (para externo / fallback)",
|
||||
connect: "Ligar",
|
||||
sessionExpires: "A sessão expira em {time}",
|
||||
@ -419,7 +423,7 @@ export const pt: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Mudar para inglês",
|
||||
switchTo: "Mudar idioma",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const ru: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Сессии",
|
||||
history: "История",
|
||||
overview: "Обзор",
|
||||
searchPlaceholder: "Поиск по содержимому сообщений...",
|
||||
noSessions: "Сессий пока нет",
|
||||
noMatch: "Нет сессий, соответствующих запросу",
|
||||
@ -269,7 +271,7 @@ export const ru: Translations = {
|
||||
"Поиск, установка, включение и обновление плагинов Hermes (аналог `hermes plugins`).",
|
||||
identifierLabel: "Git URL или owner/repo",
|
||||
inactive: "неактивно",
|
||||
installBtn: "Установить из Git",
|
||||
installBtn: "Установить",
|
||||
installHeading: "Установка из GitHub / Git URL",
|
||||
installHint: "Используйте сокращение owner/repo или полный https:// или git@ URL для клонирования.",
|
||||
memoryProviderLabel: "Провайдер памяти",
|
||||
@ -367,6 +369,8 @@ export const ru: Translations = {
|
||||
description: "Управление API-ключами и секретами, хранящимися в",
|
||||
hideAdvanced: "Скрыть расширенные",
|
||||
showAdvanced: "Показать расширенные",
|
||||
showLess: "Показать меньше",
|
||||
showMore: "Показать больше",
|
||||
llmProviders: "Провайдеры LLM",
|
||||
providersConfigured: "Настроено {configured} из {total} провайдеров",
|
||||
getKey: "Получить ключ",
|
||||
@ -392,7 +396,7 @@ export const ru: Translations = {
|
||||
disconnect: "Отключить",
|
||||
managedExternally: "Управляется извне",
|
||||
copied: "Скопировано ✓",
|
||||
cli: "CLI",
|
||||
cli: "Копировать",
|
||||
copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
|
||||
connect: "Подключить",
|
||||
sessionExpires: "Сессия истечёт через {time}",
|
||||
@ -419,7 +423,7 @@ export const ru: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Переключиться на английский",
|
||||
switchTo: "Сменить язык",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const tr: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Oturumlar",
|
||||
history: "Geçmiş",
|
||||
overview: "Genel bakış",
|
||||
searchPlaceholder: "Mesaj içeriğinde ara...",
|
||||
noSessions: "Henüz oturum yok",
|
||||
noMatch: "Aramanızla eşleşen oturum yok",
|
||||
@ -269,7 +271,7 @@ export const tr: Translations = {
|
||||
"Hermes eklentilerini keşfedin, yükleyin, etkinleştirin ve güncelleyin (`hermes plugins` ile eşdeğer).",
|
||||
identifierLabel: "Git URL veya owner/repo",
|
||||
inactive: "pasif",
|
||||
installBtn: "Git'ten yükle",
|
||||
installBtn: "Yükle",
|
||||
installHeading: "GitHub / Git URL'sinden yükle",
|
||||
installHint: "owner/repo kısayolunu veya tam https:// ya da git@ klon URL'sini kullanın.",
|
||||
memoryProviderLabel: "Bellek sağlayıcısı",
|
||||
@ -367,6 +369,8 @@ export const tr: Translations = {
|
||||
description: "Şurada saklanan API anahtarlarını ve sırları yönetin",
|
||||
hideAdvanced: "Gelişmişi Gizle",
|
||||
showAdvanced: "Gelişmişi Göster",
|
||||
showLess: "Daha az göster",
|
||||
showMore: "Daha fazla göster",
|
||||
llmProviders: "LLM Sağlayıcıları",
|
||||
providersConfigured: "{configured}/{total} sağlayıcı yapılandırıldı",
|
||||
getKey: "Anahtar al",
|
||||
@ -392,7 +396,7 @@ export const tr: Translations = {
|
||||
disconnect: "Bağlantıyı kes",
|
||||
managedExternally: "Harici olarak yönetiliyor",
|
||||
copied: "Kopyalandı ✓",
|
||||
cli: "CLI",
|
||||
cli: "Kopyala",
|
||||
copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
|
||||
connect: "Bağlan",
|
||||
sessionExpires: "Oturumun süresi {time} sonra dolacak",
|
||||
@ -419,7 +423,7 @@ export const tr: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "İngilizce'ye geç",
|
||||
switchTo: "Dil değiştir",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -145,6 +145,8 @@ export interface Translations {
|
||||
// ── Sessions page ──
|
||||
sessions: {
|
||||
title: string;
|
||||
history: string;
|
||||
overview: string;
|
||||
searchPlaceholder: string;
|
||||
noSessions: string;
|
||||
noMatch: string;
|
||||
@ -396,6 +398,8 @@ export interface Translations {
|
||||
providersConfigured: string;
|
||||
replaceCurrentValue: string;
|
||||
showAdvanced: string;
|
||||
showLess: string;
|
||||
showMore: string;
|
||||
showValue: string;
|
||||
};
|
||||
|
||||
|
||||
@ -127,6 +127,8 @@ export const uk: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Сесії",
|
||||
history: "Історія",
|
||||
overview: "Огляд",
|
||||
searchPlaceholder: "Пошук у вмісті повідомлень...",
|
||||
noSessions: "Поки немає сесій",
|
||||
noMatch: "Жодна сесія не відповідає вашому пошуку",
|
||||
@ -269,7 +271,7 @@ export const uk: Translations = {
|
||||
"Знаходьте, встановлюйте, вмикайте та оновлюйте плагіни Hermes (паритет з `hermes plugins`).",
|
||||
identifierLabel: "Git URL або owner/repo",
|
||||
inactive: "неактивний",
|
||||
installBtn: "Встановити з Git",
|
||||
installBtn: "Встановити",
|
||||
installHeading: "Встановити з GitHub / Git URL",
|
||||
installHint: "Використовуйте скорочення owner/repo або повну https:// чи git@ URL для клонування.",
|
||||
memoryProviderLabel: "Постачальник пам'яті",
|
||||
@ -367,6 +369,8 @@ export const uk: Translations = {
|
||||
description: "Керуйте API-ключами та секретами, що зберігаються в",
|
||||
hideAdvanced: "Сховати розширене",
|
||||
showAdvanced: "Показати розширене",
|
||||
showLess: "Показати менше",
|
||||
showMore: "Показати більше",
|
||||
llmProviders: "Постачальники LLM",
|
||||
providersConfigured: "Налаштовано {configured} з {total} постачальників",
|
||||
getKey: "Отримати ключ",
|
||||
@ -392,7 +396,7 @@ export const uk: Translations = {
|
||||
disconnect: "Відключити",
|
||||
managedExternally: "Керується ззовні",
|
||||
copied: "Скопійовано ✓",
|
||||
cli: "CLI",
|
||||
cli: "Копіювати",
|
||||
copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
|
||||
connect: "Підключити",
|
||||
sessionExpires: "Сесія завершиться через {time}",
|
||||
@ -419,7 +423,7 @@ export const uk: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Перемкнути на англійську",
|
||||
switchTo: "Змінити мову",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -127,6 +127,8 @@ export const zhHant: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "工作階段",
|
||||
history: "歷史",
|
||||
overview: "總覽",
|
||||
searchPlaceholder: "搜尋訊息內容...",
|
||||
noSessions: "尚無工作階段",
|
||||
noMatch: "沒有符合的工作階段",
|
||||
@ -269,7 +271,7 @@ export const zhHant: Translations = {
|
||||
"探索、安裝、啟用並更新 Hermes 外掛(對齊 `hermes plugins` CLI)。",
|
||||
identifierLabel: "Git 網址或 owner/repo",
|
||||
inactive: "未啟用",
|
||||
installBtn: "從 Git 安裝",
|
||||
installBtn: "安裝",
|
||||
installHeading: "從 GitHub / Git URL 安裝",
|
||||
installHint: "可使用 owner/repo 簡寫或完整的 https:// 或 git@ 複製網址。",
|
||||
memoryProviderLabel: "記憶提供者",
|
||||
@ -367,6 +369,8 @@ export const zhHant: Translations = {
|
||||
description: "管理儲存於下列位置的 API 金鑰與密鑰",
|
||||
hideAdvanced: "隱藏進階選項",
|
||||
showAdvanced: "顯示進階選項",
|
||||
showLess: "顯示較少",
|
||||
showMore: "顯示更多",
|
||||
llmProviders: "LLM 提供者",
|
||||
providersConfigured: "已設定 {configured}/{total} 個提供者",
|
||||
getKey: "取得金鑰",
|
||||
@ -392,7 +396,7 @@ export const zhHant: Translations = {
|
||||
disconnect: "中斷連線",
|
||||
managedExternally: "由外部管理",
|
||||
copied: "已複製 ✓",
|
||||
cli: "CLI",
|
||||
cli: "複製",
|
||||
copyCliCommand: "複製 CLI 指令(外部 / 備援用)",
|
||||
connect: "連線",
|
||||
sessionExpires: "工作階段將於 {time} 後過期",
|
||||
@ -419,7 +423,7 @@ export const zhHant: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "切換為英文",
|
||||
switchTo: "切換語言",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -126,6 +126,8 @@ export const zh: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "会话",
|
||||
history: "历史",
|
||||
overview: "概览",
|
||||
searchPlaceholder: "搜索消息内容...",
|
||||
noSessions: "暂无会话",
|
||||
noMatch: "没有匹配的会话",
|
||||
@ -265,7 +267,7 @@ export const zh: Translations = {
|
||||
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。",
|
||||
identifierLabel: "Git 地址或 owner/repo",
|
||||
inactive: "未启用",
|
||||
installBtn: "从 Git 安装",
|
||||
installBtn: "安装",
|
||||
installHeading: "从 GitHub / Git 地址安装",
|
||||
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
|
||||
memoryProviderLabel: "记忆提供方",
|
||||
@ -362,6 +364,8 @@ export const zh: Translations = {
|
||||
description: "管理存储在以下位置的 API 密钥和凭据",
|
||||
hideAdvanced: "隐藏高级选项",
|
||||
showAdvanced: "显示高级选项",
|
||||
showLess: "显示更少",
|
||||
showMore: "显示更多",
|
||||
llmProviders: "LLM 提供商",
|
||||
providersConfigured: "已配置 {configured}/{total} 个提供商",
|
||||
getKey: "获取密钥",
|
||||
@ -387,7 +391,7 @@ export const zh: Translations = {
|
||||
disconnect: "断开连接",
|
||||
managedExternally: "外部管理",
|
||||
copied: "已复制 ✓",
|
||||
cli: "CLI",
|
||||
cli: "复制",
|
||||
copyCliCommand: "复制 CLI 命令(用于外部/备用方式)",
|
||||
connect: "连接",
|
||||
sessionExpires: "会话将在 {time} 后过期",
|
||||
@ -414,7 +418,7 @@ export const zh: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "切换到英文",
|
||||
switchTo: "切换语言",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@ -124,6 +124,18 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100dvh;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
||||
dashboard sizes. Keep in sync. */
|
||||
small { font-size: 1.0625rem; }
|
||||
@ -146,7 +158,11 @@ code { font-size: 0.875rem; }
|
||||
--color-secondary: color-mix(in srgb, var(--midground-base) 6%, var(--background-base));
|
||||
--color-secondary-foreground: var(--midground);
|
||||
--color-muted: color-mix(in srgb, var(--midground-base) 8%, var(--background-base));
|
||||
--color-muted-foreground: color-mix(in srgb, var(--midground-base) 55%, transparent);
|
||||
/* Routes the shadcn `muted-foreground` slot through the DS semantic
|
||||
text-secondary token (defaults to midground 80%) so legacy call
|
||||
sites that use `text-muted-foreground` get a readable color
|
||||
instead of the old 55%-transparent default. */
|
||||
--color-muted-foreground: var(--color-text-secondary);
|
||||
--color-accent: color-mix(in srgb, var(--midground-base) 10%, var(--background-base));
|
||||
--color-accent-foreground: var(--midground);
|
||||
--color-destructive: #fb2c36;
|
||||
@ -166,6 +182,12 @@ code { font-size: 0.875rem; }
|
||||
}
|
||||
|
||||
|
||||
/* Collapsed sidebar tooltip entrance — skipped when moving between items. */
|
||||
@keyframes sidebar-tooltip-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-4px); }
|
||||
to { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
|
||||
/* Toast animations used by `components/Toast.tsx`. */
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
|
||||
@ -25,6 +25,11 @@ declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_BASE_PATH__?: string;
|
||||
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
|
||||
* engaged (public bind, no ``--insecure``). Toggles the SPA's
|
||||
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
|
||||
* fetched via :func:`getWsTicket`. */
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
let _sessionToken: string | null = null;
|
||||
@ -43,7 +48,87 @@ export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T>
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
const res = await fetch(`${BASE}${url}`, { ...init, headers });
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
// ``credentials: 'include'`` so the cookie-auth path (gated mode) works
|
||||
// for any fetch routed through here. Loopback mode is unaffected — the
|
||||
// server doesn't read cookies and the legacy session-token header is
|
||||
// already attached above.
|
||||
credentials: init?.credentials ?? "include",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// Phase 6: the gated middleware emits a structured envelope so the
|
||||
// SPA can full-page-navigate to /login on session expiry. Parse it,
|
||||
// and only redirect on the known error codes — domain-level 401s
|
||||
// (e.g. "you don't have permission to read this monitor") bubble
|
||||
// up as regular errors so callers can handle them.
|
||||
let body: { error?: string; login_url?: string } = {};
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
/* non-JSON 401 — let it fall through */
|
||||
}
|
||||
if (
|
||||
(body.error === "unauthenticated" || body.error === "session_expired") &&
|
||||
body.login_url
|
||||
) {
|
||||
// Preserve where the user was so /auth/callback can land them back
|
||||
// after re-auth. The gate's login_url already carries a ``next=``
|
||||
// built from the request path, but the SPA may be deep inside a
|
||||
// SPA route the gate never saw — e.g. a hash route or a client-side
|
||||
// /sessions/<id> deep link. Save the current location as a
|
||||
// fallback the post-login handler can read.
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"hermes.lastLocation",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
} catch {
|
||||
/* SSR / privacy mode — ignore */
|
||||
}
|
||||
window.location.assign(body.login_url);
|
||||
// Never resolve — the page is about to unload.
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
// Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart
|
||||
// (``hermes update``, ``hermes gateway restart``, etc.). A tab kept
|
||||
// open across the restart holds the OLD token in
|
||||
// ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render,
|
||||
// so every fetch returns 401. The HTML is served ``Cache-Control:
|
||||
// no-store`` so a reload picks up the freshly-injected token. Trigger
|
||||
// that reload once on the first stale-token 401 — gated mode is
|
||||
// handled above, so reaching here in gated mode means a real
|
||||
// middleware failure that should not reload-loop.
|
||||
if (!window.__HERMES_AUTH_REQUIRED__) {
|
||||
let alreadyReloaded = false;
|
||||
try {
|
||||
alreadyReloaded =
|
||||
sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
|
||||
} catch {
|
||||
/* SSR / privacy mode — fall through to throw */
|
||||
}
|
||||
if (!alreadyReloaded) {
|
||||
try {
|
||||
sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
|
||||
} catch {
|
||||
/* SSR / privacy mode — best effort */
|
||||
}
|
||||
window.location.reload();
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (res.ok) {
|
||||
// Clear the stale-token reload guard: a successful 2xx proves the
|
||||
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
|
||||
// 401 — if any — should be allowed to trigger its own reload cycle.
|
||||
try {
|
||||
sessionStorage.removeItem("hermes.tokenReloadAttempted");
|
||||
} catch {
|
||||
/* SSR / privacy mode — ignore */
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
@ -51,6 +136,11 @@ export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T>
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Encode a plugin registry key for URL paths (preserves `/` segment separators). */
|
||||
function pluginPath(name: string): string {
|
||||
return name.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
async function getSessionToken(): Promise<string> {
|
||||
if (_sessionToken) return _sessionToken;
|
||||
const injected = window.__HERMES_SESSION_TOKEN__;
|
||||
@ -61,8 +151,66 @@ async function getSessionToken(): Promise<string> {
|
||||
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
|
||||
*
|
||||
* The dashboard's gated-mode WS auth (``hermes_cli.web_server._ws_auth_ok``)
|
||||
* rejects the legacy ``?token=<_SESSION_TOKEN>`` path and only accepts
|
||||
* ``?ticket=<minted>`` consumed against the in-memory ticket store. Browsers
|
||||
* can't set ``Authorization`` on a WS upgrade, so this round-trip via the
|
||||
* authenticated REST endpoint is the bridge from cookie auth to WS auth.
|
||||
*
|
||||
* Tickets are single-use and TTL=30s — every WS connect attempt must
|
||||
* fetch a fresh ticket.
|
||||
*/
|
||||
export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> {
|
||||
const res = await fetch(`${BASE}/api/auth/ws-ticket`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
|
||||
* connect. In gated mode mints a fresh single-use ticket; in loopback
|
||||
* mode returns the injected session token.
|
||||
*/
|
||||
export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
return ["ticket", ticket];
|
||||
}
|
||||
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
return ["token", token];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
/**
|
||||
* Identity probe for the dashboard auth gate (Phase 7).
|
||||
*
|
||||
* Returns the verified Session as JSON when gated mode is active and a
|
||||
* valid cookie is attached. Loopback mode is unaffected — the endpoint
|
||||
* still exists but is never useful there (no Session, no cookie). The
|
||||
* AuthWidget component swallows 401s from this call: if the gate isn't
|
||||
* engaged, /api/auth/me returns 401 and the widget renders nothing.
|
||||
*/
|
||||
getAuthMe: () => fetchJSON<AuthMeResponse>("/api/auth/me"),
|
||||
logout: () =>
|
||||
fetch(`${BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
}).then((r) => {
|
||||
// /auth/logout returns 302 → /login. Follow that with a full-page
|
||||
// navigation rather than letting fetch() opaquely consume the
|
||||
// redirect — the SPA needs to leave the protected area.
|
||||
window.location.assign("/login");
|
||||
return r;
|
||||
}),
|
||||
getSessions: (limit = 20, offset = 0) =>
|
||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||
getSessionMessages: (id: string) =>
|
||||
@ -293,25 +441,25 @@ export const api = {
|
||||
|
||||
enableAgentPlugin: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
|
||||
`/api/dashboard/agent-plugins/${pluginPath(name)}/enable`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
disableAgentPlugin: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
|
||||
`/api/dashboard/agent-plugins/${pluginPath(name)}/disable`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
updateAgentPlugin: (name: string) =>
|
||||
fetchJSON<AgentPluginUpdateResponse>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
|
||||
`/api/dashboard/agent-plugins/${pluginPath(name)}/update`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
removeAgentPlugin: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string }>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
|
||||
`/api/dashboard/agent-plugins/${pluginPath(name)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
@ -324,7 +472,7 @@ export const api = {
|
||||
|
||||
setPluginVisibility: (name: string, hidden: boolean) =>
|
||||
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
|
||||
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`,
|
||||
`/api/dashboard/plugins/${pluginPath(name)}/visibility`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -343,6 +491,23 @@ export const api = {
|
||||
}),
|
||||
};
|
||||
|
||||
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
|
||||
*
|
||||
* Returned by the dashboard's gated middleware when a valid session cookie
|
||||
* is attached. ``email`` and ``display_name`` are empty strings under the
|
||||
* Nous Portal contract V1 (the access token has no email/name claims —
|
||||
* see Contract Anchor C4 in the plan). The AuthWidget surfaces a
|
||||
* truncated ``user_id`` instead.
|
||||
*/
|
||||
export interface AuthMeResponse {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
org_id: string;
|
||||
provider: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
@ -366,6 +531,14 @@ export interface PlatformStatus {
|
||||
|
||||
export interface StatusResponse {
|
||||
active_sessions: number;
|
||||
/** Phase 7: ``true`` when the dashboard's OAuth gate is engaged
|
||||
* (public bind, no ``--insecure``). Read alongside ``auth_providers``
|
||||
* to render a "gated / loopback" badge. */
|
||||
auth_required?: boolean;
|
||||
/** Phase 7: registered ``DashboardAuthProvider`` names (e.g. ``["nous"]``).
|
||||
* Empty in loopback mode; empty + ``auth_required=true`` is a
|
||||
* fail-closed state (the dashboard will refuse to bind). */
|
||||
auth_providers?: string[];
|
||||
config_path: string;
|
||||
config_version: number;
|
||||
env_path: string;
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
||||
*/
|
||||
|
||||
import { HERMES_BASE_PATH } from "@/lib/api";
|
||||
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
|
||||
|
||||
export type GatewayEventName =
|
||||
| "gateway.ready"
|
||||
@ -109,17 +109,32 @@ export class GatewayClient {
|
||||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!resolved) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
||||
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
||||
// Explicit ``token`` overrides the gate check (test-only path).
|
||||
let authParamName: string;
|
||||
let authParamValue: string;
|
||||
if (token) {
|
||||
authParamName = "token";
|
||||
authParamValue = token;
|
||||
} else if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
authParamName = "ticket";
|
||||
authParamValue = ticket;
|
||||
} else {
|
||||
authParamName = "token";
|
||||
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!authParamValue) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?token=${encodeURIComponent(resolved)}`,
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
@ -233,5 +248,6 @@ export class GatewayClient {
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,15 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
|
||||
export const themedFont = "font-mondwest";
|
||||
|
||||
/** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
|
||||
export const themedBody = "font-mondwest normal-case";
|
||||
|
||||
/** Mondwest brand chrome — uppercase section headers and nav labels. */
|
||||
export const themedChrome = "font-mondwest text-display";
|
||||
|
||||
/** Relative time from a Unix epoch timestamp (seconds). */
|
||||
export function timeAgo(ts: number): string {
|
||||
const delta = Date.now() / 1000 - ts;
|
||||
|
||||
@ -119,7 +119,7 @@ function SortHeader({
|
||||
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
||||
<ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
@ -146,7 +146,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
{t.analytics.dailyTokenUsage}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
|
||||
{t.analytics.input}
|
||||
@ -177,7 +177,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
style={{ height: CHART_HEIGHT_PX }}
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>
|
||||
{t.analytics.input}: {formatTokens(d.input_tokens)}
|
||||
@ -207,7 +207,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
|
||||
<div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
|
||||
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
|
||||
{daily.length > 2 && (
|
||||
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
|
||||
@ -239,7 +239,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full font-mondwest normal-case text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
|
||||
@ -298,7 +298,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full font-mondwest normal-case text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
|
||||
@ -353,7 +353,7 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full font-mondwest normal-case text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
|
||||
@ -430,11 +430,23 @@ export default function AnalyticsPage() {
|
||||
const periodLabel =
|
||||
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
|
||||
setAfterTitle(
|
||||
<span className="flex items-center gap-2">
|
||||
{loading && <Spinner className="shrink-0 text-base text-primary" />}
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
{showTokens !== false && (
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
)}
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
@ -453,16 +465,6 @@ export default function AnalyticsPage() {
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
prefix={loading ? <Spinner /> : <RefreshCw />}
|
||||
>
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
@ -484,7 +486,7 @@ export default function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
|
||||
<h2 className="font-display text-base tracking-wider uppercase text-foreground">
|
||||
<h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
|
||||
Token analytics hidden
|
||||
</h2>
|
||||
<p>
|
||||
@ -586,7 +588,7 @@ export default function AnalyticsPage() {
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
<p className="text-xs mt-1 text-text-tertiary">
|
||||
{t.analytics.startSession}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -23,8 +23,8 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography";
|
||||
import { HERMES_BASE_PATH } from "@/lib/api";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, PanelRight, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -38,12 +38,15 @@ import { api } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
function buildWsUrl(
|
||||
token: string,
|
||||
authParam: [string, string],
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ token, channel });
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
// ``["ticket", <minted>]`` in gated mode. The server-side helper
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
}
|
||||
@ -233,8 +236,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
aria-controls="chat-side-panel"
|
||||
className={cn(
|
||||
"shrink-0 rounded border border-current/20",
|
||||
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
|
||||
"text-midground/80 hover:text-midground hover:bg-midground/5",
|
||||
"px-2 py-1 text-xs font-medium tracking-wide",
|
||||
"text-text-secondary hover:text-midground hover:bg-midground/5",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
@ -544,15 +547,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
const url = buildWsUrl(token, resumeParam, channel);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
// Suppress banner/terminal side-effects when cleanup() calls `ws.close()`
|
||||
// (React StrictMode remount, route change) so we never write to a
|
||||
// disposed xterm or setState on an unmounted tree.
|
||||
// WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this
|
||||
// awaits a single-use ticket via /api/auth/ws-ticket before opening;
|
||||
// in loopback mode it resolves synchronously against the injected
|
||||
// session token. The IIFE keeps the outer effect synchronous so its
|
||||
// ``return cleanup`` stays at the top level; handlers + disposables
|
||||
// are hoisted to ``let`` bindings the cleanup closes over.
|
||||
let unmounting = false;
|
||||
let onDataDisposable: { dispose(): void } | null = null;
|
||||
let onResizeDisposable: { dispose(): void } | null = null;
|
||||
void (async () => {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (unmounting) return;
|
||||
const url = buildWsUrl(authParam, resumeParam, channel);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setBanner(null);
|
||||
@ -605,31 +615,32 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// mouse reporting, so we drop SGR mouse reports entirely instead of
|
||||
// forwarding them into Hermes. Keyboard input, paste, and resize still
|
||||
// behave normally.
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
const onDataDisposable = term.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
onDataDisposable = term.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
if (SGR_MOUSE_RE.test(data)) {
|
||||
return;
|
||||
}
|
||||
if (SGR_MOUSE_RE.test(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
});
|
||||
ws.send(data);
|
||||
});
|
||||
|
||||
const onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
|
||||
}
|
||||
});
|
||||
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
term.focus();
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
syncMetricsRef.current = null;
|
||||
onDataDisposable.dispose();
|
||||
onResizeDisposable.dispose();
|
||||
onDataDisposable?.dispose();
|
||||
onResizeDisposable?.dispose();
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.removeEventListener(
|
||||
@ -640,7 +651,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
|
||||
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
||||
if (settleRaf2) cancelAnimationFrame(settleRaf2);
|
||||
ws.close();
|
||||
// Phase 5.3: ``ws`` is local to the IIFE that opens it (the gated-mode
|
||||
// ticket fetch makes the open async). The cleanup runs at the outer
|
||||
// effect's top level so it can't reach into that scope — close via
|
||||
// the ref instead. ``?.`` covers the race where unmount fires before
|
||||
// the ticket fetch resolves and ``wsRef.current`` was never assigned.
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
@ -708,9 +724,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// model badge, tool-call list, model picker. Best-effort: if the
|
||||
// sidecar fails to connect the terminal pane keeps working.
|
||||
//
|
||||
// `normal-case` opts out of the dashboard's global `uppercase` rule on
|
||||
// the root `<div>` in App.tsx — terminal output must preserve case.
|
||||
//
|
||||
// Mobile model/tools sheet is portaled to `document.body` so it stacks
|
||||
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
|
||||
// dashboard column uses `relative z-2`, which traps `position:fixed`
|
||||
@ -756,7 +769,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
mondwest
|
||||
className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.modelToolsSheetTitle}
|
||||
@ -769,7 +783,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
size="icon"
|
||||
onClick={closeMobilePanel}
|
||||
aria-label={t.app.closeModelTools}
|
||||
className="text-midground/70 hover:text-midground"
|
||||
className="text-text-secondary hover:text-midground"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
@ -789,7 +803,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<PluginSlot name="chat:top" />
|
||||
{mobileModelToolsPortal}
|
||||
|
||||
@ -822,11 +836,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
aria-label="Copy last assistant response"
|
||||
className={cn(
|
||||
"absolute z-10",
|
||||
"normal-case tracking-normal font-normal",
|
||||
"rounded border border-current/30",
|
||||
"bg-black/20 backdrop-blur-sm",
|
||||
"opacity-60 hover:opacity-100 hover:border-current/60",
|
||||
"transition-opacity duration-150 normal-case font-normal tracking-normal",
|
||||
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
|
||||
"opacity-70 hover:opacity-100 hover:border-current/60",
|
||||
"transition-opacity duration-150",
|
||||
"bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
|
||||
"lg:bottom-4 lg:right-4",
|
||||
)}
|
||||
style={{ color: TERMINAL_THEME.foreground }}
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
Download,
|
||||
FormInput,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Upload,
|
||||
X,
|
||||
@ -385,7 +384,7 @@ export default function ConfigPage() {
|
||||
category={cat}
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
@ -393,7 +392,7 @@ export default function ConfigPage() {
|
||||
)}
|
||||
{showSection && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
|
||||
{section.replace(/_/g, " ")}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
@ -486,18 +485,18 @@ export default function ConfigPage() {
|
||||
{yamlMode ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={handleYamlSave}
|
||||
disabled={yamlSaving}
|
||||
prefix={<Save />}
|
||||
>
|
||||
{yamlSaving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
prefix={<Save />}
|
||||
>
|
||||
{saving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
@ -534,13 +533,13 @@ export default function ConfigPage() {
|
||||
<div className="sm:sticky sm:top-4">
|
||||
<div className="flex flex-col border border-border bg-muted/20">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
<Filter className="h-3 w-3 text-text-tertiary" />
|
||||
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
{t.config.filters}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
|
||||
{t.config.sections}
|
||||
</div>
|
||||
|
||||
@ -556,7 +555,7 @@ export default function ConfigPage() {
|
||||
setSearchQuery("");
|
||||
setActiveCategory(cat);
|
||||
}}
|
||||
className="rounded-sm whitespace-nowrap px-2 py-1 text-[11px]"
|
||||
className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
|
||||
>
|
||||
<CategoryIcon
|
||||
category={cat}
|
||||
@ -566,10 +565,10 @@ export default function ConfigPage() {
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
className={`text-xs tabular-nums ${
|
||||
isActive
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/50"
|
||||
? "text-text-secondary"
|
||||
: "text-text-tertiary"
|
||||
}`}
|
||||
>
|
||||
{categoryCounts[cat] || 0}
|
||||
@ -591,7 +590,7 @@ export default function ConfigPage() {
|
||||
<Search className="h-4 w-4" />
|
||||
{t.config.searchResults}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{searchMatchedFields.length}{" "}
|
||||
{t.config.fields.replace(
|
||||
"{s}",
|
||||
@ -622,7 +621,7 @@ export default function ConfigPage() {
|
||||
/>
|
||||
{prettyCategoryName(activeCategory)}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{activeFields.length}{" "}
|
||||
{t.config.fields.replace(
|
||||
"{s}",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react";
|
||||
import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
@ -10,7 +10,7 @@ import type { CronJob, ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useModalBehavior } from "@nous-research/ui/hooks/use-modal-behavior";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
@ -18,6 +18,7 @@ import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
@ -228,10 +229,10 @@ export default function CronPage() {
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
);
|
||||
@ -282,7 +283,7 @@ export default function CronPage() {
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-cron-title"
|
||||
>
|
||||
<div className="relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col">
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
@ -296,7 +297,7 @@ export default function CronPage() {
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="create-cron-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
{t.cron.newJob}
|
||||
</h2>
|
||||
@ -379,10 +380,11 @@ export default function CronPage() {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : <Plus />}
|
||||
prefix={creating ? <Spinner /> : undefined}
|
||||
>
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
|
||||
@ -133,12 +133,12 @@ function EnvVarRow({
|
||||
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
||||
if (compact && !info.is_set && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden opacity-50 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||
<span className="font-mono-ui text-xs">
|
||||
{varKey}
|
||||
</span>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
|
||||
<span className="text-xs text-text-tertiary truncate hidden sm:block">
|
||||
{info.description}
|
||||
</span>
|
||||
</div>
|
||||
@ -148,7 +148,7 @@ function EnvVarRow({
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
@ -169,12 +169,12 @@ function EnvVarRow({
|
||||
// Non-compact unset row
|
||||
if (!info.is_set && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden opacity-60 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||
<Label className="font-mono-ui text-xs">
|
||||
{varKey}
|
||||
</Label>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
|
||||
<span className="text-xs text-text-tertiary truncate hidden sm:block">
|
||||
{info.description}
|
||||
</span>
|
||||
</div>
|
||||
@ -184,7 +184,7 @@ function EnvVarRow({
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
@ -207,7 +207,7 @@ function EnvVarRow({
|
||||
<div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
|
||||
<Label className="font-mono-ui text-xs">{varKey}</Label>
|
||||
<Badge tone={info.is_set ? "success" : "outline"}>
|
||||
{info.is_set ? t.common.set : t.env.notSet}
|
||||
</Badge>
|
||||
@ -217,7 +217,7 @@ function EnvVarRow({
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
@ -232,7 +232,7 @@ function EnvVarRow({
|
||||
<Badge
|
||||
key={tool}
|
||||
tone="secondary"
|
||||
className="text-[0.6rem] py-0 px-1.5"
|
||||
className="text-xs py-0 px-1.5"
|
||||
>
|
||||
{tool}
|
||||
</Badge>
|
||||
@ -396,7 +396,7 @@ function ProviderGroupCard({
|
||||
{group.name === "Other" ? t.common.other : group.name}
|
||||
</span>
|
||||
{hasAnyConfigured && (
|
||||
<Badge tone="success" className="text-[0.6rem]">
|
||||
<Badge tone="success" className="text-xs">
|
||||
{configuredCount} {t.common.set.toLowerCase()}
|
||||
</Badge>
|
||||
)}
|
||||
@ -407,13 +407,13 @@ function ProviderGroupCard({
|
||||
href={keyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[0.65rem] text-muted-foreground/60">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{t.env.keysCount
|
||||
.replace("{count}", String(group.entries.length))
|
||||
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
|
||||
@ -546,7 +546,7 @@ export default function EnvPage() {
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => scrollTo(s.id)}
|
||||
className="shrink-0 cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||
className="shrink-0 cursor-pointer px-2 py-0.5 font-mondwest text-display text-xs tracking-wider text-text-secondary hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
@ -745,7 +745,7 @@ export default function EnvPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.env.description} <code>~/.hermes/.env</code>
|
||||
</p>
|
||||
<p className="text-[0.7rem] text-muted-foreground/70">
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{t.env.changesNote}
|
||||
</p>
|
||||
</div>
|
||||
@ -797,80 +797,36 @@ export default function EnvPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{nonProviderGrouped.map(
|
||||
({
|
||||
label,
|
||||
icon: Icon,
|
||||
setEntries,
|
||||
unsetEntries,
|
||||
totalEntries,
|
||||
category,
|
||||
}) => {
|
||||
if (totalEntries === 0) return null;
|
||||
{nonProviderGrouped.map((section) => {
|
||||
if (section.totalEntries === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={category} id={`section-${category}`}>
|
||||
<CardHeader className="border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{setEntries.length} {t.common.of} {totalEntries}{" "}
|
||||
{t.common.configured}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 pt-4 overflow-hidden">
|
||||
{setEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClear={keyClear.requestDelete}
|
||||
onReveal={handleReveal}
|
||||
onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
))}
|
||||
|
||||
{unsetEntries.length > 0 && (
|
||||
<CollapsibleUnset
|
||||
category={category}
|
||||
unsetEntries={unsetEntries}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClear={keyClear.requestDelete}
|
||||
onReveal={handleReveal}
|
||||
onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
return (
|
||||
<EnvCategoryCard
|
||||
key={section.category}
|
||||
section={section}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClear={keyClear.requestDelete}
|
||||
onReveal={handleReveal}
|
||||
onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<PluginSlot name="env:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CollapsibleUnset — for non-provider categories */
|
||||
/* EnvCategoryCard — keys / messaging / settings sections */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CollapsibleUnset({
|
||||
category: _category,
|
||||
unsetEntries,
|
||||
function EnvCategoryCard({
|
||||
section,
|
||||
edits,
|
||||
setEdits,
|
||||
revealed,
|
||||
@ -881,8 +837,14 @@ function CollapsibleUnset({
|
||||
onCancelEdit,
|
||||
clearDialogOpen = false,
|
||||
}: {
|
||||
category: string;
|
||||
unsetEntries: [string, EnvVarInfo][];
|
||||
section: {
|
||||
category: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
setEntries: [string, EnvVarInfo][];
|
||||
totalEntries: number;
|
||||
unsetEntries: [string, EnvVarInfo][];
|
||||
};
|
||||
edits: Record<string, string>;
|
||||
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
revealed: Record<string, string>;
|
||||
@ -893,39 +855,64 @@ function CollapsibleUnset({
|
||||
onCancelEdit: (key: string) => void;
|
||||
clearDialogOpen?: boolean;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const noneConfigured = section.setEntries.length === 0;
|
||||
const [showAll, setShowAll] = useState(noneConfigured);
|
||||
const { t } = useI18n();
|
||||
const Icon = section.icon;
|
||||
const hasContent = section.setEntries.length > 0 || showAll;
|
||||
const rowProps = {
|
||||
edits,
|
||||
setEdits,
|
||||
revealed,
|
||||
saving,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
clearDialogOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
prefix={collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
aria-expanded={!collapsed}
|
||||
className="self-start mt-1 normal-case tracking-normal text-xs text-muted-foreground hover:text-foreground"
|
||||
<Card id={`section-${section.category}`}>
|
||||
<CardHeader
|
||||
className={`bg-card${hasContent ? " border-b border-border" : ""}`}
|
||||
>
|
||||
{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{section.label}</CardTitle>
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
unsetEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
onClear={onClear}
|
||||
onReveal={onReveal}
|
||||
onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{section.unsetEntries.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll((open) => !open)}
|
||||
aria-expanded={showAll}
|
||||
className="shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mondwest text-xs tracking-[0.08em] text-text-secondary transition-colors hover:text-foreground"
|
||||
>
|
||||
{showAll ? t.env.showLess : t.env.showMore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardDescription>
|
||||
{section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
|
||||
{t.common.configured}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{hasContent && (
|
||||
<CardContent className="grid gap-3 overflow-hidden pt-4">
|
||||
{section.setEntries.map(([key, info]) => (
|
||||
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
|
||||
))}
|
||||
|
||||
{showAll &&
|
||||
section.unsetEntries.map(([key, info]) => (
|
||||
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,11 +40,13 @@ const LINE_COLORS: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
warning: "text-warning",
|
||||
info: "text-foreground",
|
||||
debug: "text-muted-foreground/60",
|
||||
debug: "text-text-tertiary",
|
||||
};
|
||||
|
||||
const toOptions = <T extends string>(values: readonly T[]) =>
|
||||
values.map((v) => ({ value: v, label: v }));
|
||||
const formatFilterLabel = (value: string) => value.toUpperCase();
|
||||
|
||||
const toSegmentOptions = <T extends string>(values: readonly T[]) =>
|
||||
values.map((v) => ({ value: v, label: formatFilterLabel(v) }));
|
||||
|
||||
const filterGroupClass =
|
||||
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
|
||||
@ -85,41 +87,42 @@ export default function LogsPage() {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setAfterTitle(
|
||||
<span className="flex items-center gap-2">
|
||||
{loading && <Spinner className="shrink-0 text-base text-primary" />}
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
{file} · {level} · {component}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{formatFilterLabel(file)} · {formatFilterLabel(level)} ·{" "}
|
||||
{formatFilterLabel(component)}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
|
||||
{t.logs.autoRefresh}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
id="logs-auto-refresh"
|
||||
/>
|
||||
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
|
||||
{t.logs.autoRefresh}
|
||||
</Label>
|
||||
{autoRefresh && (
|
||||
<Badge tone="success" className="text-[10px]">
|
||||
<Badge tone="success" className="text-xs">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
prefix={loading ? <Spinner /> : <RefreshCw />}
|
||||
>
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
@ -163,7 +166,7 @@ export default function LogsPage() {
|
||||
className={segmentedClass}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
options={toOptions(FILES)}
|
||||
options={toSegmentOptions(FILES)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
@ -172,7 +175,7 @@ export default function LogsPage() {
|
||||
className={segmentedClass}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
options={toOptions(LEVELS)}
|
||||
options={toSegmentOptions(LEVELS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
@ -181,7 +184,7 @@ export default function LogsPage() {
|
||||
className={segmentedClass}
|
||||
value={component}
|
||||
onChange={setComponent}
|
||||
options={toOptions(COMPONENTS)}
|
||||
options={toSegmentOptions(COMPONENTS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ import type {
|
||||
ModelsAnalyticsModelEntry,
|
||||
ModelsAnalyticsResponse,
|
||||
} from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { timeAgo, cn, themedBody } from "@/lib/utils";
|
||||
import { formatTokenCount } from "@/lib/format";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
@ -27,7 +27,7 @@ import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { useModalBehavior } from "@nous-research/ui/hooks/use-modal-behavior";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
@ -125,7 +125,7 @@ function TokenBar({
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
|
||||
{segments.map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
|
||||
@ -152,22 +152,22 @@ function CapabilityBadges({
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{capabilities.supports_tools && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||
</span>
|
||||
)}
|
||||
{capabilities.supports_vision && (
|
||||
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
<Eye className="h-2.5 w-2.5" /> Vision
|
||||
</span>
|
||||
)}
|
||||
{capabilities.supports_reasoning && (
|
||||
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
|
||||
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
<Brain className="h-2.5 w-2.5" /> Reasoning
|
||||
</span>
|
||||
)}
|
||||
{capabilities.model_family && (
|
||||
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-xs font-medium text-text-secondary">
|
||||
{capabilities.model_family}
|
||||
</span>
|
||||
)}
|
||||
@ -237,7 +237,7 @@ function UseAsMenu({
|
||||
outlined
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={busy}
|
||||
className="text-[10px] h-6 px-2"
|
||||
className="h-6 px-2 text-xs uppercase"
|
||||
prefix={busy ? <Spinner /> : null}
|
||||
>
|
||||
Use as <ChevronDown className="h-3 w-3" />
|
||||
@ -248,20 +248,20 @@ function UseAsMenu({
|
||||
type="button"
|
||||
onClick={() => assign("main", "")}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs hover:bg-muted/50 disabled:opacity-40"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Star className="h-3 w-3" />
|
||||
Main model
|
||||
</span>
|
||||
{isMain && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-primary/80">
|
||||
<span className="text-display text-xs tracking-wider text-primary">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-border/50 px-3 py-1.5 text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
<div className="border-t border-border/50 px-3 py-1.5 text-display text-xs tracking-wider text-text-tertiary">
|
||||
Auxiliary task
|
||||
</div>
|
||||
|
||||
@ -269,7 +269,7 @@ function UseAsMenu({
|
||||
type="button"
|
||||
onClick={() => assign("auxiliary", "")}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
|
||||
>
|
||||
<span>All auxiliary tasks</span>
|
||||
</button>
|
||||
@ -280,11 +280,11 @@ function UseAsMenu({
|
||||
type="button"
|
||||
onClick={() => assign("auxiliary", t.key)}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{mainAuxTask === t.key && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-primary/80">
|
||||
<span className="text-display text-xs tracking-wider text-primary">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
@ -292,7 +292,7 @@ function UseAsMenu({
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-[10px] text-destructive border-t border-border/50">
|
||||
<div className="px-3 py-2 text-xs text-destructive border-t border-border/50">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -345,36 +345,36 @@ function ModelCard({
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/50 text-xs font-mono">
|
||||
<span className="text-text-tertiary text-xs font-mono">
|
||||
#{rank}
|
||||
</span>
|
||||
<CardTitle className="text-sm font-mono-ui truncate">
|
||||
{shortModelName(entry.model)}
|
||||
</CardTitle>
|
||||
{isMain && (
|
||||
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-primary">
|
||||
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-primary">
|
||||
<Star className="h-2.5 w-2.5" /> main
|
||||
</span>
|
||||
)}
|
||||
{mainAuxTask && (
|
||||
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-purple-600 dark:text-purple-400">
|
||||
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-purple-600 dark:text-purple-400">
|
||||
aux · {mainAuxTask}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{provider && (
|
||||
<Badge tone="secondary" className="text-[9px]">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{provider}
|
||||
</Badge>
|
||||
)}
|
||||
{caps.context_window && caps.context_window > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<span className="text-xs text-text-secondary">
|
||||
{formatTokenCount(caps.context_window)} ctx
|
||||
</span>
|
||||
)}
|
||||
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<span className="text-xs text-text-secondary">
|
||||
{formatTokenCount(caps.max_output_tokens)} out
|
||||
</span>
|
||||
)}
|
||||
@ -386,7 +386,7 @@ function ModelCard({
|
||||
<div className="text-xs font-mono font-semibold">
|
||||
{formatTokens(totalTokens)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{t.models.tokens}
|
||||
</div>
|
||||
</div>
|
||||
@ -396,7 +396,7 @@ function ModelCard({
|
||||
<div className="text-xs font-mono font-semibold">
|
||||
{entry.sessions}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{t.models.sessions}
|
||||
</div>
|
||||
</div>
|
||||
@ -425,7 +425,7 @@ function ModelCard({
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-center">
|
||||
<div className="font-mono font-semibold">{entry.sessions}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{t.models.sessions}
|
||||
</div>
|
||||
</div>
|
||||
@ -433,7 +433,7 @@ function ModelCard({
|
||||
<div className="font-mono font-semibold">
|
||||
{formatTokens(entry.avg_tokens_per_session)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{t.models.avgPerSession}
|
||||
</div>
|
||||
</div>
|
||||
@ -441,7 +441,7 @@ function ModelCard({
|
||||
<div className="font-mono font-semibold">
|
||||
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{t.models.apiCalls}
|
||||
</div>
|
||||
</div>
|
||||
@ -449,7 +449,7 @@ function ModelCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border/30 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{showTokens && entry.estimated_cost > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
@ -524,7 +524,7 @@ function AuxiliaryTasksModal({
|
||||
aria-modal="true"
|
||||
aria-labelledby="aux-modal-title"
|
||||
>
|
||||
<div className="relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
||||
<div className={cn(themedBody, "relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
@ -539,7 +539,7 @@ function AuxiliaryTasksModal({
|
||||
<div className="flex items-center justify-between gap-3 pr-8">
|
||||
<h2
|
||||
id="aux-modal-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
Auxiliary Tasks
|
||||
</h2>
|
||||
@ -548,13 +548,13 @@ function AuxiliaryTasksModal({
|
||||
outlined
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={resetBusy}
|
||||
className="text-[10px] h-6"
|
||||
className="h-6 text-xs uppercase"
|
||||
prefix={resetBusy ? <Spinner /> : null}
|
||||
>
|
||||
Reset all to auto
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/80 mt-2">
|
||||
<p className="text-xs text-text-secondary mt-2">
|
||||
Auxiliary tasks handle side-jobs like vision, session search, and
|
||||
compression. <span className="font-mono">auto</span> means
|
||||
"use the main model". Override per-task when you want a
|
||||
@ -575,11 +575,11 @@ function AuxiliaryTasksModal({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium">{t.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{t.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
<div className="text-xs font-mono text-text-secondary truncate">
|
||||
{isAuto
|
||||
? "auto (use main model)"
|
||||
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
||||
@ -589,7 +589,7 @@ function AuxiliaryTasksModal({
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
||||
className="text-[10px] h-6"
|
||||
className="h-6 text-xs uppercase"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
@ -675,7 +675,7 @@ function ModelSettingsPanel({
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]">
|
||||
<span className="max-w-full min-w-0 text-xs text-text-secondary [overflow-wrap:anywhere]">
|
||||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
@ -687,11 +687,11 @@ function ModelSettingsPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Star className="h-3 w-3 text-primary" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">
|
||||
<span className="text-display text-xs font-medium tracking-wider">
|
||||
Main model
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground truncate">
|
||||
<div className="text-xs font-mono text-text-secondary truncate">
|
||||
{mainProv || "(unset)"}
|
||||
{mainProv && mainModel && " · "}
|
||||
{mainModel || "(unset)"}
|
||||
@ -700,7 +700,7 @@ function ModelSettingsPanel({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPicker({ kind: "main" })}
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
className="shrink-0 self-start text-xs uppercase sm:self-center"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
@ -710,12 +710,12 @@ function ModelSettingsPanel({
|
||||
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">
|
||||
<Cpu className="h-3 w-3 text-text-tertiary" />
|
||||
<span className="text-display text-xs font-medium tracking-wider">
|
||||
Auxiliary tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground truncate">
|
||||
<div className="text-xs font-mono text-text-secondary truncate">
|
||||
{auxOverrideCount > 0
|
||||
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
|
||||
: `${AUX_TASKS.length} tasks · all auto`}
|
||||
@ -725,7 +725,7 @@ function ModelSettingsPanel({
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setAuxModalOpen(true)}
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
className="shrink-0 self-start text-xs uppercase sm:self-center"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
@ -821,11 +821,21 @@ export default function ModelsPage() {
|
||||
const periodLabel =
|
||||
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
|
||||
setAfterTitle(
|
||||
<span className="flex items-center gap-2">
|
||||
{loading && <Spinner className="shrink-0 text-base text-primary" />}
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
@ -838,21 +848,12 @@ export default function ModelsPage() {
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
className="uppercase"
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
prefix={loading ? <Spinner /> : <RefreshCw />}
|
||||
>
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
@ -926,7 +927,7 @@ export default function ModelsPage() {
|
||||
/>
|
||||
</div>
|
||||
{!showTokens && (
|
||||
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||
<p className="mt-4 text-xs text-text-tertiary leading-relaxed">
|
||||
Token & cost analytics are hidden because the local counts
|
||||
exclude auxiliary calls (compression, vision, web extract,
|
||||
…) and provider retries, so they diverge from your provider
|
||||
@ -977,7 +978,7 @@ export default function ModelsPage() {
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<Cpu className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{t.models.noModelsData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
<p className="text-xs mt-1 text-text-tertiary">
|
||||
{t.models.startSession}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react";
|
||||
import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/lib/api";
|
||||
@ -39,7 +39,7 @@ export default function PluginsPage() {
|
||||
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
const { setAfterTitle } = usePageHeader();
|
||||
|
||||
const loadHub = useCallback(() => {
|
||||
return api
|
||||
@ -59,22 +59,20 @@ export default function PluginsPage() {
|
||||
}, [loadHub]);
|
||||
|
||||
useEffect(() => {
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 justify-start sm:justify-end">
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="w-max max-w-full shrink-0 gap-2"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.refreshDashboard}
|
||||
</Button>
|
||||
</div>,
|
||||
setAfterTitle(
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
aria-label={t.pluginsPage.refreshDashboard}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw />}
|
||||
</Button>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
|
||||
return () => setAfterTitle(null);
|
||||
}, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]);
|
||||
|
||||
const onInstall = async () => {
|
||||
const id = installId.trim();
|
||||
@ -160,7 +158,7 @@ export default function PluginsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
|
||||
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
|
||||
<p className="text-xs tracking-[0.08em] text-text-tertiary">
|
||||
{t.pluginsPage.providersHint}
|
||||
</p>
|
||||
</CardHeader>
|
||||
@ -212,13 +210,13 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-fit gap-2"
|
||||
className="w-fit uppercase"
|
||||
size="sm"
|
||||
disabled={providerBusy}
|
||||
onClick={() => void onSaveProviders()}
|
||||
prefix={providerBusy ? <Spinner /> : undefined}
|
||||
>
|
||||
{providerBusy ? <Spinner /> : null}
|
||||
{t.pluginsPage.saveProviders}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -227,7 +225,7 @@ export default function PluginsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
|
||||
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
|
||||
<p className="text-xs tracking-[0.08em] text-text-tertiary">
|
||||
{t.pluginsPage.installHint}
|
||||
</p>
|
||||
</CardHeader>
|
||||
@ -240,7 +238,7 @@ export default function PluginsPage() {
|
||||
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
|
||||
|
||||
<Input
|
||||
className="normal-case font-sans lowercase"
|
||||
className="font-mono-ui lowercase"
|
||||
id="install-url"
|
||||
placeholder="owner/repo or https://..."
|
||||
spellCheck={false}
|
||||
@ -256,7 +254,7 @@ export default function PluginsPage() {
|
||||
|
||||
<Switch checked={installForce} onCheckedChange={setInstallForce} />
|
||||
|
||||
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
|
||||
<span className="text-xs tracking-[0.06em] text-text-secondary">
|
||||
{t.pluginsPage.forceReinstall}
|
||||
</span>
|
||||
</div>
|
||||
@ -265,27 +263,27 @@ export default function PluginsPage() {
|
||||
|
||||
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
|
||||
|
||||
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
|
||||
<span className="text-xs tracking-[0.06em] text-text-secondary">
|
||||
{t.pluginsPage.enableAfterInstall}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-fit gap-2"
|
||||
className="w-fit uppercase"
|
||||
size="sm"
|
||||
disabled={installBusy}
|
||||
onClick={() => void onInstall()}
|
||||
prefix={installBusy ? <Spinner /> : undefined}
|
||||
>
|
||||
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.installBtn}
|
||||
</Button>
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
|
||||
<p className="text-xs tracking-[0.06em] text-text-tertiary">
|
||||
{t.pluginsPage.rescanHint}
|
||||
</p>
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
|
||||
<p className="text-xs tracking-[0.06em] text-text-tertiary">
|
||||
{t.pluginsPage.removeHint}
|
||||
</p>
|
||||
</CardContent>
|
||||
@ -293,20 +291,20 @@ export default function PluginsPage() {
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
|
||||
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
{t.pluginsPage.pluginListHeading}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
|
||||
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
|
||||
<div className="flex items-center gap-2 py-8 text-xs text-text-tertiary">
|
||||
|
||||
<Spinner />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
|
||||
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
|
||||
<p className="text-xs text-text-tertiary">{t.common.noResults}</p>
|
||||
) : (
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
@ -331,7 +329,7 @@ export default function PluginsPage() {
|
||||
|
||||
<div className="flex flex-col gap-3 opacity-95">
|
||||
|
||||
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
|
||||
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
{t.pluginsPage.orphanHeading}
|
||||
</h3>
|
||||
|
||||
@ -339,7 +337,7 @@ export default function PluginsPage() {
|
||||
|
||||
{hub!.orphan_dashboard_plugins.map((m) => (
|
||||
|
||||
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
|
||||
<li className="text-xs text-text-secondary" key={m.name}>
|
||||
|
||||
|
||||
{m.label ?? m.name} — {m.description || m.tab?.path}
|
||||
@ -433,36 +431,35 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
|
||||
|
||||
<Button
|
||||
disabled={busy || row.runtime_status === "enabled"}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.enableAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.enableRuntime, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.pluginsPage.enableRuntime}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
disabled={busy || row.runtime_status === "disabled"}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.disableAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.disableRuntime, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.pluginsPage.disableRuntime}
|
||||
</Button>
|
||||
{row.runtime_status === "enabled" ? (
|
||||
<Button
|
||||
disabled={busy}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.disableAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.disableRuntime, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.pluginsPage.disableRuntime}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={busy}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.enableAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.enableRuntime, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.pluginsPage.enableRuntime}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tabPath ? (
|
||||
|
||||
@ -470,7 +467,7 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-none px-3 py-1.5",
|
||||
"border border-current/25 hover:bg-current/10",
|
||||
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
)}
|
||||
to={tabPath}
|
||||
>
|
||||
@ -535,14 +532,14 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||
</div>
|
||||
|
||||
{row.description ? (
|
||||
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words">
|
||||
<p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
|
||||
{row.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{dm?.slots?.length ? (
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
||||
<p className="text-xs tracking-[0.05em] text-text-tertiary">
|
||||
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
@ -557,7 +554,7 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||
{!row.has_dashboard_manifest && !dm ? (
|
||||
|
||||
|
||||
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
|
||||
<p className="text-xs italic text-text-disabled">
|
||||
{t.pluginsPage.noDashboardTab}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
import {
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Plus,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Users,
|
||||
@ -21,7 +20,7 @@ import type { ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useModalBehavior } from "@nous-research/ui/hooks/use-modal-behavior";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
@ -31,6 +30,7 @@ import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
|
||||
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
||||
@ -231,8 +231,11 @@ export default function ProfilesPage() {
|
||||
// Put "Create" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button size="sm" onClick={() => setCreateModalOpen(true)}>
|
||||
<Plus className="h-3 w-3" />
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
);
|
||||
@ -256,10 +259,7 @@ export default function ProfilesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
// Profile names, model slugs, and paths are case-sensitive; opt out of
|
||||
// the app shell's global ``uppercase`` so they render as the user typed.
|
||||
// Children that explicitly opt back in (Badges, etc.) keep their casing.
|
||||
<div className="flex flex-col gap-6 normal-case">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
@ -287,7 +287,7 @@ export default function ProfilesPage() {
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-profile-title"
|
||||
>
|
||||
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col">
|
||||
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
@ -301,7 +301,7 @@ export default function ProfilesPage() {
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="create-profile-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
{t.profiles.newProfile}
|
||||
</h2>
|
||||
@ -339,7 +339,7 @@ export default function ProfilesPage() {
|
||||
/>
|
||||
|
||||
<Label
|
||||
className="font-sans normal-case tracking-normal text-sm cursor-pointer"
|
||||
className="font-mondwest normal-case tracking-normal text-sm cursor-pointer"
|
||||
htmlFor="clone-from-default"
|
||||
>
|
||||
{t.profiles.cloneFromDefault}
|
||||
@ -347,8 +347,12 @@ export default function ProfilesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="h-3 w-3" />
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
@ -523,7 +527,7 @@ export default function ProfilesPage() {
|
||||
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor={`soul-editor-${p.name}`}
|
||||
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground"
|
||||
className="flex items-center gap-2 font-mondwest text-display text-xs tracking-wider text-muted-foreground"
|
||||
>
|
||||
{t.profiles.soulSection}
|
||||
</Label>
|
||||
@ -537,10 +541,11 @@ export default function ProfilesPage() {
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => handleSaveSoul(p.name)}
|
||||
disabled={soulSaving}
|
||||
>
|
||||
{soulSaving ? t.common.saving : t.profiles.saveSoul}
|
||||
{soulSaving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,6 +37,7 @@ import { PlatformsCard } from "@/components/PlatformsCard";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Segmented } from "@nous-research/ui/ui/components/segmented";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
@ -83,7 +84,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
||||
parts.push(snippet.slice(last));
|
||||
}
|
||||
return (
|
||||
<p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80">
|
||||
<p className="font-mondwest normal-case mt-0.5 min-w-0 max-w-full truncate text-xs text-text-secondary">
|
||||
{parts}
|
||||
</p>
|
||||
);
|
||||
@ -191,12 +192,12 @@ function MessageBubble({
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
|
||||
{isHit && (
|
||||
<Badge tone="warning" className="text-[9px] py-0 px-1.5">
|
||||
<Badge tone="warning" className="text-xs py-0 px-1.5">
|
||||
{t.common.match}
|
||||
</Badge>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{timeAgo(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
@ -294,6 +295,43 @@ function SessionRow({
|
||||
const SourceIcon = sourceInfo.icon;
|
||||
const hasTitle = session.title && session.title !== "Untitled";
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Badge tone="outline" className="text-xs">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
|
||||
{resumeInChatEnabled && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
|
||||
@ -310,76 +348,54 @@ function SessionRow({
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
>
|
||||
{hasTitle
|
||||
? session.title
|
||||
: session.preview
|
||||
? session.preview.slice(0, 60)
|
||||
: t.sessions.untitledSession}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge tone="success" className="shrink-0 text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
>
|
||||
{hasTitle
|
||||
? session.title
|
||||
: session.preview
|
||||
? session.preview.slice(0, 60)
|
||||
: t.sessions.untitledSession}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge tone="success" className="shrink-0 text-xs">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
|
||||
{(session.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">
|
||||
{session.message_count} {t.common.msgs}
|
||||
</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
|
||||
{(session.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">
|
||||
{session.message_count} {t.common.msgs}
|
||||
</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">{timeAgo(session.last_active)}</span>
|
||||
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
{resumeInChatEnabled && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:hidden">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -408,11 +424,62 @@ function SessionRow({
|
||||
);
|
||||
}
|
||||
|
||||
type SessionsView = "list" | "overview";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function SessionsPagination({
|
||||
className,
|
||||
compact = false,
|
||||
onPageChange,
|
||||
page,
|
||||
total,
|
||||
}: SessionsPaginationProps) {
|
||||
const { t } = useI18n();
|
||||
const pageCount = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center ${compact ? "gap-1" : "justify-between pt-2"}${className ? ` ${className}` : ""}`}
|
||||
>
|
||||
{!compact && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
|
||||
{t.common.of} {total}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
outlined
|
||||
size="icon"
|
||||
disabled={page === 0}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label={t.sessions.previousPage}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="px-2 text-xs text-muted-foreground">
|
||||
{t.common.page} {page + 1} {t.common.of} {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
outlined
|
||||
size="icon"
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label={t.sessions.nextPage}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 20;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@ -424,16 +491,16 @@ export default function SessionsPage() {
|
||||
const logScrollRef = useRef<HTMLPreElement | null>(null);
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
|
||||
const [view, setView] = useState<SessionsView>("overview");
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const { setAfterTitle } = usePageHeader();
|
||||
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
||||
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (loading) {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
setAfterTitle(
|
||||
@ -441,46 +508,10 @@ export default function SessionsPage() {
|
||||
{total}
|
||||
</Badge>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
{searching ? (
|
||||
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder={t.sessions.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pr-7 pl-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<Button
|
||||
ghost
|
||||
size="xs"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={t.common.clear}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [
|
||||
loading,
|
||||
search,
|
||||
searching,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
t.common.clear,
|
||||
t.sessions.searchPlaceholder,
|
||||
total,
|
||||
]);
|
||||
}, [loading, setAfterTitle, total]);
|
||||
|
||||
const loadSessions = useCallback((p: number) => {
|
||||
setLoading(true);
|
||||
@ -591,6 +622,16 @@ export default function SessionsPage() {
|
||||
.filter((s) => !s.is_active)
|
||||
.slice(0, 5);
|
||||
|
||||
const isSearching = Boolean(search.trim());
|
||||
const showOverviewTab =
|
||||
platformEntries.length > 0 || recentSessions.length > 0;
|
||||
const showList = view === "list" || isSearching || !showOverviewTab;
|
||||
const showPagination = showList && !searchResults && total > PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) setView("list");
|
||||
}, [isSearching]);
|
||||
|
||||
const alerts: { message: string; detail?: string }[] = [];
|
||||
if (status) {
|
||||
if (status.gateway_state === "startup_failed") {
|
||||
@ -692,7 +733,7 @@ export default function SessionsPage() {
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[10px] shrink-0"
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{actionStatus?.running
|
||||
? t.status.running
|
||||
@ -708,7 +749,7 @@ export default function SessionsPage() {
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={dismissLog}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
className="shrink-0 text-text-secondary hover:text-foreground"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<X />
|
||||
@ -717,7 +758,7 @@ export default function SessionsPage() {
|
||||
|
||||
<pre
|
||||
ref={logScrollRef}
|
||||
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
|
||||
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-xs leading-relaxed whitespace-pre-wrap break-all"
|
||||
>
|
||||
{actionStatus?.lines && actionStatus.lines.length > 0
|
||||
? actionStatus.lines.join("\n")
|
||||
@ -726,126 +767,170 @@ export default function SessionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platformEntries.length > 0 && status && (
|
||||
<PlatformsCard platforms={platformEntries} />
|
||||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="min-w-0 truncate text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid min-w-0 gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 break-words text-xs text-muted-foreground">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
|
||||
{s.preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
tone="outline"
|
||||
className="shrink-0 self-start text-[10px] sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">
|
||||
{search ? t.sessions.noMatch : t.sessions.noSessions}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
{t.sessions.startConversation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => sessionDelete.requestDelete(s.id)}
|
||||
resumeInChatEnabled={resumeInChatEnabled}
|
||||
{(showOverviewTab && !isSearching) || showList ? (
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
|
||||
{showOverviewTab && !isSearching && (
|
||||
<Segmented
|
||||
className="w-fit shrink-0"
|
||||
size="md"
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "overview", label: t.sessions.overview },
|
||||
{ value: "list", label: t.sessions.history },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{showList && (
|
||||
<div className="relative min-w-0 w-full sm:w-auto sm:min-w-[12rem] sm:max-w-md sm:flex-1">
|
||||
{searching ? (
|
||||
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder={t.sessions.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
|
||||
/>
|
||||
{search && (
|
||||
<Button
|
||||
ghost
|
||||
size="xs"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={t.common.clear}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!searchResults && total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
|
||||
{t.common.of} {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
outlined
|
||||
size="icon"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label={t.sessions.previousPage}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
{t.common.page} {page + 1} {t.common.of}{" "}
|
||||
{Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<Button
|
||||
outlined
|
||||
size="icon"
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label={t.sessions.nextPage}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<SessionsPagination
|
||||
compact
|
||||
className="shrink-0 sm:ml-auto"
|
||||
page={page}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showList ? (
|
||||
filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">
|
||||
{search ? t.sessions.noMatch : t.sessions.noSessions}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-text-tertiary">
|
||||
{t.sessions.startConversation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => sessionDelete.requestDelete(s.id)}
|
||||
resumeInChatEnabled={resumeInChatEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showPagination && (
|
||||
<SessionsPagination
|
||||
page={page}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{platformEntries.length > 0 && status && (
|
||||
<PlatformsCard platforms={platformEntries} />
|
||||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="min-w-0 truncate text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid min-w-0 gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="font-mondwest normal-case min-w-0 truncate text-sm font-medium">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 break-words text-xs text-muted-foreground">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<p className="font-mondwest normal-case min-w-0 max-w-full text-xs leading-snug text-text-tertiary [overflow-wrap:anywhere]">
|
||||
{s.preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
tone="outline"
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="sessions:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionsPaginationProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@ -258,8 +258,8 @@ export default function SkillsPage() {
|
||||
<div className="sm:sticky sm:top-0">
|
||||
<div className="flex flex-col rounded-none border border-border bg-muted/20">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
<Filter className="h-3 w-3 text-text-tertiary" />
|
||||
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
{t.skills.filters}
|
||||
</span>
|
||||
</div>
|
||||
@ -290,7 +290,7 @@ export default function SkillsPage() {
|
||||
!isSearching &&
|
||||
allCategories.length > 0 && (
|
||||
<div className="hidden sm:flex flex-col border-t border-border">
|
||||
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
<div className="px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
|
||||
{t.skills.categories}
|
||||
</div>
|
||||
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
|
||||
@ -304,14 +304,14 @@ export default function SkillsPage() {
|
||||
onClick={() =>
|
||||
setActiveCategory(isActive ? null : key)
|
||||
}
|
||||
className="rounded-none px-2 py-1 text-[11px]"
|
||||
className="rounded-none px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
className={`text-xs tabular-nums ${
|
||||
isActive
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/50"
|
||||
? "text-text-secondary"
|
||||
: "text-text-tertiary"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
@ -335,7 +335,7 @@ export default function SkillsPage() {
|
||||
<Search className="h-4 w-4" />
|
||||
{t.skills.title}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{t.skills.resultCount
|
||||
.replace("{count}", String(searchMatchedSkills.length))
|
||||
.replace(
|
||||
@ -379,7 +379,7 @@ export default function SkillsPage() {
|
||||
)
|
||||
: t.skills.all}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{t.skills.skillCount
|
||||
.replace("{count}", String(activeSkills.length))
|
||||
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
@ -437,18 +437,18 @@ export default function SkillsPage() {
|
||||
</span>
|
||||
<Badge
|
||||
tone={ts.enabled ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
className="text-xs"
|
||||
>
|
||||
{ts.enabled
|
||||
? t.common.active
|
||||
: t.common.inactive}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<p className="text-xs text-text-secondary mb-2">
|
||||
{ts.description}
|
||||
</p>
|
||||
{ts.enabled && !ts.configured && (
|
||||
<p className="text-[10px] text-amber-300/80 mb-2">
|
||||
<p className="text-xs text-amber-300 mb-2">
|
||||
{t.skills.setupNeeded}
|
||||
</p>
|
||||
)}
|
||||
@ -458,7 +458,7 @@ export default function SkillsPage() {
|
||||
<Badge
|
||||
key={tool}
|
||||
tone="secondary"
|
||||
className="text-[10px] font-mono"
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{tool}
|
||||
</Badge>
|
||||
@ -466,7 +466,7 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
)}
|
||||
{ts.tools.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{ts.enabled
|
||||
? t.skills.toolsetLabel.replace(
|
||||
"{name}",
|
||||
|
||||
@ -35,7 +35,7 @@ export function PluginPage({ name }: { name: string }) {
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-lg p-4",
|
||||
"font-mondwest text-sm tracking-[0.08em] text-midground/80",
|
||||
"font-mondwest text-sm tracking-[0.08em] text-text-secondary",
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
@ -48,7 +48,7 @@ export function PluginPage({ name }: { name: string }) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-4",
|
||||
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
|
||||
"font-mondwest text-sm tracking-[0.1em] text-text-tertiary",
|
||||
)}
|
||||
>
|
||||
<Spinner className="shrink-0" />
|
||||
|
||||
Reference in New Issue
Block a user