From e116957a63706bea112bb44aa77dbb42aae17dfe Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 28 Apr 2026 08:57:33 -0400 Subject: [PATCH 01/10] fix: replace all buttons for design system buttons --- web/src/App.tsx | 45 ++++++++++++++--------- web/src/components/ChatSidebar.tsx | 9 ++--- web/src/components/ModelPickerDialog.tsx | 6 +-- web/src/components/OAuthLoginModal.tsx | 16 +++----- web/src/components/OAuthProvidersCard.tsx | 45 ++++++++--------------- web/src/components/ui/button.tsx | 38 ------------------- web/src/components/ui/confirm-dialog.tsx | 8 ++-- web/src/pages/AnalyticsPage.tsx | 12 ++---- web/src/pages/ConfigPage.tsx | 31 +++++----------- web/src/pages/CronPage.tsx | 22 +++++------ web/src/pages/DocsPage.tsx | 16 +++++--- web/src/pages/EnvPage.tsx | 30 +++++++-------- web/src/pages/LogsPage.tsx | 8 ++-- web/src/pages/SessionsPage.tsx | 22 +++++------ web/src/plugins/registry.ts | 2 +- 15 files changed, 117 insertions(+), 193 deletions(-) delete mode 100644 web/src/components/ui/button.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 65b4d800..841108a5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -160,7 +160,10 @@ function resolveIcon(name: string): ComponentType<{ className?: string }> { return ICON_MAP[name] ?? Puzzle; } -function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] { +function buildNavItems( + builtIn: NavItem[], + manifests: PluginManifest[], +): NavItem[] { const items = [...builtIn]; for (const manifest of manifests) { @@ -425,18 +428,22 @@ export default function App() { > - - Hermes - - Agent - + + + + + Hermes + + Agent + + - - @@ -579,8 +585,9 @@ export default function App() { or idle-disconnect after N minutes hidden; neither is shipped today. */} - {embeddedChat && !chatOverriddenByPlugin && ( - pluginsLoading ? ( + {embeddedChat && + !chatOverriddenByPlugin && + (pluginsLoading ? ( // Direct /chat deep-link: plugin manifests haven't resolved // yet, so we can't tell if a plugin is going to claim this // route. Show a lightweight placeholder instead of a @@ -593,7 +600,10 @@ export default function App() { aria-live="polite" > - + Loading chat… @@ -609,8 +619,7 @@ export default function App() { > - ) - )} + ))} diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 6bfac9cf..05f07a33 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -23,8 +23,8 @@ * terminal pane keeps working unimpaired. */ +import { Button } from "@nous-research/ui"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; @@ -337,12 +337,11 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { {error && ( } > - reconnect )} diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index d30fb8dd..81bb5abf 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -1,4 +1,4 @@ -import { Button } from "@/components/ui/button"; +import { Button } from "@nous-research/ui"; import { Input } from "@/components/ui/input"; import type { GatewayClient } from "@/lib/gatewayClient"; import { Check, Loader2, Search, X } from "lucide-react"; @@ -222,10 +222,10 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) { - + Cancel - + Switch diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 66c78139..a6cd2ca2 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; -import { H2 } from "@nous-research/ui"; +import { Button, H2 } from "@nous-research/ui"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useI18n } from "@/i18n"; @@ -254,7 +253,6 @@ export function OAuthLoginModal({ {t.oauth.submitCode} @@ -289,8 +287,7 @@ export function OAuthLoginModal({ } handleCopyUserCode( ( @@ -301,12 +298,12 @@ export function OAuthLoginModal({ ).user_code, ) } - className="text-xs" + className="!p-2 aspect-square" > {codeCopied ? ( - + ) : ( - + )} @@ -348,11 +345,10 @@ export function OAuthLoginModal({ {errorMsg || t.oauth.loginFailed} - + {t.common.close} { if (start?.session_id) { api.cancelOAuthSession(start.session_id).catch(() => {}); diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 94084878..70672908 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react"; import { api, type OAuthProvider } from "@/lib/api"; +import { Button } from "@nous-research/ui"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; import { useI18n } from "@/i18n"; @@ -94,13 +94,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { {t.oauth.providerLogins} } > - {t.common.refresh} @@ -194,53 +192,42 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { className="inline-flex" title={`Open ${p.name} docs`} > - + )} {!p.status.logged_in && p.flow !== "external" && ( setLoginFor(p)} - className="text-xs h-7" + prefix={} > - {t.oauth.login} )} {!p.status.logged_in && ( handleCopy(p)} - className="text-xs h-7" title={t.oauth.copyCliCommand} + prefix={copiedId === p.id ? undefined : } > - {copiedId === p.id ? ( - <>{t.oauth.copied}> - ) : ( - <> - - {t.oauth.cli} - > - )} + {copiedId === p.id ? t.oauth.copied : t.oauth.cli} )} {p.status.logged_in && p.flow !== "external" && ( handleDisconnect(p)} disabled={isBusy} - className="text-xs h-7" + prefix={ + isBusy ? ( + + ) : ( + + ) + } > - {isBusy ? ( - - ) : ( - - )} {t.oauth.disconnect} )} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx deleted file mode 100644 index 8f2f2720..00000000 --- a/web/src/components/ui/button.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; - -export const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap font-mondwest text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer" - + " disabled:pointer-events-none disabled:opacity-50", - { - variants: { - variant: { - default: "bg-foreground/90 text-background hover:bg-foreground", - destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: "border border-border bg-transparent hover:bg-foreground/10 hover:text-foreground", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-foreground/10 hover:text-foreground", - link: "text-foreground underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 px-3 text-[0.65rem]", - lg: "h-10 px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -export function Button({ - className, - variant, - size, - ...props -}: React.ButtonHTMLAttributes & VariantProps) { - return ; -} diff --git a/web/src/components/ui/confirm-dialog.tsx b/web/src/components/ui/confirm-dialog.tsx index 48e58264..6e735383 100644 --- a/web/src/components/ui/confirm-dialog.tsx +++ b/web/src/components/ui/confirm-dialog.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { AlertTriangle } from "lucide-react"; +import { Button } from "@nous-research/ui"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; export function ConfirmDialog({ cancelLabel = "Cancel", @@ -101,8 +101,7 @@ export function ConfirmDialog({ @@ -111,10 +110,9 @@ export function ConfirmDialog({ {loading ? "…" : confirmLabel} diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 63dd15e4..e0574782 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -10,9 +10,9 @@ import { import { api } from "@/lib/api"; import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; +import { Button } from "@nous-research/ui"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; @@ -317,9 +317,7 @@ export default function AnalyticsPage() { setDays(p.days)} > {p.label} @@ -328,13 +326,11 @@ export default function AnalyticsPage() { } > - {t.common.refresh} , diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 8705ac4c..0e943dec 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -33,8 +33,8 @@ import { getNestedValue, setNestedValue } from "@/lib/nested"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { AutoField } from "@/components/AutoField"; +import { Button } from "@nous-research/ui"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/i18n"; @@ -345,10 +345,10 @@ export default function ConfigPage() { - + - fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}> + fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig} className="!p-2 aspect-square"> @@ -358,7 +358,7 @@ export default function ConfigPage() { : prettyCategoryName(activeCategory); const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel); return ( - + ); @@ -367,32 +367,19 @@ export default function ConfigPage() { setYamlMode(!yamlMode)} - className="gap-1.5" + prefix={yamlMode ? : } > - {yamlMode ? ( - <> - - {t.common.form} - > - ) : ( - <> - - YAML - > - )} + {yamlMode ? t.common.form : "YAML"} {yamlMode ? ( - - + }> {yamlSaving ? t.common.saving : t.common.save} ) : ( - - + }> {saving ? t.common.saving : t.common.save} )} diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 63478fa7..569e4559 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; -import { H2 } from "@nous-research/ui"; +import { Button, H2 } from "@nous-research/ui"; import { api } from "@/lib/api"; import type { CronJob } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; @@ -9,7 +9,6 @@ import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { Toast } from "@/components/Toast"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectOption } from "@/components/ui/select"; @@ -166,7 +165,6 @@ export default function CronPage() { loading={jobDelete.isDeleting} /> - {/* Create new job form */} @@ -237,9 +235,9 @@ export default function CronPage() { } className="w-full" > - {creating ? t.common.creating : t.common.create} @@ -248,7 +246,6 @@ export default function CronPage() { - {/* Jobs list */} ( - {/* Info */} @@ -306,16 +302,15 @@ export default function CronPage() { )} - {/* Actions */} handlePauseResume(job)} + className="!p-2 aspect-square" > {job.state === "paused" ? ( @@ -325,21 +320,21 @@ export default function CronPage() { handleTrigger(job)} + className="!p-2 aspect-square" > jobDelete.requestDelete(job.id)} + className="!p-2 aspect-square" > @@ -348,6 +343,7 @@ export default function CronPage() { ))} + ); diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx index 2e1a6491..95ef2718 100644 --- a/web/src/pages/DocsPage.tsx +++ b/web/src/pages/DocsPage.tsx @@ -2,12 +2,19 @@ import { useLayoutEffect } from "react"; import { ExternalLink } from "lucide-react"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; -import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { PluginSlot } from "@/plugins"; export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/"; +const DS_BUTTON_OUTLINED_LINK_CN = cn( + "group relative inline-grid grid-cols-[auto_1fr_auto] items-center", + "px-[.9em_.75em] py-[1.25em] gap-2", + "leading-0 font-bold tracking-[0.2em] uppercase", + "text-midground bg-transparent shadow-midground", + "shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]", +); + export default function DocsPage() { const { t } = useI18n(); const { setEnd } = usePageHeader(); @@ -18,12 +25,9 @@ export default function DocsPage() { href={HERMES_DOCS_URL} target="_blank" rel="noopener noreferrer" - className={cn( - buttonVariants({ variant: "outline", size: "sm" }), - "h-7 text-xs", - )} + className={DS_BUTTON_OUTLINED_LINK_CN} > - + {t.app.openDocumentation} , ); diff --git a/web/src/pages/EnvPage.tsx b/web/src/pages/EnvPage.tsx index 7ece6912..c5bf1b61 100644 --- a/web/src/pages/EnvPage.tsx +++ b/web/src/pages/EnvPage.tsx @@ -21,9 +21,9 @@ import { Toast } from "@/components/Toast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { useToast } from "@/hooks/useToast"; import { OAuthProvidersCard } from "@/components/OAuthProvidersCard"; +import { Button } from "@nous-research/ui"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; @@ -134,9 +134,8 @@ function EnvVarRow({ {t.env.getKey} )} - } onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}> - {t.common.set} @@ -159,9 +158,8 @@ function EnvVarRow({ {t.env.getKey} )} - } onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}> - {t.common.set} @@ -206,26 +204,25 @@ function EnvVarRow({ {info.is_set && ( - onReveal(varKey)} + onReveal(varKey)} title={isRevealed ? t.env.hideValue : t.env.showValue} - aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}> + aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`} + className="!p-2 aspect-square"> {isRevealed ? : } )} - } onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}> - {info.is_set ? t.common.replace : t.common.set} {info.is_set && ( - } + className="text-destructive hover:!text-destructive" onClick={() => onClear(varKey)} disabled={saving === varKey || clearDialogOpen}> - {saving === varKey ? "..." : t.common.clear} )} @@ -238,13 +235,12 @@ function EnvVarRow({ onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))} placeholder={info.is_set ? t.env.replaceCurrentValue.replace("{preview}", info.redacted_value ?? "---") : t.env.enterValue} className="flex-1 font-mono-ui text-xs" /> - onSave(varKey)} + onSave(varKey)} prefix={} disabled={saving === varKey || !edits[varKey]}> - {saving === varKey ? "..." : t.common.save} - onCancelEdit(varKey)}> - {t.common.cancel} + } onClick={() => onCancelEdit(varKey)}> + {t.common.cancel} )} @@ -537,7 +533,7 @@ export default function EnvPage() { {t.env.changesNote}
{sub}
Date: Tue, 28 Apr 2026 16:20:05 +0000 Subject: [PATCH 06/10] fix(nix): refresh npm lockfile hashes --- nix/web.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/web.nix b/nix/web.nix index e79826fe..fbde22af 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-4Z8KQ69QhO83X6zff+5urWBv6MME686MhTTMdwSl65o="; + hash = "sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; From e1027134cda1b27beeafd210707572f7d0b5ac45 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 28 Apr 2026 12:28:08 -0400 Subject: [PATCH 07/10] chore: remove comments --- web/src/App.tsx | 26 -- web/src/components/Backdrop.tsx | 10 +- web/src/components/LanguageSwitcher.tsx | 1 - web/src/components/ModelInfoCard.tsx | 21 +- web/src/components/OAuthLoginModal.tsx | 12 +- web/src/components/OAuthProvidersCard.tsx | 61 ++- web/src/pages/AnalyticsPage.tsx | 205 +++++++--- web/src/pages/ConfigPage.tsx | 175 +++++--- web/src/pages/EnvPage.tsx | 467 +++++++++++++++------- web/src/pages/LogsPage.tsx | 22 +- web/src/pages/SessionsPage.tsx | 13 +- web/src/pages/SkillsPage.tsx | 86 ++-- 12 files changed, 721 insertions(+), 378 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4f7f4189..65a9ede4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -558,35 +558,9 @@ export default function App() { /> - {/* - Persistent chat host: always mounted when `hermes dashboard - --tui` is active, visibility toggled by route. Keeping the - tree alive preserves the xterm instance, its WebSocket, and - the PTY child that backs the TUI session — so navigating to - another tab and returning lands the user in the same - conversation instead of spawning a fresh session. - - The host sits alongside (not inside one) because - React Router unmounts route elements on path change, which - is exactly the destructive lifecycle we're avoiding. - - Trade-off worth knowing about: while hidden, ChatPage still - holds a PTY child + WebSocket + xterm instance for the - dashboard's full lifetime. The WS keeps delivering bytes - and xterm keeps parsing them into a display:none host - (cheap — no paint work, but not free). If this becomes a - resource problem we can pause `term.write` when !isActive - or idle-disconnect after N minutes hidden; neither is - shipped today. - */} {embeddedChat && !chatOverriddenByPlugin && (pluginsLoading ? ( - // Direct /chat deep-link: plugin manifests haven't resolved - // yet, so we can't tell if a plugin is going to claim this - // route. Show a lightweight placeholder instead of a - // blank page. Typical wait is <50ms; worst case is the - // 2s plugin-registration safety timeout. isChatRoute ? ( hides itself when a CSS bg is set // so the two don't double-darken. CSS var fallbacks keep the // default behaviour unchanged when no theme customises these. - mixBlendMode: "var(--component-backdrop-filler-blend-mode, difference)", + mixBlendMode: + "var(--component-backdrop-filler-blend-mode, difference)", opacity: "var(--component-backdrop-filler-opacity, 0.033)", backgroundImage: "var(--theme-asset-bg)", backgroundSize: "var(--component-backdrop-background-size, cover)", - backgroundPosition: "var(--component-backdrop-background-position, center)", + backgroundPosition: + "var(--component-backdrop-background-position, center)", } as unknown as React.CSSProperties } > - {/* Default filler image only renders when no theme-asset-bg is - set. Themes that provide their own `assets.bg` override the - 's backgroundImage above, so hiding the in that - case prevents the two from compositing incorrectly. */} - {/* Show the *current* language's flag — tooltip advertises the click action */} {locale === "en" ? "🇬🇧" : "🇨🇳"} diff --git a/web/src/components/ModelInfoCard.tsx b/web/src/components/ModelInfoCard.tsx index 1a78710e..b398d2b8 100644 --- a/web/src/components/ModelInfoCard.tsx +++ b/web/src/components/ModelInfoCard.tsx @@ -1,12 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { - Brain, - Eye, - Gauge, - Lightbulb, - Wrench, - Loader2, -} from "lucide-react"; +import { Brain, Eye, Gauge, Lightbulb, Wrench, Loader2 } from "lucide-react"; import { api } from "@/lib/api"; import type { ModelInfoResponse } from "@/lib/api"; import { formatTokenCount } from "@/lib/format"; @@ -18,7 +11,10 @@ interface ModelInfoCardProps { refreshKey?: number; } -export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardProps) { +export function ModelInfoCard({ + currentModel, + refreshKey = 0, +}: ModelInfoCardProps) { const [info, setInfo] = useState(null); const [loading, setLoading] = useState(false); const lastFetchKeyRef = useRef(""); @@ -53,7 +49,6 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro return ( - {/* Context window */} @@ -68,12 +63,13 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro (override — auto: {formatTokenCount(info.auto_context_length)}) ) : ( - auto-detected + + auto-detected + )} - {/* Max output */} {hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && ( @@ -86,7 +82,6 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro )} - {/* Capability badges */} {hasCaps && ( {caps.supports_tools && ( diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 8a85d79a..67c3d885 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -21,11 +21,7 @@ type Phase = | "approved" | "error"; -export function OAuthLoginModal({ - provider, - onClose, - onSuccess, -}: Props) { +export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { const [phase, setPhase] = useState("starting"); const [start, setStart] = useState(null); const [pkceCode, setPkceCode] = useState(""); @@ -202,7 +198,6 @@ export function OAuthLoginModal({ )} - {/* ── starting ───────────────────────────────────── */} {phase === "starting" && ( @@ -210,7 +205,6 @@ export function OAuthLoginModal({ )} - {/* ── PKCE: paste code ───────────────────────────── */} {start?.flow === "pkce" && phase === "awaiting_user" && ( <> @@ -250,7 +244,6 @@ export function OAuthLoginModal({ > )} - {/* ── PKCE: submitting exchange ──────────────────── */} {phase === "submitting" && ( @@ -258,7 +251,6 @@ export function OAuthLoginModal({ )} - {/* ── Device code: show code + URL, polling ──────── */} {start?.flow === "device_code" && phase === "polling" && ( <> @@ -309,7 +301,6 @@ export function OAuthLoginModal({ > )} - {/* ── approved ───────────────────────────────────── */} {phase === "approved" && ( @@ -317,7 +308,6 @@ export function OAuthLoginModal({ )} - {/* ── error ──────────────────────────────────────── */} {phase === "error" && ( <> diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index c7faadb8..842be557 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -1,8 +1,22 @@ import { useEffect, useState, useCallback, useRef } from "react"; -import { ShieldCheck, ShieldOff, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react"; +import { + ShieldCheck, + ShieldOff, + ExternalLink, + RefreshCw, + LogOut, + Terminal, + LogIn, +} from "lucide-react"; import { api, type OAuthProvider } from "@/lib/api"; import { Button, CopyButton } from "@nous-research/ui"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Badge } from "@nous-research/ui"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; import { useI18n } from "@/i18n"; @@ -12,7 +26,10 @@ interface Props { onSuccess?: (msg: string) => void; } -function formatExpiresAt(expiresAt: string | null | undefined, expiresInTemplate: string): string | null { +function formatExpiresAt( + expiresAt: string | null | undefined, + expiresInTemplate: string, +): string | null { if (!expiresAt) return null; try { const dt = new Date(expiresAt); @@ -70,7 +87,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { } }; - const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0; + const connectedCount = + providers?.filter((p) => p.status.logged_in).length ?? 0; const totalCount = providers?.length ?? 0; return ( @@ -79,19 +97,25 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { - {t.oauth.providerLogins} + + {t.oauth.providerLogins} + } + prefix={ + + } > {t.common.refresh} - {t.oauth.description.replace("{connected}", String(connectedCount)).replace("{total}", String(totalCount))} + {t.oauth.description + .replace("{connected}", String(connectedCount)) + .replace("{total}", String(totalCount))} @@ -107,14 +131,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { )} {providers?.map((p) => { - const expiresLabel = formatExpiresAt(p.status.expires_at, t.oauth.expiresIn); + const expiresLabel = formatExpiresAt( + p.status.expires_at, + t.oauth.expiresIn, + ); const isBusy = busyId === p.id; return ( - {/* Left: status icon + name + source */} {p.status.logged_in ? ( @@ -124,7 +150,10 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { {p.name} - + {t.oauth.flowLabels[p.flow]} {p.status.logged_in && ( @@ -145,11 +174,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { {p.status.logged_in && p.status.token_preview && ( - token{" "} + token {p.status.token_preview} {p.status.source_label && ( - {" "}· {p.status.source_label} + {" "} + · {p.status.source_label} )} @@ -170,7 +200,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { )} - {/* Right: action buttons */} + {p.docs_url && ( )} {!p.status.logged_in && p.flow !== "external" && ( - setLoginFor(p)} - prefix={} - > + setLoginFor(p)} prefix={}> {t.oauth.login} )} diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index b22fb6f7..bce6cb45 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -1,13 +1,12 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { - BarChart3, - Brain, - Cpu, - RefreshCw, - TrendingUp, -} from "lucide-react"; +import { BarChart3, Brain, Cpu, RefreshCw, TrendingUp } from "lucide-react"; import { api } from "@/lib/api"; -import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api"; +import type { + AnalyticsResponse, + AnalyticsDailyEntry, + AnalyticsModelEntry, + AnalyticsSkillEntry, +} from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { Button, Stats } from "@nous-research/ui"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -43,16 +42,21 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { const { t } = useI18n(); if (daily.length === 0) return null; - const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1); + const maxTokens = Math.max( + ...daily.map((d) => d.input_tokens + d.output_tokens), + 1, + ); return ( - {t.analytics.dailyTokenUsage} + + {t.analytics.dailyTokenUsage} + - + {t.analytics.input} @@ -64,47 +68,63 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { - + {daily.map((d) => { const total = d.input_tokens + d.output_tokens; - const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX); - const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX); + const inputH = Math.round( + (d.input_tokens / maxTokens) * CHART_HEIGHT_PX, + ); + const outputH = Math.round( + (d.output_tokens / maxTokens) * CHART_HEIGHT_PX, + ); return ( - {/* Tooltip */} {formatDate(d.day)} - {t.analytics.input}: {formatTokens(d.input_tokens)} - {t.analytics.output}: {formatTokens(d.output_tokens)} - {t.analytics.total}: {formatTokens(total)} + + {t.analytics.input}: {formatTokens(d.input_tokens)} + + + {t.analytics.output}: {formatTokens(d.output_tokens)} + + + {t.analytics.total}: {formatTokens(total)} + - {/* Input bar */} + 0 ? 1 : 0) }} /> - {/* Output bar */} + 0 ? 1 : 0) }} + style={{ + height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0), + }} /> ); })} - {/* X-axis labels */} + {daily.length > 0 ? formatDate(daily[0].day) : ""} {daily.length > 2 && ( {formatDate(daily[Math.floor(daily.length / 2)].day)} )} - {daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""} + + {daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""} + @@ -122,7 +142,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { - {t.analytics.dailyBreakdown} + + {t.analytics.dailyBreakdown} + @@ -130,23 +152,42 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { - {t.analytics.date} - {t.sessions.title} - {t.analytics.input} - {t.analytics.output} + + {t.analytics.date} + + + {t.sessions.title} + + + {t.analytics.input} + + + {t.analytics.output} + {sorted.map((d) => { return ( - - {formatDate(d.day)} - {d.sessions} + + + {formatDate(d.day)} + + + {d.sessions} + - {formatTokens(d.input_tokens)} + + {formatTokens(d.input_tokens)} + - {formatTokens(d.output_tokens)} + + {formatTokens(d.output_tokens)} + ); @@ -164,7 +205,8 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { if (models.length === 0) return null; const sorted = [...models].sort( - (a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens), + (a, b) => + b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens), ); return ( @@ -172,7 +214,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { - {t.analytics.perModelBreakdown} + + {t.analytics.perModelBreakdown} + @@ -180,22 +224,37 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { - {t.analytics.model} - {t.sessions.title} - {t.analytics.tokens} + + {t.analytics.model} + + + {t.sessions.title} + + + {t.analytics.tokens} + {sorted.map((m) => ( - + {m.model} - {m.sessions} + + {m.sessions} + - {formatTokens(m.input_tokens)} + + {formatTokens(m.input_tokens)} + {" / "} - {formatTokens(m.output_tokens)} + + {formatTokens(m.output_tokens)} + ))} @@ -224,21 +283,38 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) { - {t.analytics.skill} - {t.analytics.loads} - {t.analytics.edits} - {t.analytics.total} - {t.analytics.lastUsed} + + {t.analytics.skill} + + + {t.analytics.loads} + + + {t.analytics.edits} + + + {t.analytics.total} + + + {t.analytics.lastUsed} + {skills.map((skill) => ( - + {skill.skill} - {skill.view_count} - {skill.manage_count} + + {skill.view_count} + + + {skill.manage_count} + {skill.total_count} {skill.last_used_at ? timeAgo(skill.last_used_at) : "—"} @@ -338,7 +414,6 @@ export default function AnalyticsPage() { {data && ( <> - {/* Summary stats + bar chart side-by-side on lg+ */} @@ -377,24 +452,28 @@ export default function AnalyticsPage() { - {/* Tables */} > )} - {data && data.daily.length === 0 && data.by_model.length === 0 && data.skills.top_skills.length === 0 && ( - - - - - {t.analytics.noUsageData} - {t.analytics.startSession} - - - - )} + {data && + data.daily.length === 0 && + data.by_model.length === 0 && + data.skills.top_skills.length === 0 && ( + + + + + {t.analytics.noUsageData} + + {t.analytics.startSession} + + + + + )} ); diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 273a1c4c..7b074cc3 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -45,7 +45,10 @@ import { PluginSlot } from "@/plugins"; /* Helpers */ /* ------------------------------------------------------------------ */ -const CATEGORY_ICONS: Record> = { +const CATEGORY_ICONS: Record< + string, + React.ComponentType<{ className?: string }> +> = { general: Settings, agent: Bot, terminal: Monitor, @@ -63,7 +66,13 @@ const CATEGORY_ICONS: Record auxiliary: Wrench, }; -function CategoryIcon({ category, className }: { category: string; className?: string }) { +function CategoryIcon({ + category, + className, +}: { + category: string; + className?: string; +}) { const Icon = CATEGORY_ICONS[category] ?? FileQuestion; return ; } @@ -74,9 +83,14 @@ function CategoryIcon({ category, className }: { category: string; className?: s export default function ConfigPage() { const [config, setConfig] = useState | null>(null); - const [schema, setSchema] = useState> | null>(null); + const [schema, setSchema] = useState + > | null>(null); const [categoryOrder, setCategoryOrder] = useState([]); - const [defaults, setDefaults] = useState | null>(null); + const [defaults, setDefaults] = useState | null>( + null, + ); const [saving, setSaving] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [yamlMode, setYamlMode] = useState(false); @@ -124,7 +138,10 @@ export default function ConfigPage() { } useEffect(() => { - api.getConfig().then(setConfig).catch(() => {}); + api + .getConfig() + .then(setConfig) + .catch(() => {}); api .getSchema() .then((resp) => { @@ -132,7 +149,10 @@ export default function ConfigPage() { setCategoryOrder(resp.category_order ?? []); }) .catch(() => {}); - api.getDefaults().then(setDefaults).catch(() => {}); + api + .getDefaults() + .then(setDefaults) + .catch(() => {}); }, []); // Set active category when categories load @@ -157,7 +177,11 @@ export default function ConfigPage() { /* ---- Categories ---- */ const categories = useMemo(() => { if (!schema) return []; - const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))]; + const allCats = [ + ...new Set( + Object.values(schema).map((s) => String(s.category ?? "general")), + ), + ]; const ordered = categoryOrder.filter((c) => allCats.includes(c)); const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort(); return [...ordered, ...extra]; @@ -186,8 +210,12 @@ export default function ConfigPage() { return ( key.toLowerCase().includes(lowerSearch) || humanLabel.toLowerCase().includes(lowerSearch) || - String(s.category ?? "").toLowerCase().includes(lowerSearch) || - String(s.description ?? "").toLowerCase().includes(lowerSearch) + String(s.category ?? "") + .toLowerCase() + .includes(lowerSearch) || + String(s.description ?? "") + .toLowerCase() + .includes(lowerSearch) ); }); }, [isSearching, lowerSearch, schema]); @@ -196,7 +224,7 @@ export default function ConfigPage() { const activeFields = useMemo(() => { if (!schema || isSearching) return []; return Object.entries(schema).filter( - ([, s]) => String(s.category ?? "general") === activeCategory + ([, s]) => String(s.category ?? "general") === activeCategory, ); }, [schema, activeCategory, isSearching]); @@ -219,7 +247,10 @@ export default function ConfigPage() { try { await api.saveConfigRaw(yamlText); showToast(t.config.yamlConfigSaved, "success"); - api.getConfig().then(setConfig).catch(() => {}); + api + .getConfig() + .then(setConfig) + .catch(() => {}); } catch (e) { showToast(`${t.config.failedToSaveYaml}: ${e}`, "error"); } finally { @@ -247,12 +278,17 @@ export default function ConfigPage() { next = setNestedValue(next, key, getNestedValue(defaults, key)); } setConfig(next); - showToast(t.config.resetScopeToast.replace("{scope}", scopeLabel), "success"); + showToast( + t.config.resetScopeToast.replace("{scope}", scopeLabel), + "success", + ); }; const handleExport = () => { if (!config) return; - const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); + const blob = new Blob([JSON.stringify(config, null, 2)], { + type: "application/json", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -287,7 +323,10 @@ export default function ConfigPage() { } /* ---- Render field list (shared between search & normal) ---- */ - const renderFields = (fields: [string, Record][], showCategory = false) => { + const renderFields = ( + fields: [string, Record][], + showCategory = false, + ) => { let lastSection = ""; let lastCat = ""; return fields.map(([key, s]) => { @@ -295,7 +334,11 @@ export default function ConfigPage() { const section = parts.length > 1 ? parts[0] : ""; const cat = String(s.category ?? "general"); const showCatBadge = showCategory && cat !== lastCat; - const showSection = !showCategory && section && section !== lastSection && section !== activeCategory; + const showSection = + !showCategory && + section && + section !== lastSection && + section !== activeCategory; lastSection = section; lastCat = cat; @@ -303,7 +346,10 @@ export default function ConfigPage() { {showCatBadge && ( - + {prettyCategoryName(cat)} @@ -336,7 +382,6 @@ export default function ConfigPage() { - {/* ═══════════════ Header Bar ═══════════════ */} @@ -345,24 +390,52 @@ export default function ConfigPage() { - + - fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}> + fileInputRef.current?.click()} + title={t.config.importConfig} + aria-label={t.config.importConfig} + > - - {!yamlMode && (() => { - const resetScopeLabel = isSearching - ? t.config.searchResults - : prettyCategoryName(activeCategory); - const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel); - return ( - - - - ); - })()} + + {!yamlMode && + (() => { + const resetScopeLabel = isSearching + ? t.config.searchResults + : prettyCategoryName(activeCategory); + const resetTitle = t.config.resetScopeTooltip.replace( + "{scope}", + resetScopeLabel, + ); + return ( + + + + ); + })()} @@ -375,7 +448,11 @@ export default function ConfigPage() { {yamlMode ? ( - }> + } + > {yamlSaving ? t.common.saving : t.common.save} ) : ( @@ -386,7 +463,6 @@ export default function ConfigPage() { - {/* ═══════════════ YAML Mode ═══════════════ */} {yamlMode ? ( @@ -411,13 +487,10 @@ export default function ConfigPage() { ) : ( - /* ═══════════════ Form Mode ═══════════════ */ - {/* ---- Filter panel ---- */}
@@ -309,7 +301,6 @@ export function OAuthLoginModal({ > )} - {/* ── approved ───────────────────────────────────── */} {phase === "approved" && (
- token{" "} + token {p.status.token_preview} {p.status.source_label && ( - {" "}· {p.status.source_label} + {" "} + · {p.status.source_label} )}
{t.analytics.noUsageData}
{t.analytics.startSession}
+ {t.analytics.startSession} +