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:
Austin Pickett
2026-05-28 14:20:49 -04:00
1254 changed files with 199373 additions and 11909 deletions
+150
View 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>
);
}
+2 -2
View File
@@ -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>
);
}
+42 -33
View File
@@ -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>
) : (
+49 -31
View File
@@ -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;
}
+16 -2
View File
@@ -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} />;
}
+6 -6
View File
@@ -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>
)}
+6 -5
View File
@@ -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>
);
+2 -1
View File
@@ -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"
+35 -31
View File
@@ -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>
+3 -3
View File
@@ -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>
)}
+9 -7
View File
@@ -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;
}
+12 -10
View File
@@ -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;
}
+1 -1
View File
@@ -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>
)}
+63 -45
View File
@@ -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;
}
+5 -5
View File
@@ -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";
}