Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled

This commit is contained in:
红尘
2026-05-31 09:36:58 +08:00
commit d73ff9b0fb
4188 changed files with 1614916 additions and 0 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>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
const description = schema.description ? String(schema.description) : "";
if (!keyPath && !description) return null;
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
{description && <span className="text-xs text-text-secondary">{description}</span>}
</div>
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatScalar(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function NestedValueEditor({
fieldKey,
value,
onChange,
}: {
fieldKey: string;
value: unknown;
onChange: (v: unknown) => void;
}) {
if (isRecord(value)) {
return (
<div className="grid gap-2 border border-border p-2">
{Object.entries(value).map(([subKey, subVal]) => (
<div key={subKey} className="grid gap-1">
<Label className="text-xs text-muted-foreground">{subKey}</Label>
<NestedValueEditor
fieldKey={`${fieldKey}.${subKey}`}
value={subVal}
onChange={(next) => onChange({ ...value, [subKey]: next })}
/>
</div>
))}
</div>
);
}
if (Array.isArray(value)) {
return (
<div className="grid gap-2">
{value.map((item, index) => (
<div key={`${fieldKey}.${index}`} className="grid gap-1">
<Label className="text-xs text-muted-foreground">Item {index + 1}</Label>
<NestedValueEditor
fieldKey={`${fieldKey}.${index}`}
value={item}
onChange={(next) =>
onChange(value.map((existing, i) => (i === index ? next : existing)))
}
/>
</div>
))}
</div>
);
}
return (
<Input
value={formatScalar(value)}
onChange={(e) => onChange(e.target.value)}
className="text-xs"
/>
);
}
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
if (isRecord(value) || (Array.isArray(value) && value.some((item) => isRecord(item)))) {
return (
<div className="grid gap-3 border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<NestedValueEditor fieldKey={schemaKey} value={value} onChange={onChange} />
</div>
);
}
if (schema.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
{options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt || "(none)"}
</SelectOption>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
if (!Number.isNaN(n)) {
onChange(n);
}
}}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
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"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (schema.type === "list") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
onChange={(e) =>
onChange(
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="comma-separated values"
/>
</div>
);
}
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
interface AutoFieldProps {
schemaKey: string;
schema: Record<string, unknown>;
value: unknown;
onChange: (v: unknown) => void;
}
+93
View File
@@ -0,0 +1,93 @@
import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
/**
* Replicates the visual layer stack of `<Overlays dark />` from
* `@nous-research/ui` without pulling in its leva / gsap / three peer deps.
*
* See `design-language/src/ui/components/overlays/index.tsx` for the source of
* truth. Defaults match LENS_0 (the Hermes teal dark preset); the deep canvas
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
* `prefers-reduced-motion: reduce` set. We skip the animated noise layer
* in that case so low-power / accessibility-conscious sessions stay crisp,
* mirroring the DS `<Noise />` component's own opt-out.
*/
export function Backdrop() {
const gpuTier = useGpuTier();
return (
<>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={{
backgroundColor: "var(--background-base)",
mixBlendMode: "difference",
}}
/>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[2]"
style={
{
// Themes can override the filler background by setting
// `assets.bg` — the <img> 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)",
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)",
} as unknown as React.CSSProperties
}
>
<img
alt=""
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
fetchPriority="low"
src={fillerBgUrl}
/>
</div>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[99]"
style={{
background:
"radial-gradient(ellipse at 0% 0%, transparent 60%, var(--warm-glow) 100%)",
mixBlendMode: "lighten",
opacity: 0.22,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[101]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
backgroundSize: "512px 512px",
mixBlendMode: "color-dodge",
opacity: "calc(0.55 * var(--noise-opacity-mul, 1))",
}}
/>
)}
</>
);
}
+394
View File
@@ -0,0 +1,394 @@
/**
* ChatSidebar — structured-events panel that sits next to the xterm.js
* terminal in the dashboard Chat tab.
*
* Two WebSockets, one per concern:
*
* 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — drives the
* sidebar's own slot of the dashboard's in-process gateway. Owns
* the model badge / picker / connection state / error banner.
* Independent of the PTY pane's session by design — those are the
* pieces the sidebar needs to be able to drive directly (model
* switch via slash.exec, etc.).
*
* 2. **Event subscriber** (/api/events?channel=…) — passive, receives
* every dispatcher emit from the PTY-side `tui_gateway.entry` that
* the dashboard fanned out. This is how `tool.start/progress/
* complete` from the agent loop reach the sidebar even though the
* PTY child runs three processes deep from us. The `channel` id
* ties this listener to the same chat tab's PTY child — see
* `ChatPage.tsx` for where the id is generated.
*
* Best-effort throughout: WS failures show in the badge / banner, the
* terminal pane keeps working unimpaired.
*/
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
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, buildWsAuthParam } from "@/lib/api";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
credential_warning?: string;
}
interface RpcEnvelope {
method?: string;
params?: { type?: string; payload?: unknown };
}
const TOOL_LIMIT = 20;
const STATE_LABEL: Record<ConnectionState, string> = {
idle: "idle",
connecting: "connecting",
open: "live",
closed: "closed",
error: "error",
};
const STATE_TONE: Record<
ConnectionState,
"secondary" | "warning" | "success" | "destructive"
> = {
idle: "secondary",
connecting: "warning",
open: "success",
closed: "secondary",
error: "destructive",
};
interface ChatSidebarProps {
channel: string;
className?: string;
}
export function ChatSidebar({ channel, className }: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
// it's the signal that says "rebuild the client".
const [version, setVersion] = useState(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
const gw = useMemo(() => new GatewayClient(), [version]);
const [state, setState] = useState<ConnectionState>("idle");
const [sessionId, setSessionId] = useState<string | null>(null);
const [info, setInfo] = useState<SessionInfo>({});
const [tools, setTools] = useState<ToolEntry[]>([]);
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
if (ev.session_id) {
setSessionId(ev.session_id);
}
if (ev.payload) {
setInfo((prev) => ({ ...prev, ...ev.payload }));
}
});
const offError = gw.on<{ message?: string }>("error", (ev) => {
const message = ev.payload?.message;
if (message) {
setError(message);
}
});
// Adopt whichever session the gateway hands us. session.create on the
// sidecar is independent of the PTY pane's session by design — we
// only need a sid to drive the model picker's slash.exec calls.
gw.connect()
.then(() => {
if (cancelled) {
return;
}
return gw.request<{ session_id: string }>("session.create", {});
})
.then((created) => {
if (cancelled || !created?.session_id) {
return;
}
setSessionId(created.session_id);
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
}
});
return () => {
cancelled = true;
offState();
offSessionInfo();
offError();
gw.close();
};
}, [gw]);
// Event subscriber WebSocket — receives the rebroadcast of every
// dispatcher emit from the PTY child's gateway. See /api/pub +
// /api/events in hermes_cli/web_server.py for the broadcast hop.
//
// Failures (auth/loopback rejection, server too old to expose the
// endpoint, transient drops) surface in the same banner as the
// JSON-RPC sidecar so the sidebar matches its documented best-effort
// UX and the user always has a reconnect affordance.
useEffect(() => {
if (!channel) {
return;
}
// 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;
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()}`,
);
// `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 {
frame = JSON.parse(ev.data);
} catch {
return;
}
if (frame.method !== "event" || !frame.params) {
return;
}
const { type, payload } = frame.params;
if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
const toolId = p?.tool_id;
if (!toolId) {
return;
}
setTools((prev) =>
[
...prev,
{
kind: "tool" as const,
id: `tool-${toolId}-${prev.length}`,
tool_id: toolId,
name: p?.name ?? "tool",
context: p?.context,
status: "running" as const,
startedAt: Date.now(),
},
].slice(-TOOL_LIMIT),
);
} else if (type === "tool.progress") {
const p = payload as
| { name?: string; preview?: string }
| undefined;
if (!p?.name || !p.preview) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.status === "running" && t.name === p.name
? { ...t, preview: p.preview }
: t,
),
);
} else if (type === "tool.complete") {
const p = payload as
| {
tool_id?: string;
summary?: string;
error?: string;
inline_diff?: string;
}
| undefined;
if (!p?.tool_id) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.tool_id === p.tool_id
? {
...t,
status: p.error ? "error" : "done",
summary: p.summary,
error: p.error,
inline_diff: p.inline_diff,
completedAt: Date.now(),
}
: t,
),
);
}
});
})();
return () => {
unmounting = true;
ws?.close();
};
}, [channel, version]);
const reconnect = useCallback(() => {
setError(null);
setTools([]);
setVersion((v) => v + 1);
}, []);
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
// via PTY, so the sidebar doesn't need to surface output of its own.
const onModelSubmit = useCallback(
(slashCommand: string) => {
if (!sessionId) {
return;
}
void gw.request("slash.exec", {
session_id: sessionId,
command: slashCommand,
});
setModelOpen(false);
},
[gw, sessionId],
);
const canPickModel = state === "open" && !!sessionId;
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
const banner = error ?? info.credential_warning ?? null;
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 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-display text-xs tracking-wider text-text-tertiary">
model
</div>
<Button
ghost
size="sm"
disabled={!canPickModel}
onClick={() => setModelOpen(true)}
suffix={
canPickModel ? (
<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"
title={info.model ?? "switch model"}
>
<span className="truncate">{modelLabel}</span>
</Button>
</div>
<Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
</Card>
{banner && (
<Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<div className="wrap-break-word text-destructive">{banner}</div>
{error && (
<Button
size="sm"
outlined
className="mt-1"
onClick={reconnect}
prefix={<RefreshCw />}
>
reconnect
</Button>
)}
</div>
</Card>
)}
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
<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-text-secondary">
no tool calls yet
</div>
) : (
tools.map((t) => <ToolCall key={t.id} tool={t} />)
)}
</div>
</Card>
{modelOpen && canPickModel && sessionId && (
<ModelPickerDialog
gw={gw}
sessionId={sessionId}
onClose={() => setModelOpen(false)}
onSubmit={onModelSubmit}
/>
)}
</aside>
);
}
@@ -0,0 +1,40 @@
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { useI18n } from "@/i18n";
export function DeleteConfirmDialog({
cancelLabel,
confirmLabel,
description,
loading,
onCancel,
onConfirm,
open,
title,
}: DeleteConfirmDialogProps) {
const { t } = useI18n();
return (
<ConfirmDialog
open={open}
onCancel={onCancel}
onConfirm={onConfirm}
title={title}
description={description}
loading={loading}
destructive
confirmLabel={confirmLabel ?? t.common.delete}
cancelLabel={cancelLabel ?? t.common.cancel}
/>
);
}
interface DeleteConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}
+186
View File
@@ -0,0 +1,186 @@
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/index";
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
import { useI18n } from "@/i18n/context";
import { LOCALE_META } from "@/i18n";
import type { Locale } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* Language picker — shows the current language's endonym, opens a dropdown
* of all supported locales when clicked. Persists choice to localStorage via
* the I18n context.
*
* Replaces the older two-state EN↔ZH toggle now that we ship 16 locales
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
*
* No country flags by design — languages aren't countries, and flag pairings
* inevitably create political mismappings (e.g. Mandarin variants ≠ any single
* jurisdiction, English ≠ GB, Portuguese ≠ PT). Endonyms are unambiguous.
*
* When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
* `dropUp` so the list opens above the trigger and avoids clipping below the
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
*/
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);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
if (!open || useMobileSheet) return;
function onPointerDown(e: PointerEvent) {
const target = e.target as Node;
if (containerRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, useMobileSheet]);
const current = LOCALE_META[locale];
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
const sheetTitle = t.language.switchTo;
return (
<div ref={containerRef} className="relative inline-flex">
<Button
ghost
onClick={() => setOpen((v) => !v)}
title={t.language.switchTo}
aria-label={t.language.switchTo}
aria-haspopup="listbox"
aria-expanded={open}
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 text-display tracking-wide text-xs"
>
{locale === "en" ? "EN" : current.name}
</Typography>
</span>
</Button>
{useMobileSheet && (
<BottomSheet
backdropDismissLabel={t.common.close}
onClose={() => setOpen(false)}
open={open}
title={sheetTitle}
>
<div aria-label={sheetTitle} role="listbox">
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
</BottomSheet>
)}
{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>
);
}
function LanguageSwitcherOptions({
allLocales,
locale,
setLocale,
setOpen,
}: LanguageSwitcherOptionsProps) {
return (
<>
{allLocales.map(([code, meta]) => {
const selected = code === locale;
return (
<button
aria-selected={selected}
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);
setOpen(false);
}}
role="option"
type="button"
>
<span className="truncate">{meta.name}</span>
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
</button>
);
})}
</>
);
}
interface LanguageSwitcherOptionsProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
locale: Locale;
setLocale: (code: Locale) => void;
setOpen: (open: boolean) => void;
}
interface LanguageSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+383
View File
@@ -0,0 +1,383 @@
import { useMemo, type ReactNode } from "react";
/**
* Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
*
* `streaming` renders a blinking caret at the tail of the last block so it
* appears to hug the final character instead of wrapping onto a new line
* after a block element (paragraph/list/code/…).
*/
export function Markdown({
content,
highlightTerms,
streaming,
}: {
content: string;
highlightTerms?: string[];
streaming?: boolean;
}) {
const blocks = useMemo(() => parseBlocks(content), [content]);
const caret = streaming ? <StreamingCaret /> : null;
return (
<div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => (
<Block
key={i}
block={block}
highlightTerms={highlightTerms}
caret={caret && i === blocks.length - 1 ? caret : null}
/>
))}
{blocks.length === 0 && caret}
</div>
);
}
function StreamingCaret() {
return (
<span
aria-hidden
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
/>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type BlockNode =
| { type: "code"; lang: string; content: string }
| { type: "heading"; level: number; content: string }
| { type: "hr" }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; content: string };
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const blocks: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) {
blocks.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2],
});
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
// Unordered list
if (/^[-*+]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*+]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\d+[.)]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: true, items });
continue;
}
// Empty line
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect consecutive non-empty, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^```/) &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+[.)]\s/) &&
!lines[i].match(/^[-*_]{3,}\s*$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
}
}
return blocks;
}
/* ------------------------------------------------------------------ */
/* Block renderer */
/* ------------------------------------------------------------------ */
function Block({
block,
highlightTerms,
caret,
}: {
block: BlockNode;
highlightTerms?: string[];
caret?: ReactNode;
}) {
switch (block.type) {
case "code":
return (
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>
{block.content}
{caret}
</code>
</pre>
);
case "heading": {
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
const sizes: Record<string, string> = {
h1: "text-base font-bold",
h2: "text-sm font-bold",
h3: "text-sm font-semibold",
h4: "text-sm font-medium",
};
return (
<Tag className={sizes[Tag]}>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</Tag>
);
}
case "hr":
return (
<>
<hr className="border-border" />
{caret}
</>
);
case "list": {
const Tag = block.ordered ? "ol" : "ul";
const last = block.items.length - 1;
return (
<Tag
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
>
{block.items.map((item, i) => (
<li key={i}>
<InlineContent text={item} highlightTerms={highlightTerms} />
{i === last ? caret : null}
</li>
))}
</Tag>
);
}
case "paragraph":
return (
<p>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</p>
);
}
}
/* ------------------------------------------------------------------ */
/* Inline parser + renderer */
/* ------------------------------------------------------------------ */
type InlineNode =
| { type: "text"; content: string }
| { type: "code"; content: string }
| { type: "bold"; content: string }
| { type: "italic"; content: string }
| { type: "link"; text: string; href: string }
| { type: "br" };
function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break
const pattern =
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
if (match[1]) {
// Inline code
nodes.push({ type: "code", content: match[1].slice(1, -1) });
} else if (match[2]) {
// [text](url) link
nodes.push({ type: "link", text: match[3], href: match[4] });
} else if (match[5]) {
// **bold**
nodes.push({ type: "bold", content: match[6] });
} else if (match[7]) {
// *italic*
nodes.push({ type: "italic", content: match[8] });
} else if (match[9]) {
// Bare URL
nodes.push({ type: "link", text: match[9], href: match[9] });
} else if (match[10]) {
// Line break within paragraph
nodes.push({ type: "br" });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", content: text.slice(lastIndex) });
}
return nodes;
}
function InlineContent({
text,
highlightTerms,
}: {
text: string;
highlightTerms?: string[];
}) {
const nodes = useMemo(() => parseInline(text), [text]);
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case "text":
return (
<HighlightedText
key={i}
text={node.content}
terms={highlightTerms}
/>
);
case "code":
return (
<code
key={i}
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
>
{node.content}
</code>
);
case "bold":
return (
<strong key={i} className="font-semibold">
<HighlightedText text={node.content} terms={highlightTerms} />
</strong>
);
case "italic":
return (
<em key={i}>
<HighlightedText text={node.content} terms={highlightTerms} />
</em>
);
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={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
>
{node.text}
</a>
);
}
case "br":
return <br key={i} />;
}
})}
</>
);
}
/** Highlight search terms within a plain text string. */
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
if (!terms || terms.length === 0) return <>{text}</>;
// Build a regex that matches any of the search terms (case-insensitive)
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning px-0.5">
{part}
</mark>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
interface ModelInfoCardProps {
/** Current model string from config state — used to detect changes */
currentModel: string;
/** Bumped after config saves to trigger re-fetch */
refreshKey?: number;
}
export function ModelInfoCard({
currentModel,
refreshKey = 0,
}: ModelInfoCardProps) {
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
if (!currentModel) return;
// Re-fetch when model changes OR when refreshKey bumps (after save)
const fetchKey = `${currentModel}:${refreshKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
setLoading(true);
api
.getModelInfo()
.then(setInfo)
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [currentModel, refreshKey]);
if (loading) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Spinner className="text-xs" />
Loading model info
</div>
);
}
if (!info || !info.model || info.effective_context_length <= 0) return null;
const caps = info.capabilities;
const hasCaps = caps && Object.keys(caps).length > 0;
return (
<div className="border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
<span className="font-medium">Context Window</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500 text-xs">
(override auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-text-tertiary text-xs">
auto-detected
</span>
)}
</div>
</div>
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lightbulb className="h-3.5 w-3.5" />
<span className="font-medium">Max Output</span>
</div>
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(caps.max_output_tokens)}
</span>
</div>
)}
{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-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-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-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-xs font-medium text-text-secondary">
{caps.model_family}
</span>
)}
</div>
)}
</div>
);
}
+470
View File
@@ -0,0 +1,470 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
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.
*
* Mirrors ui-tui/src/components/modelPicker.tsx:
* Stage 1: pick provider (authenticated providers only)
* Stage 2: pick model within that provider
*
* Two invocation modes:
*
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
* loads options via `model.options` JSON-RPC and emits the result as a
* slash command string (`/model <model> --provider <slug> [--global]`)
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
*
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
* `onApply`. The picker fetches options via the REST endpoint and calls
* `onApply(provider, model, persistGlobal)` instead of emitting a slash
* command. This lets the Models page reuse the same UI without
* requiring an open chat PTY.
*/
interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
warning?: string;
}
interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
interface Props {
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
gw?: GatewayClient;
sessionId?: string;
onSubmit?(slashCommand: string): void;
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
loader?(): Promise<ModelOptionsResponse>;
onApply?(args: {
provider: string;
model: string;
persistGlobal: boolean;
}): Promise<void> | void;
onClose(): void;
title?: string;
/** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
alwaysGlobal?: boolean;
}
export function ModelPickerDialog(props: Props) {
const {
gw,
sessionId,
onSubmit,
loader,
onApply,
onClose,
title = "Switch Model",
alwaysGlobal = false,
} = props;
const standalone = !!loader && !!onApply;
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
const [currentModel, setCurrentModel] = useState("");
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSlug, setSelectedSlug] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [query, setQuery] = useState("");
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
const [applying, setApplying] = useState(false);
const closedRef = useRef(false);
// Load providers + models on open.
useEffect(() => {
closedRef.current = false;
const promise = standalone
? (loader as () => Promise<ModelOptionsResponse>)()
: (gw as GatewayClient).request<ModelOptionsResponse>(
"model.options",
sessionId ? { session_id: sessionId } : {},
);
promise
.then((r) => {
if (closedRef.current) return;
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
setCurrentProviderSlug(String(r?.provider ?? ""));
setSelectedSlug(
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
);
setSelectedModel("");
setLoading(false);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
closedRef.current = true;
};
// Deliberately omit props from deps — stable for the dialog's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Esc closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const selectedProvider = useMemo(
() => providers.find((p) => p.slug === selectedSlug) ?? null,
[providers, selectedSlug],
);
const models = useMemo(
() => selectedProvider?.models ?? [],
[selectedProvider],
);
const needle = query.trim().toLowerCase();
const filteredProviders = useMemo(
() =>
!needle
? providers
: providers.filter(
(p) =>
p.name.toLowerCase().includes(needle) ||
p.slug.toLowerCase().includes(needle) ||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
),
[providers, needle],
);
const filteredModels = useMemo(
() =>
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
[models, needle],
);
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const confirm = async () => {
if (!canConfirm || !selectedProvider) return;
if (standalone && onApply) {
setApplying(true);
try {
await onApply({
provider: selectedProvider.slug,
model: selectedModel,
persistGlobal,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (onSubmit) {
const global = persistGlobal ? " --global" : "";
onSubmit(
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
);
onClose();
}
};
// Portal to document.body: the main dashboard column in App.tsx is
// `relative z-2`, which creates a stacking context that traps fixed
// descendants below the app sidebar (z-50). Without the portal this
// modal's z-[100] is scoped to z-2 and the sidebar covers its left
// edge — visible especially in the Large theme variants where the
// larger root font widens the dialog into the sidebar's column. See
// Toast.tsx for the same pattern.
return createPortal(
<div
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="model-picker-title"
>
<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"
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">
<h2
id="model-picker-title"
className="font-mondwest text-display text-base tracking-wider"
>
{title}
</h2>
<p className="text-xs text-muted-foreground mt-1 font-mono">
current: {currentModel || "(unknown)"}
{currentProviderSlug && ` · ${currentProviderSlug}`}
</p>
</header>
<div className="px-5 pt-3 pb-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
placeholder="Filter providers and models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
<ProviderColumn
loading={loading}
error={error}
providers={filteredProviders}
total={providers.length}
selectedSlug={selectedSlug}
query={needle}
onSelect={(slug) => {
setSelectedSlug(slug);
setSelectedModel("");
}}
/>
<ModelColumn
provider={selectedProvider}
models={filteredModels}
allModels={models}
selectedModel={selectedModel}
currentModel={currentModel}
currentProviderSlug={currentProviderSlug}
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
// Confirm on next tick so state settles.
window.setTimeout(confirm, 0);
}}
/>
</div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
{alwaysGlobal ? (
<span className="text-xs text-muted-foreground">
Saves to config.yaml applies to new sessions.
</span>
) : (
<div className="flex items-center gap-2">
<Checkbox
checked={persistGlobal}
id="model-picker-persist-global"
onCheckedChange={(checked) =>
setPersistGlobal(checked === true)
}
/>
<Label
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)
</Label>
</div>
)}
<div className="flex items-center gap-2 ml-auto">
<Button outlined onClick={onClose} disabled={applying}>
Cancel
</Button>
<Button onClick={confirm} disabled={!canConfirm}>
{applying ? <Spinner /> : "Switch"}
</Button>
</div>
</footer>
</div>
</div>,
document.body,
);
}
/* ------------------------------------------------------------------ */
/* Provider column */
/* ------------------------------------------------------------------ */
function ProviderColumn({
loading,
error,
providers,
total,
selectedSlug,
query,
onSelect,
}: {
loading: boolean;
error: string | null;
providers: ModelOptionProvider[];
total: number;
selectedSlug: string;
query: string;
onSelect(slug: string): void;
}) {
return (
<div className="border-r border-border overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Spinner className="text-xs" /> loading
</div>
)}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && (
<div className="p-4 text-xs text-muted-foreground italic">
{query
? "no matches"
: total === 0
? "no authenticated providers"
: "no matches"}
</div>
)}
{providers.map((p) => {
const active = p.slug === selectedSlug;
return (
<ListItem
key={p.slug}
active={active}
onClick={() => onSelect(p.slug)}
className={`items-start text-xs border-l-2 ${
active ? "border-l-primary" : "border-l-transparent"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />}
</div>
<div className="text-xs text-text-secondary font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
</ListItem>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Model column */
/* ------------------------------------------------------------------ */
function ModelColumn({
provider,
models,
allModels,
selectedModel,
currentModel,
currentProviderSlug,
onSelect,
onConfirm,
}: {
provider: ModelOptionProvider | null;
models: string[];
allModels: string[];
selectedModel: string;
currentModel: string;
currentProviderSlug: string;
onSelect(model: string): void;
onConfirm(model: string): void;
}) {
if (!provider) {
return (
<div className="overflow-y-auto">
<div className="p-4 text-xs text-muted-foreground italic">
pick a provider
</div>
</div>
);
}
return (
<div className="overflow-y-auto">
{provider.warning && (
<div className="p-3 text-xs text-destructive border-b border-border">
{provider.warning}
</div>
)}
{models.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground italic">
{allModels.length
? "no models match your filter"
: "no models listed for this provider"}
</div>
) : (
models.map((m) => {
const active = m === selectedModel;
const isCurrent =
m === currentModel && provider.slug === currentProviderSlug;
return (
<ListItem
key={m}
active={active}
onClick={() => onSelect(m)}
onDoubleClick={() => onConfirm(m)}
className="px-3 py-1.5 text-xs font-mono"
>
<Check
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="flex-1 truncate">{m}</span>
{isCurrent && <CurrentTag />}
</ListItem>
);
})
)}
</div>
);
}
function CurrentTag() {
return (
<span className="text-display text-xs tracking-wider text-primary shrink-0">
current
</span>
);
}
+374
View File
@@ -0,0 +1,374 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, X, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
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;
onClose: () => void;
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
}
type Phase =
| "idle"
| "starting"
| "awaiting_user"
| "submitting"
| "polling"
| "approved"
| "error";
export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState("");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null);
const { t } = useI18n();
// Initiate flow on mount
useEffect(() => {
isMounted.current = true;
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
if (resp.flow === "pkce") {
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else {
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Failed to start login: ${e}`);
});
return () => {
isMounted.current = false;
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tick the countdown
useEffect(() => {
if (secondsLeft === null) return;
if (phase === "approved" || phase === "error") return;
const tick = window.setInterval(() => {
if (!isMounted.current) return;
setSecondsLeft((s) => {
if (s !== null && s <= 1) {
setPhase("error");
setErrorMsg(t.oauth.sessionExpired);
return 0;
}
return s !== null && s > 0 ? s - 1 : 0;
});
}, 1000);
return () => window.clearInterval(tick);
}, [secondsLeft, phase, t]);
// Device-code: poll backend every 2s
useEffect(() => {
if (!start || start.flow !== "device_code" || phase !== "polling") return;
const sid = start.session_id;
pollTimer.current = window.setInterval(async () => {
try {
const resp = await api.pollOAuthSession(provider.id, sid);
if (!isMounted.current) return;
if (resp.status === "approved") {
setPhase("approved");
if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else if (resp.status !== "pending") {
setPhase("error");
setErrorMsg(resp.error_message || `Login ${resp.status}`);
if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Polling failed: ${e}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
}
}, 2000);
return () => {
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
const handleSubmitPkceCode = async () => {
if (!start || start.flow !== "pkce") return;
if (!pkceCode.trim()) return;
setPhase("submitting");
setErrorMsg(null);
try {
const resp = await api.submitOAuthCode(
provider.id,
start.session_id,
pkceCode.trim(),
);
if (!isMounted.current) return;
if (resp.ok && resp.status === "approved") {
setPhase("approved");
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else {
setPhase("error");
setErrorMsg(resp.message || "Token exchange failed");
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Submit failed: ${e}`);
}
};
const handleClose = async () => {
if (start && phase !== "approved" && phase !== "error") {
try {
await api.cancelOAuthSession(start.session_id);
} catch {
// ignore
}
}
onClose();
};
const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose();
};
const fmtTime = (s: number | null) => {
if (s === null) return "";
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={handleBackdrop}
role="dialog"
aria-modal="true"
aria-labelledby="oauth-modal-title"
>
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl")}>
<Button
ghost
size="icon"
onClick={handleClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label={t.common.close}
>
<X />
</Button>
<div className="p-6 flex flex-col gap-4">
<div>
<H2
id="oauth-modal-title"
variant="sm"
mondwest
className="tracking-wider uppercase"
>
{t.oauth.connect} {provider.name}
</H2>
{secondsLeft !== null &&
phase !== "approved" &&
phase !== "error" && (
<p className="text-xs text-muted-foreground mt-1">
{t.oauth.sessionExpires.replace(
"{time}",
fmtTime(secondsLeft),
)}
</p>
)}
</div>
{phase === "starting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Spinner />
{t.oauth.initiatingLogin}
</div>
)}
{start?.flow === "pkce" && phase === "awaiting_user" && (
<>
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
<li>{t.oauth.pkceStep1}</li>
<li>{t.oauth.pkceStep2}</li>
<li>{t.oauth.pkceStep3}</li>
</ol>
<div className="flex flex-col gap-2">
<Input
value={pkceCode}
onChange={(e) => setPkceCode(e.target.value)}
placeholder={t.oauth.pasteCode}
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
autoFocus
/>
<div className="flex items-center gap-2 justify-between">
<a
href={
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
.auth_url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenAuth}
</a>
<Button
onClick={handleSubmitPkceCode}
disabled={!pkceCode.trim()}
>
{t.oauth.submitCode}
</Button>
</div>
</div>
</>
)}
{phase === "submitting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Spinner />
{t.oauth.exchangingCode}
</div>
)}
{start?.flow === "device_code" && phase === "polling" && (
<>
<p className="text-sm text-muted-foreground">
{t.oauth.enterCodePrompt}
</p>
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
{
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
</code>
<CopyButton
text={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
/>
</div>
<a
href={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).verification_url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenVerification}
</a>
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
<Spinner className="text-xs" />
{t.oauth.waitingAuth}
</div>
</>
)}
{phase === "approved" && (
<div className="flex items-center gap-3 py-6 text-sm text-success">
<Check className="h-5 w-5" />
{t.oauth.connectedClosing}
</div>
)}
{phase === "error" && (
<>
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg || t.oauth.loginFailed}
</div>
<div className="flex justify-end gap-2">
<Button outlined onClick={handleClose}>
{t.common.close}
</Button>
<Button
onClick={() => {
if (start?.session_id) {
api.cancelOAuthSession(start.session_id).catch(() => {});
}
setErrorMsg(null);
setStart(null);
setPkceCode("");
setPhase("starting");
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(
resp.flow === "device_code"
? "polling"
: "awaiting_user",
);
if (resp.flow === "pkce") {
window.open(
resp.auth_url,
"_blank",
"noopener,noreferrer",
);
} else {
window.open(
resp.verification_url,
"_blank",
"noopener,noreferrer",
);
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`${t.common.retry} failed: ${e}`);
});
}}
>
{t.common.retry}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ShieldCheck,
ShieldOff,
ExternalLink,
RefreshCw,
Terminal,
} from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
interface Props {
onError?: (msg: string) => void;
onSuccess?: (msg: string) => void;
}
function formatExpiresAt(
expiresAt: string | null | undefined,
expiresInTemplate: string,
): string | null {
if (!expiresAt) return null;
try {
const dt = new Date(expiresAt);
if (Number.isNaN(dt.getTime())) return null;
const now = Date.now();
const diff = dt.getTime() - now;
if (diff < 0) return "expired";
const mins = Math.floor(diff / 60_000);
if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`);
const hours = Math.floor(mins / 60);
if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`);
const days = Math.floor(hours / 24);
return expiresInTemplate.replace("{time}", `${days}d`);
} catch {
return null;
}
}
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
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);
onErrorRef.current = onError;
const refresh = useCallback(() => {
setLoading(true);
api
.getOAuthProviders()
.then((resp) => setProviders(resp.providers))
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const handleDisconnect = async (provider: OAuthProvider) => {
setBusyId(provider.id);
setDisconnectTarget(null);
try {
await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
refresh();
} catch (e) {
onError?.(`${t.oauth.disconnect} failed: ${e}`);
} finally {
setBusyId(null);
}
};
const connectedCount =
providers?.filter((p) => p.status.logged_in).length ?? 0;
const totalCount = providers?.length ?? 0;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.oauth.providerLogins}
</CardTitle>
</div>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={refresh}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>
<CardDescription>
{t.oauth.description
.replace("{connected}", String(connectedCount))
.replace("{total}", String(totalCount))}
</CardDescription>
</CardHeader>
<CardContent>
{loading && providers === null && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{providers && providers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{t.oauth.noProviders}
</p>
)}
<div className="flex flex-col divide-y divide-border">
{providers?.map((p) => {
const expiresLabel = formatExpiresAt(
p.status.expires_at,
t.oauth.expiresIn,
);
const isBusy = busyId === p.id;
return (
<div
key={p.id}
className="flex items-center justify-between gap-4 py-3"
>
<div className="flex items-start gap-3 min-w-0 flex-1">
{p.status.logged_in ? (
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
) : (
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span>
<Badge
tone="outline"
className="text-xs tracking-wide"
>
{t.oauth.flowLabels[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge tone="success" className="text-xs">
{t.oauth.connected}
</Badge>
)}
{expiresLabel === "expired" && (
<Badge tone="destructive" className="text-xs">
{t.oauth.expired}
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge tone="outline" className="text-xs">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<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="text-text-tertiary">
{" "}
· {p.status.source_label}
</span>
)}
</span>
)}
{!p.status.logged_in && (
<>
<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">
{p.status.error}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{p.docs_url && (
<a
href={p.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
title={`Open ${p.name} docs`}
>
<Button ghost size="icon">
<ExternalLink />
</Button>
</a>
)}
{!p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
className="uppercase"
onClick={() => setLoginFor(p)}
>
{t.oauth.login}
</Button>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
outlined
className="uppercase"
onClick={() => setDisconnectTarget(p)}
disabled={isBusy}
prefix={isBusy ? <Spinner /> : undefined}
>
{t.oauth.disconnect}
</Button>
)}
{p.status.logged_in && p.flow === "external" && (
<span className="text-xs text-text-tertiary italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" />
{t.oauth.managedExternally}
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
{loginFor && (
<OAuthLoginModal
provider={loginFor}
onClose={() => {
setLoginFor(null);
refresh();
}}
onSuccess={(msg) => onSuccess?.(msg)}
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>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react";
import type { PlatformStatus } from "@/lib/api";
import { isoTimeAgo } from "@/lib/utils";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { useI18n } from "@/i18n";
export function PlatformsCard({ platforms }: PlatformsCardProps) {
const { t } = useI18n();
const platformStateBadge: Record<
string,
{ tone: "success" | "warning" | "destructive"; label: string }
> = {
connected: { tone: "success", label: t.status.connected },
disconnected: { tone: "warning", label: t.status.disconnected },
fatal: { tone: "destructive", label: t.status.error },
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.connectedPlatforms}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = platformStateBadge[info.state] ?? {
tone: "outline" as const,
label: info.state,
};
const IconComponent =
info.state === "connected"
? Wifi
: info.state === "fatal"
? AlertTriangle
: WifiOff;
return (
<div
key={name}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex items-center gap-3 min-w-0 w-full">
<IconComponent
className={`h-4 w-4 shrink-0 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-mondwest normal-case text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="font-mondwest normal-case text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="font-mondwest normal-case text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge
tone={display.tone}
className="shrink-0 self-start sm:self-center"
>
{display.tone === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
}
+42
View File
@@ -0,0 +1,42 @@
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({ status }: SidebarFooterProps) {
const { t } = useI18n();
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2.5",
"border-t border-current/10",
)}
>
<Typography
className="font-mono-ui text-xs tabular-nums tracking-[0.08em] text-text-tertiary lowercase"
>
{status?.version != null ? `v${status.version}` : "—"}
</Typography>
<a
href="https://nousresearch.com"
target="_blank"
rel="noopener noreferrer"
className={cn(
"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",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</a>
</div>
);
}
interface SidebarFooterProps {
status: StatusResponse | null;
}
+72
View File
@@ -0,0 +1,72 @@
import { Link } from "react-router-dom";
import type { StatusResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
const { t } = useI18n();
if (status === null) {
return (
<div className="px-5 py-1.5" aria-hidden>
<div className="h-2 w-[80%] max-w-full animate-pulse rounded-sm bg-midground/10" />
</div>
);
}
const gw = gatewayLine(status, t);
const { activeSessionsLabel, gatewayStatusLabel } = t.app;
return (
<Link
to="/sessions"
title={t.app.statusOverview}
className={cn(
"block text-left",
"px-5 pb-2 pt-0.5",
"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-xs leading-snug tracking-[0.08em]">
<p className="break-words">
<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-text-tertiary">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-text-secondary">
{status.active_sessions}
</span>
</p>
</div>
</Link>
);
}
export function gatewayLine(
status: StatusResponse,
t: ReturnType<typeof useI18n>["t"],
): { label: string; tone: string } {
const g = t.app.gatewayStrip;
const byState: Record<string, { label: string; tone: string }> = {
running: { label: g.running, tone: "text-success" },
starting: { label: g.starting, tone: "text-warning" },
startup_failed: { label: g.failed, tone: "text-destructive" },
stopped: { label: g.stopped, tone: "text-muted-foreground" },
};
if (status.gateway_state && byState[status.gateway_state]) {
return byState[status.gateway_state];
}
return status.gateway_running
? { label: g.running, tone: "text-success" }
: { label: g.off, tone: "text-muted-foreground" };
}
interface SidebarStatusStripProps {
status: StatusResponse | null;
}
+171
View File
@@ -0,0 +1,171 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/**
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
* arrow keys or click to select, Tab to apply, Enter to submit.
*
* The parent owns all keyboard handling via `ref.handleKey`, which returns
* true when the popover consumed the event, so the composer's Enter/arrow
* logic stays in one place.
*/
export interface CompletionItem {
display: string;
text: string;
meta?: string;
}
export interface SlashPopoverHandle {
/** Returns true if the key was consumed by the popover. */
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
}
interface Props {
input: string;
gw: GatewayClient | null;
onApply(nextInput: string): void;
}
interface CompletionResponse {
items?: CompletionItem[];
replace_from?: number;
}
const DEBOUNCE_MS = 60;
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
function SlashPopover({ input, gw, onApply }, ref) {
const [items, setItems] = useState<CompletionItem[]>([]);
const [selected, setSelected] = useState(0);
const [replaceFrom, setReplaceFrom] = useState(1);
const lastInputRef = useRef<string>("");
// Debounced completion fetch. We never clear `items` in the effect body
// (doing so would flag react-hooks/set-state-in-effect); instead the
// render guard below hides stale items once the input stops matching.
useEffect(() => {
const trimmed = input ?? "";
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
if (!trimmed.startsWith("/")) lastInputRef.current = "";
return;
}
lastInputRef.current = trimmed;
const timer = window.setTimeout(async () => {
if (lastInputRef.current !== trimmed) return;
try {
const r = await gw.request<CompletionResponse>("complete.slash", {
text: trimmed,
});
if (lastInputRef.current !== trimmed) return;
setItems(r?.items ?? []);
setReplaceFrom(r?.replace_from ?? 1);
setSelected(0);
} catch {
if (lastInputRef.current === trimmed) setItems([]);
}
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [input, gw]);
const apply = useCallback(
(item: CompletionItem) => {
onApply(input.slice(0, replaceFrom) + item.text);
},
[input, replaceFrom, onApply],
);
// Only consume keys when the popover is actually visible. Stale items from
// a previous slash prefix are ignored once the user deletes the "/".
const visible = items.length > 0 && input.startsWith("/");
useImperativeHandle(
ref,
() => ({
handleKey: (e) => {
if (!visible) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelected((s) => (s + 1) % items.length);
return true;
case "ArrowUp":
e.preventDefault();
setSelected((s) => (s - 1 + items.length) % items.length);
return true;
case "Tab": {
e.preventDefault();
const item = items[selected];
if (item) apply(item);
return true;
}
case "Escape":
e.preventDefault();
setItems([]);
return true;
default:
return false;
}
},
}),
[visible, items, selected, apply],
);
if (!visible) return null;
return (
<div
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
role="listbox"
>
{items.map((it, i) => {
const active = i === selected;
return (
<ListItem
key={`${it.text}-${i}`}
active={active}
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className="px-3 py-1.5"
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="font-mono text-xs shrink-0 truncate">
{it.display}
</span>
{it.meta && (
<span className="text-xs text-text-tertiary truncate ml-auto">
{it.meta}
</span>
)}
</ListItem>
);
})}
</div>
);
},
);
+243
View File
@@ -0,0 +1,243 @@
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/index";
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
import { BUILTIN_THEMES, useTheme } from "@/themes";
import type { DashboardTheme, ThemeListEntry } from "@/themes";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* Compact theme picker mounted next to the language switcher in the header.
* Each dropdown row shows a 3-stop swatch (background / midground / warm
* glow) so users can preview the palette before committing. User-defined
* themes from `~/.hermes/dashboard-themes/*.yaml` use their API-provided
* definitions so they show real palette swatches just like built-ins.
*
* When placed at the bottom of a container (e.g. the sidebar rail), pass
* `dropUp` so the menu opens above the trigger instead of clipping below
* the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` so the picker is not clipped by
* the sidebar (same idea as a responsive Drawer).
*/
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);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
useEffect(() => {
if (!open || useMobileSheet) return;
const onMouseDown = (e: MouseEvent) => {
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);
}, [open, close, useMobileSheet]);
const current = availableThemes.find((th) => th.name === themeName);
const label = current?.label ?? themeName;
const sheetTitle = t.theme?.title ?? "Theme";
return (
<div ref={wrapperRef} className="relative">
<Button
ghost
size={collapsed ? "icon" : undefined}
onClick={() => setOpen((o) => !o)}
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"
>
<span className="inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" />
{!collapsed && (
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
)}
</span>
</Button>
{useMobileSheet && (
<BottomSheet
backdropDismissLabel={t.common.close}
onClose={close}
open={open}
title={sheetTitle}
>
<div aria-label={sheetTitle} role="listbox">
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
</BottomSheet>
)}
{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>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
function ThemeSwitcherOptions({
availableThemes,
close,
setTheme,
themeName,
}: ThemeSwitcherOptionsProps) {
return (
<>
{availableThemes.map((th) => {
const isActive = th.name === themeName;
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
return (
<ListItem
active={isActive}
aria-selected={isActive}
className="gap-3"
key={th.name}
onClick={() => {
setTheme(th.name);
close();
}}
role="option"
>
{paletteTheme ? (
<ThemeSwatch theme={paletteTheme} />
) : (
<PlaceholderSwatch />
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography
mondwest
className="truncate text-display text-xs tracking-wide"
>
{th.label}
</Typography>
{th.description && (
<Typography className="truncate text-xs tracking-normal text-text-tertiary">
{th.description}
</Typography>
)}
</div>
<Check
className={cn(
"h-3 w-3 shrink-0 text-midground",
isActive ? "opacity-100" : "opacity-0",
)}
/>
</ListItem>
);
})}
</>
);
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette;
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: background.hex }} />
<span className="flex-1" style={{ background: midground.hex }} />
<span className="flex-1" style={{ background: warmGlow }} />
</div>
);
}
function PlaceholderSwatch() {
return (
<div
aria-hidden
className="h-4 w-9 shrink-0 border border-dashed border-current/20"
/>
);
}
interface ThemeSwitcherOptionsProps {
availableThemes: ThemeListEntry[];
close: () => void;
setTheme: (name: string) => void;
themeName: string;
}
interface ThemeSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+228
View File
@@ -0,0 +1,228 @@
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
/**
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
*
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
* in between) as a single collapsible item in the transcript:
*
* ▸ ● read_file(path=/foo) 2.3s
*
* Click the header to reveal a preformatted body with context (args), the
* streaming preview (while running), and the final summary or error. Error
* rows auto-expand so failures aren't silently collapsed.
*/
export interface ToolEntry {
kind: "tool";
id: string;
tool_id: string;
name: string;
context?: string;
preview?: string;
summary?: string;
error?: string;
inline_diff?: string;
status: "running" | "done" | "error";
startedAt: number;
completedAt?: number;
}
const STATUS_TONE: Record<ToolEntry["status"], string> = {
running: "border-primary/40 bg-primary/[0.04]",
done: "border-border bg-muted/20",
error: "border-destructive/50 bg-destructive/[0.04]",
};
const BULLET_TONE: Record<ToolEntry["status"], string> = {
running: "text-primary",
done: "text-primary/80",
error: "text-destructive",
};
const TICK_MS = 500;
export function ToolCall({ tool }: { tool: ToolEntry }) {
// `open` is derived: errors default-expanded, everything else collapsed.
// `null` means "follow the default"; any explicit bool is the user's override.
// This lets a running tool flip to expanded automatically when it errors,
// without mirroring state in an effect.
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const open = userOverride ?? tool.status === "error";
// Tick `now` while the tool is running so the elapsed label updates live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (tool.status !== "running") return;
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
return () => window.clearInterval(id);
}, [tool.status]);
// Historical tools (hydrated from session.resume) signal missing timestamps
// with `startedAt === 0`; we hide the elapsed badge for those rather than
// rendering a misleading "0ms".
const hasTimestamps = tool.startedAt > 0;
const elapsed = hasTimestamps
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
: null;
const hasBody = !!(
tool.context ||
tool.preview ||
tool.summary ||
tool.error ||
tool.inline_diff
);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
>
<ListItem
onClick={() => setUserOverride(!open)}
disabled={!hasBody}
aria-expanded={open}
className="px-2.5 py-1.5 text-xs hover:bg-foreground/2 disabled:cursor-default"
>
{hasBody ? (
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<span className="w-3 shrink-0" />
)}
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
<span className="font-mono font-medium shrink-0">{tool.name}</span>
<span className="font-mono text-text-secondary truncate min-w-0 flex-1">
{tool.context ?? ""}
</span>
{tool.status === "running" && (
<span
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
title="running"
/>
)}
{tool.status === "error" && (
<AlertCircle
className="h-3 w-3 shrink-0 text-destructive"
aria-label="error"
/>
)}
{tool.status === "done" && (
<Check
className="h-3 w-3 shrink-0 text-primary/80"
aria-label="done"
/>
)}
{elapsed && (
<span className="font-mono text-xs text-text-tertiary tabular-nums shrink-0">
{elapsed}
</span>
)}
</ListItem>
{open && hasBody && (
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
{tool.context && <Section label="context">{tool.context}</Section>}
{tool.preview && tool.status === "running" && (
<Section label="streaming">
{tool.preview}
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
</Section>
)}
{tool.inline_diff && (
<Section label="diff">
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
{colorizeDiff(tool.inline_diff)}
</pre>
</Section>
)}
{tool.summary && (
<Section label="result">
<span className="text-foreground/90 whitespace-pre-wrap">
{tool.summary}
</span>
</Section>
)}
{tool.error && (
<Section label="error" tone="error">
<span className="text-destructive whitespace-pre-wrap">
{tool.error}
</span>
</Section>
)}
</div>
)}
</div>
);
}
function Section({
label,
children,
tone,
}: {
label: string;
children: React.ReactNode;
tone?: "error";
}) {
return (
<div className="flex gap-3">
<span
className={`text-display font-mondwest tracking-wider text-xs shrink-0 w-20 pt-0.5 ${
tone === "error" ? "text-destructive" : "text-text-tertiary"
}`}
>
{label}
</span>
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
</div>
);
}
function fmtElapsed(ms: number): string {
const sec = Math.max(0, ms) / 1000;
if (sec < 1) return `${Math.round(ms)}ms`;
if (sec < 10) return `${sec.toFixed(1)}s`;
if (sec < 60) return `${Math.round(sec)}s`;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return s ? `${m}m ${s}s` : `${m}m`;
}
/** Colorize unified-diff lines for the inline diff section. */
function colorizeDiff(diff: string): React.ReactNode {
return diff.split("\n").map((line, i) => (
<div key={i} className={diffLineClass(line)}>
{line || "\u00A0"}
</div>
));
}
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
return "text-text-secondary";
}