mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-19 04:52:06 +00:00
fix(dashboard): UI polish — modals, layout, consistency, test fixes
Dashboard UX polish pass — consolidates create forms into modals triggered from the page header, fixes layout inconsistencies, adds scroll-to navigation for the Keys page, and aligns the TokenBar with the design system. Changes: - App.tsx: add padding to sidebar header - resolve-page-title.ts: add missing routes, better fallback title - en.ts: fix nav labels (Profiles was 'profiles : multi agents') - ModelsPage: two-col layout, auxiliary tasks modal, TokenBar redesign - ProfilesPage: create button in header, form in modal, Checkbox component - CronPage: create button in header, form in modal - EnvPage: scroll-to sub-nav in header, fix text overflow Modal and dialog standardization: - Replace all native confirm()/window.confirm() with ConfirmDialog (OAuthProvidersCard, PluginsPage, ModelsPage, ConfigPage) - Add useModalBehavior hook (Escape-to-close, scroll lock, focus restore) - Apply hook to ProfilesPage, CronPage, AuxiliaryTasksModal Component fixes (from PR review): - Checkbox: fix controlled/uncontrolled mismatch, add focus-visible ring - TokenBar: add rounded-full to legend dots, remove dead code CI/test fixes: - Fix TS unused imports (noUnusedLocals), type-narrow PickerTarget union - Add windows-footgun suppression on platform-guarded os.killpg - Fix 19 stale unit tests + 9 e2e tests broken by recent main changes - Restore minimal example-dashboard plugin for plugin auth test
This commit is contained in:
parent
dd0923bb89
commit
fc3fd6bb6b
27 changed files with 788 additions and 295 deletions
|
|
@ -473,7 +473,7 @@ export default function App() {
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2",
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 px-4",
|
||||
"border-b border-current/20",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
|
|
@ -55,6 +56,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
||||
const [disconnectTarget, setDisconnectTarget] =
|
||||
useState<OAuthProvider | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const onErrorRef = useRef(onError);
|
||||
|
|
@ -74,10 +77,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
}, [refresh]);
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
|
||||
return;
|
||||
}
|
||||
setBusyId(provider.id);
|
||||
setDisconnectTarget(null);
|
||||
try {
|
||||
await api.disconnectOAuthProvider(provider.id);
|
||||
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
|
||||
|
|
@ -236,7 +237,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => handleDisconnect(p)}
|
||||
onClick={() => setDisconnectTarget(p)}
|
||||
disabled={isBusy}
|
||||
prefix={isBusy ? <Spinner /> : <LogOut />}
|
||||
>
|
||||
|
|
@ -266,6 +267,17 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
onError={(msg) => onError?.(msg)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={disconnectTarget !== null}
|
||||
onCancel={() => setDisconnectTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (disconnectTarget) void handleDisconnect(disconnectTarget);
|
||||
}}
|
||||
title={`${t.oauth.disconnect} ${disconnectTarget?.name ?? ""}?`}
|
||||
description={`This will remove the stored OAuth tokens for ${disconnectTarget?.name ?? "this provider"}. You will need to re-authenticate to use it again.`}
|
||||
destructive
|
||||
confirmLabel={t.oauth.disconnect}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
61
web/src/components/ui/checkbox.tsx
Normal file
61
web/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
className,
|
||||
label,
|
||||
id,
|
||||
checked,
|
||||
defaultChecked,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
// Support both controlled (checked prop) and uncontrolled (defaultChecked) usage.
|
||||
// For visual rendering, prefer `checked` if provided; otherwise fall back to defaultChecked.
|
||||
const isChecked = checked ?? defaultChecked ?? false;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 cursor-pointer select-none",
|
||||
props.disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center transition-all",
|
||||
"border bg-background/40",
|
||||
// Focus-visible ring for keyboard accessibility
|
||||
"group-has-[:focus-visible]:ring-2 group-has-[:focus-visible]:ring-ring group-has-[:focus-visible]:ring-offset-1",
|
||||
isChecked
|
||||
? "border-foreground bg-foreground/20"
|
||||
: "border-border group-hover:border-foreground/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 transition-opacity",
|
||||
isChecked
|
||||
? "text-foreground opacity-100"
|
||||
: "text-foreground opacity-0",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
defaultChecked={checked === undefined ? defaultChecked : undefined}
|
||||
className="sr-only"
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ export const en: Translations = {
|
|||
keys: "Keys",
|
||||
logs: "Logs",
|
||||
models: "Models",
|
||||
profiles: "profiles : multi agents",
|
||||
profiles: "Profiles",
|
||||
plugins: "Plugins",
|
||||
sessions: "Sessions",
|
||||
skills: "Skills",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
|
|||
"/chat": "chat",
|
||||
"/sessions": "sessions",
|
||||
"/analytics": "analytics",
|
||||
"/models": "models",
|
||||
"/logs": "logs",
|
||||
"/cron": "cron",
|
||||
"/skills": "skills",
|
||||
"/plugins": "plugins",
|
||||
"/profiles": "profiles",
|
||||
"/config": "config",
|
||||
"/env": "keys",
|
||||
"/docs": "documentation",
|
||||
|
|
@ -30,5 +32,10 @@ export function resolvePageTitle(
|
|||
if (key) {
|
||||
return t.app.nav[key];
|
||||
}
|
||||
// Derive title from pathname: "/profiles" → "Profiles"
|
||||
const segment = normalized.slice(1);
|
||||
if (segment) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
}
|
||||
return t.app.webUi;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { Button } from "@nous-research/ui/ui/components/button";
|
|||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
|
@ -118,6 +119,7 @@ export default function ConfigPage() {
|
|||
const [yamlLoading, setYamlLoading] = useState(false);
|
||||
const [yamlSaving, setYamlSaving] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
|
|
@ -290,11 +292,17 @@ export default function ConfigPage() {
|
|||
// "reset this tab", not "wipe my entire config.yaml".
|
||||
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||
if (scopedFields.length === 0) return;
|
||||
setConfirmReset(true);
|
||||
};
|
||||
|
||||
const executeReset = () => {
|
||||
if (!defaults || !config) return;
|
||||
setConfirmReset(false);
|
||||
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||
if (scopedFields.length === 0) return;
|
||||
const scopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
const message = t.config.confirmResetScope.replace("{scope}", scopeLabel);
|
||||
if (!window.confirm(message)) return;
|
||||
let next: Record<string, unknown> = config;
|
||||
for (const [key] of scopedFields) {
|
||||
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
||||
|
|
@ -627,6 +635,22 @@ export default function ConfigPage() {
|
|||
</div>
|
||||
)}
|
||||
<PluginSlot name="config:bottom" />
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onConfirm={executeReset}
|
||||
title={t.config.confirmResetScope.replace(
|
||||
"{scope}",
|
||||
isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory),
|
||||
)}
|
||||
description={`This will reset ${
|
||||
(isSearching ? searchMatchedFields : activeFields).length
|
||||
} field(s) to their default values.`}
|
||||
destructive
|
||||
confirmLabel={t.config.resetDefaults}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, 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,11 +10,13 @@ import type { CronJob } from "@/lib/api";
|
|||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
|
|
@ -80,11 +82,18 @@ export default function CronPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
// New job form state
|
||||
// New job modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [schedule, setSchedule] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
|
|
@ -118,6 +127,7 @@ export default function CronPage() {
|
|||
setSchedule("");
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
setCreateModalOpen(false);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
||||
|
|
@ -181,6 +191,22 @@ export default function CronPage() {
|
|||
),
|
||||
});
|
||||
|
||||
// Put "Create" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
|
|
@ -213,86 +239,110 @@ export default function CronPage() {
|
|||
loading={jobDelete.isDeleting}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.cron.newJob}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="cron-name"
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Create job modal */}
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
||||
role="dialog"
|
||||
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">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => setCreateModalOpen(false)}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="cron-prompt"
|
||||
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="create-cron-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
>
|
||||
{t.cron.newJob}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="cron-schedule"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
id="cron-name"
|
||||
autoFocus
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">
|
||||
{t.cron.delivery.local}
|
||||
</SelectOption>
|
||||
<SelectOption value="telegram">
|
||||
{t.cron.delivery.telegram}
|
||||
</SelectOption>
|
||||
<SelectOption value="discord">
|
||||
{t.cron.delivery.discord}
|
||||
</SelectOption>
|
||||
<SelectOption value="slack">
|
||||
{t.cron.delivery.slack}
|
||||
</SelectOption>
|
||||
<SelectOption value="email">
|
||||
{t.cron.delivery.email}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="cron-prompt"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
||||
<Input
|
||||
id="cron-schedule"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">
|
||||
{t.cron.delivery.local}
|
||||
</SelectOption>
|
||||
<SelectOption value="telegram">
|
||||
{t.cron.delivery.telegram}
|
||||
</SelectOption>
|
||||
<SelectOption value="discord">
|
||||
{t.cron.delivery.discord}
|
||||
</SelectOption>
|
||||
<SelectOption value="slack">
|
||||
{t.cron.delivery.slack}
|
||||
</SelectOption>
|
||||
<SelectOption value="email">
|
||||
{t.cron.delivery.email}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
prefix={<Plus />}
|
||||
className="w-full"
|
||||
prefix={creating ? <Spinner /> : <Plus />}
|
||||
>
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
|
|
@ -35,6 +35,7 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -132,7 +133,7 @@ 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 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<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 gap-2 min-w-0">
|
||||
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||
{varKey}
|
||||
|
|
@ -168,7 +169,7 @@ 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 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 opacity-60 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||
{varKey}
|
||||
|
|
@ -203,7 +204,7 @@ function EnvVarRow({
|
|||
|
||||
// Full expanded row for set keys or keys being edited
|
||||
return (
|
||||
<div className="grid gap-2 border border-border p-4">
|
||||
<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>
|
||||
|
|
@ -493,6 +494,7 @@ export default function EnvPage() {
|
|||
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle } = usePageHeader();
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
|
|
@ -501,6 +503,58 @@ export default function EnvPage() {
|
|||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Scroll-to sub-nav in the page header
|
||||
const sections = useMemo(() => {
|
||||
const items: { id: string; label: string }[] = [
|
||||
{ id: "section-oauth", label: "OAuth" },
|
||||
{ id: "section-providers", label: "Providers" },
|
||||
];
|
||||
if (vars) {
|
||||
const categories = ["tool", "messaging", "setting"];
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
tool: "Tools",
|
||||
messaging: "Messaging",
|
||||
setting: "Settings",
|
||||
};
|
||||
for (const cat of categories) {
|
||||
const hasEntries = Object.values(vars).some(
|
||||
(info) => info.category === cat,
|
||||
);
|
||||
if (hasEntries) {
|
||||
items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [vars]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!vars) {
|
||||
setAfterTitle(null);
|
||||
return;
|
||||
}
|
||||
const scrollTo = (id: string) => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
setAfterTitle(
|
||||
<nav className="flex items-center gap-1" aria-label="Jump to section">
|
||||
{sections.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => scrollTo(s.id)}
|
||||
className="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"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
};
|
||||
}, [vars, sections, setAfterTitle]);
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
const value = edits[key];
|
||||
if (!value) return;
|
||||
|
|
@ -701,12 +755,14 @@ export default function EnvPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
/>
|
||||
<div id="section-oauth">
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card id="section-providers">
|
||||
<CardHeader className="border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||
|
|
@ -750,7 +806,7 @@ export default function EnvPage() {
|
|||
if (totalEntries === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={category}>
|
||||
<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" />
|
||||
|
|
@ -762,7 +818,7 @@ export default function EnvPage() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 pt-4">
|
||||
<CardContent className="grid gap-3 pt-4 overflow-hidden">
|
||||
{setEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Settings2,
|
||||
Star,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
|
@ -25,6 +26,8 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|||
import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
|
@ -91,27 +94,39 @@ function TokenBar({
|
|||
if (total === 0) return null;
|
||||
|
||||
const segments = [
|
||||
{ value: cacheRead, color: "bg-blue-400/60", label: "Cache Read" },
|
||||
{ value: reasoning, color: "bg-purple-400/60", label: "Reasoning" },
|
||||
{ value: input, color: "bg-[#ffe6cb]/70", label: "Input" },
|
||||
{ value: output, color: "bg-emerald-500/70", label: "Output" },
|
||||
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
|
||||
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
|
||||
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
|
||||
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
|
||||
].filter((s) => s.value > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-2 w-full overflow-hidden rounded-sm bg-muted/30">
|
||||
<div className="space-y-1.5">
|
||||
{/* Stacked bar — segments fill proportionally to their share of total */}
|
||||
<div className="relative flex min-h-[1.5rem] w-full items-stretch overflow-hidden">
|
||||
{segments.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${s.color} transition-all duration-300`}
|
||||
className={`${s.color} relative flex items-center transition-all duration-300`}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
/>
|
||||
>
|
||||
{/* Stepped fill pattern overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(to right, transparent 0 0.4rem, currentColor 0.4rem calc(0.4rem + 1px))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
|
||||
{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.color}`} />
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
|
||||
{s.label} {formatTokens(s.value)}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -378,7 +393,7 @@ function ModelCard({
|
|||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<TokenBar
|
||||
input={entry.input_tokens}
|
||||
output={entry.output_tokens}
|
||||
|
|
@ -445,6 +460,157 @@ type PickerTarget =
|
|||
| { kind: "main" }
|
||||
| { kind: "aux"; task: string };
|
||||
|
||||
function AuxiliaryTasksModal({
|
||||
aux,
|
||||
refreshKey,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
aux: AuxiliaryModelsResponse | null;
|
||||
refreshKey: number;
|
||||
onSaved(): void;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
||||
const [resetBusy, setResetBusy] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const modalRef = useModalBehavior({ open: true, onClose });
|
||||
|
||||
const resetAllAux = async () => {
|
||||
setConfirmReset(false);
|
||||
setResetBusy(true);
|
||||
try {
|
||||
await api.setModelAssignment({
|
||||
scope: "auxiliary",
|
||||
task: "__reset__",
|
||||
provider: "",
|
||||
model: "",
|
||||
});
|
||||
onSaved();
|
||||
} finally {
|
||||
setResetBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
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">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<div className="flex items-center justify-between gap-3 pr-8">
|
||||
<h2
|
||||
id="aux-modal-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
>
|
||||
Auxiliary Tasks
|
||||
</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={resetBusy}
|
||||
className="text-[10px] h-6"
|
||||
prefix={resetBusy ? <Spinner /> : null}
|
||||
>
|
||||
Reset all to auto
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/80 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
|
||||
cheap/fast model for a specific job.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-1">
|
||||
{AUX_TASKS.map((t) => {
|
||||
const cur = aux?.tasks.find((a) => a.task === t.key);
|
||||
const isAuto =
|
||||
!cur || cur.provider === "auto" || !cur.provider;
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<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">
|
||||
{t.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{isAuto
|
||||
? "auto (use main model)"
|
||||
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
||||
className="text-[10px] h-6"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{picker && picker.kind === "aux" && (
|
||||
<ModelPickerDialog
|
||||
key={`picker-${refreshKey}`}
|
||||
loader={api.getModelOptions}
|
||||
alwaysGlobal
|
||||
title={`Set Auxiliary: ${
|
||||
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
|
||||
picker.task
|
||||
}`}
|
||||
onApply={async ({ provider, model }) => {
|
||||
await api.setModelAssignment({
|
||||
scope: "auxiliary",
|
||||
task: picker.task,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
onSaved();
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onConfirm={() => void resetAllAux()}
|
||||
title="Reset auxiliary models"
|
||||
description="Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set."
|
||||
destructive
|
||||
confirmLabel="Reset all"
|
||||
loading={resetBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSettingsPanel({
|
||||
aux,
|
||||
refreshKey,
|
||||
|
|
@ -454,9 +620,8 @@ function ModelSettingsPanel({
|
|||
refreshKey: number;
|
||||
onSaved(): void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [auxModalOpen, setAuxModalOpen] = useState(false);
|
||||
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
||||
const [resetBusy, setResetBusy] = useState(false);
|
||||
|
||||
const mainProv = aux?.main.provider ?? "";
|
||||
const mainModel = aux?.main.model ?? "";
|
||||
|
|
@ -476,23 +641,10 @@ function ModelSettingsPanel({
|
|||
onSaved();
|
||||
};
|
||||
|
||||
const resetAllAux = async () => {
|
||||
if (!window.confirm("Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set.")) {
|
||||
return;
|
||||
}
|
||||
setResetBusy(true);
|
||||
try {
|
||||
await api.setModelAssignment({
|
||||
scope: "auxiliary",
|
||||
task: "__reset__",
|
||||
provider: "",
|
||||
model: "",
|
||||
});
|
||||
onSaved();
|
||||
} finally {
|
||||
setResetBusy(false);
|
||||
}
|
||||
};
|
||||
// Count how many aux tasks have overrides
|
||||
const auxOverrideCount = aux?.tasks.filter(
|
||||
(a) => a.provider && a.provider !== "auto",
|
||||
).length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -505,21 +657,10 @@ function ModelSettingsPanel({
|
|||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs"
|
||||
>
|
||||
{expanded ? "Hide auxiliary" : "Show auxiliary"}
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -544,85 +685,41 @@ function ModelSettingsPanel({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auxiliary rows */}
|
||||
{expanded && (
|
||||
<div className="space-y-1 border-t border-border/50 pt-3">
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{/* Auxiliary tasks summary + open modal */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<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">
|
||||
Auxiliary tasks
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={resetAllAux}
|
||||
disabled={resetBusy}
|
||||
className="text-[10px] h-6"
|
||||
prefix={resetBusy ? <Spinner /> : null}
|
||||
>
|
||||
Reset all to auto
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground truncate">
|
||||
{auxOverrideCount > 0
|
||||
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
|
||||
: `${AUX_TASKS.length} tasks · all auto`}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/80 pb-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
|
||||
cheap/fast model for a specific job.
|
||||
</p>
|
||||
|
||||
{AUX_TASKS.map((t) => {
|
||||
const cur = aux?.tasks.find((a) => a.task === t.key);
|
||||
const isAuto =
|
||||
!cur || cur.provider === "auto" || !cur.provider;
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className="flex items-center justify-between gap-3 px-3 py-1.5 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<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">
|
||||
{t.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{isAuto
|
||||
? "auto (use main model)"
|
||||
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
||||
className="text-[10px] h-6"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setAuxModalOpen(true)}
|
||||
className="text-xs"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{picker && (
|
||||
<ModelPickerDialog
|
||||
key={`picker-${refreshKey}`}
|
||||
loader={api.getModelOptions}
|
||||
alwaysGlobal
|
||||
title={
|
||||
picker.kind === "main"
|
||||
? "Set Main Model"
|
||||
: `Set Auxiliary: ${
|
||||
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
|
||||
picker.task
|
||||
}`
|
||||
}
|
||||
title="Set Main Model"
|
||||
onApply={async ({ provider, model }) => {
|
||||
await applyAssignment({
|
||||
scope: picker.kind === "main" ? "main" : "auxiliary",
|
||||
task: picker.kind === "main" ? "" : picker.task,
|
||||
scope: "main",
|
||||
task: "",
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
|
|
@ -630,6 +727,15 @@ function ModelSettingsPanel({
|
|||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{auxModalOpen && (
|
||||
<AuxiliaryTasksModal
|
||||
aux={aux}
|
||||
refreshKey={refreshKey}
|
||||
onSaved={onSaved}
|
||||
onClose={() => setAuxModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -725,28 +831,14 @@ export default function ModelsPage() {
|
|||
<div className="flex flex-col gap-6">
|
||||
<PluginSlot name="models:top" />
|
||||
|
||||
<ModelSettingsPanel
|
||||
aux={aux}
|
||||
refreshKey={saveKey}
|
||||
onSaved={onAssigned}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<ModelSettingsPanel
|
||||
aux={aux}
|
||||
refreshKey={saveKey}
|
||||
onSaved={onAssigned}
|
||||
/>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{data && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<Stats
|
||||
|
|
@ -781,7 +873,25 @@ export default function ModelsPage() {
|
|||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{data.models.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.models.map((m, i) => (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Switch } from "@nous-research/ui/ui/components/switch";
|
|||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -393,6 +394,7 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
|
||||
|
||||
const busy = rowBusy === row.name;
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
const badgeTone =
|
||||
row.runtime_status === "enabled"
|
||||
|
|
@ -533,18 +535,7 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
disabled={busy}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ok =
|
||||
typeof window !== "undefined"
|
||||
? window.confirm(t.pluginsPage.removeConfirm)
|
||||
: false;
|
||||
if (!ok) return;
|
||||
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.removeAgentPlugin(row.name);
|
||||
showToast(`${row.name} removed`, "success");
|
||||
});
|
||||
}}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
|
||||
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||
|
|
@ -576,6 +567,21 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRemove}
|
||||
onCancel={() => setConfirmRemove(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmRemove(false);
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.removeAgentPlugin(row.name);
|
||||
showToast(`${row.name} removed`, "success");
|
||||
});
|
||||
}}
|
||||
title={t.pluginsPage.removeConfirm}
|
||||
description={`This will remove the "${row.name}" plugin from your agent.`}
|
||||
destructive
|
||||
confirmLabel={t.common.delete}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
|
||||
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
||||
|
|
@ -23,11 +26,18 @@ export default function ProfilesPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
// Create form
|
||||
// Create modal
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
|
||||
// Inline rename state
|
||||
const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
|
||||
|
|
@ -68,6 +78,7 @@ export default function ProfilesPage() {
|
|||
await api.createProfile({ name, clone_from_default: cloneFromDefault });
|
||||
showToast(`${t.profiles.created}: ${name}`, "success");
|
||||
setNewName("");
|
||||
setCreateModalOpen(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
|
|
@ -170,6 +181,22 @@ export default function ProfilesPage() {
|
|||
|
||||
const pendingName = profileDelete.pendingId;
|
||||
|
||||
// Put "Create" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
|
|
@ -198,51 +225,75 @@ export default function ProfilesPage() {
|
|||
loading={profileDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Create new profile */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.profiles.newProfile}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
placeholder={t.profiles.namePlaceholder}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
aria-invalid={
|
||||
newName.trim() !== "" &&
|
||||
!PROFILE_NAME_RE.test(newName.trim())
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.profiles.nameRule}
|
||||
</p>
|
||||
</div>
|
||||
{/* Create profile modal */}
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
||||
role="dialog"
|
||||
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">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => setCreateModalOpen(false)}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="create-profile-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
>
|
||||
{t.profiles.newProfile}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
autoFocus
|
||||
placeholder={t.profiles.namePlaceholder}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
aria-invalid={
|
||||
newName.trim() !== "" &&
|
||||
!PROFILE_NAME_RE.test(newName.trim())
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.profiles.nameRule}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
id="clone-from-default"
|
||||
checked={cloneFromDefault}
|
||||
onChange={(e) => setCloneFromDefault(e.target.checked)}
|
||||
label={t.profiles.cloneFromDefault}
|
||||
/>
|
||||
{t.profiles.cloneFromDefault}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue