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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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][];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user