Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
@@ -0,0 +1,97 @@
/**
* Change password dialog: validates the old password and that
* the two new-password entries match; refreshes /api/me on success. The initial-password
* notice banner disappears once passwordIsInitial clears. Shared by the sidebar user menu and the notice banner.
*/
import { useEffect, useState } from "react";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useAuth } from "../../state/auth";
import { Button } from "../ui/button";
import { PasswordInput } from "../ui/password-input";
import { Modal } from "../ui/modal";
export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { refresh } = useAuth();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
setError(null);
}, [open]);
const submit = async () => {
if (!oldPassword || !newPassword || !confirmPassword) {
setError(S.common.requiredField);
return;
}
if (newPassword !== confirmPassword) {
setError(S.account.passwordMismatch);
return;
}
setBusy(true);
setError(null);
try {
await api.changePassword({ oldPassword, newPassword });
await refresh();
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
}
};
return (
<Modal
open={open}
title={S.account.changePassword}
onClose={onClose}
footer={
<>
<Button onClick={onClose} disabled={busy}>
{S.common.cancel}
</Button>
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
{S.common.save}
</Button>
</>
}
>
<div className="space-y-3">
<PasswordInput
label={S.account.oldPassword}
size="sm"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
autoComplete="current-password"
autoFocus
/>
<PasswordInput
label={S.account.newPassword}
size="sm"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
hint={S.auth.passwordHint}
/>
<PasswordInput
label={S.account.confirmPassword}
size="sm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</Modal>
);
}
@@ -0,0 +1,186 @@
/**
* App main layout:
* - >=md: left single-column sidebar (Project / new chat / nav / Session list / user config) + main content;
* - <md: top thin bar (hamburger -> sidebar drawer + brand name) + main content.
* All chrome uses solid backgrounds and avoids stacking contexts (frosted-glass/transform would trap overlay z-index).
*/
import { useState } from "react";
import { NavLink, Outlet } from "react-router";
import { S } from "../../lib/strings";
import { useAuth } from "../../state/auth";
import { Drawer } from "../ui/drawer";
import { Sidebar } from "./sidebar";
import { ChangePasswordDialog } from "../account/change-password-dialog";
/** Narrow-rail nav icons (matches Sidebar NAV_ICONS). */
const RAIL_NAV: ReadonlyArray<{ to: string; label: string; icon: string }> = [
{ to: "/chat", label: "chat", icon: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4.2-7.6L21 4v5h-5" },
{
to: "/agents",
label: "agents",
icon: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
},
{
to: "/skills",
label: "skills",
icon: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
},
{
to: "/models",
label: "models",
icon: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3",
},
{ to: "/usage", label: "usage", icon: "M4 20V10m6 10V4m6 16v-7m4 7H2" },
{ to: "/traces", label: "traces", icon: "M4 6h16M4 12h10M4 18h13" },
];
export function AppLayout() {
const { user } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("penguin.sidebarCollapsed") === "1",
);
const toggleCollapsed = () =>
setCollapsed((v) => {
const next = !v;
localStorage.setItem("penguin.sidebarCollapsed", next ? "1" : "0");
return next;
});
return (
<div className="flex h-full">
{/* Desktop: single-column sidebar (collapsible to a narrow rail) */}
<aside
className={`hidden shrink-0 border-r border-gray-200 bg-gray-50 md:block dark:border-gray-800 dark:bg-gray-900 ${
collapsed ? "w-12" : "w-64 lg:w-72"
}`}
>
{collapsed ? (
// Collapsed narrow rail: expand button on top, icon nav below, user avatar at the bottom; no Logo shown.
<div className="flex h-full flex-col items-center gap-1 py-2.5">
<button
type="button"
title={S.nav.expandSidebar}
aria-label={S.nav.expandSidebar}
onClick={toggleCollapsed}
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M9 6l6 6-6 6M20 4v16" />
</svg>
</button>
<nav className="mt-1 flex flex-col items-center gap-1">
{RAIL_NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
title={item.label}
className={({ isActive }) =>
`flex h-8 w-8 items-center justify-center rounded-md transition-colors duration-150 ${
isActive
? "bg-gray-200/70 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
: "text-gray-500 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
}`
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={item.icon} />
</svg>
</NavLink>
))}
</nav>
<button
type="button"
title={`${user?.userId ?? ""} · ${S.nav.expandSidebar}`}
aria-label={user?.userId ?? S.auth.admin}
onClick={toggleCollapsed}
className="mt-auto flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"
>
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
</button>
</div>
) : (
<Sidebar onCollapse={toggleCollapsed} />
)}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Mobile: top thin bar (hamburger + brand) */}
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-2 md:hidden dark:border-gray-800 dark:bg-gray-950">
<button
type="button"
aria-label={S.chat.sessionList}
onClick={() => setDrawerOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
aria-hidden
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="text-sm font-semibold">{S.appName}</span>
</header>
{/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change */}
{user?.passwordIsInitial && (
<div className="flex shrink-0 items-center justify-center gap-3 border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300">
<span>{S.account.initialPasswordBanner}</span>
<button
type="button"
className="shrink-0 font-medium underline underline-offset-2 hover:text-amber-950 dark:hover:text-amber-100"
onClick={() => setChangePasswordOpen(true)}
>
{S.account.changeNow}
</button>
</div>
)}
<main className="min-h-0 flex-1 overflow-hidden">
<Outlet />
</main>
</div>
<ChangePasswordDialog
open={changePasswordOpen}
onClose={() => setChangePasswordOpen(false)}
/>
{/* Mobile: sidebar drawer */}
<Drawer open={drawerOpen} side="left" title={S.appName} onClose={() => setDrawerOpen(false)}>
<div className="h-full bg-gray-50 dark:bg-gray-900">
<Sidebar onNavigate={() => setDrawerOpen(false)} />
</div>
</Drawer>
</div>
);
}
@@ -0,0 +1,310 @@
/**
* Project dialogs: create Project, Project settings
* (member management and deletion, owner only). Invoked from the sidebar's Project switcher.
*/
import { useEffect, useState } from "react";
import type { MemberInfo } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import {
PROJECT_ID_MAX_LENGTH,
PROJECT_SUFFIX_PATTERN,
SEMANTIC_ID_PATTERN,
} from "../../lib/semantic-id";
import { projectDisplayName, useProject } from "../../state/project";
import { useAuth } from "../../state/auth";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Modal } from "../ui/modal";
import { Badge } from "../ui/badge";
export function CreateProjectDialog({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: (projectId: string) => void;
}) {
const { user } = useAuth();
// Non-admin Project ids are forced to have a "<username>-" prefix: the input locks the prefix segment, only the rest is editable.
const prefix = user && !user.isAdmin ? `${user.userId}-` : "";
const [idInput, setIdInput] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// No draft is kept: the form starts empty every time it opens.
useEffect(() => {
if (!open) return;
setIdInput("");
setName("");
setError(null);
}, [open]);
const submit = async () => {
const id = prefix + idInput.trim();
if (!idInput.trim()) {
setError(S.common.requiredField);
return;
}
// Non-admin: validate the suffix segment (the hyphen is a reserved separator, appearing only once at the prefix join); admin: validate the whole string.
const valid = prefix
? PROJECT_SUFFIX_PATTERN.test(idInput.trim()) && id.length <= PROJECT_ID_MAX_LENGTH
: SEMANTIC_ID_PATTERN.test(id);
if (!valid) {
setError(prefix ? S.project.idPrefixHint : S.project.idHint);
return;
}
setBusy(true);
setError(null);
try {
const res = await api.createProject({
projectId: id,
...(name.trim() ? { name: name.trim() } : {}),
});
onCreated(res.project.projectId);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
}
};
return (
<Modal
open={open}
title={S.project.createTitle}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>{S.common.cancel}</Button>
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
{S.common.create}
</Button>
</>
}
>
<div className="space-y-3">
{prefix ? (
<div>
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.project.id}
</span>
<div className="flex items-stretch">
<span className="flex shrink-0 items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-100 px-2 font-mono text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
{prefix}
</span>
<Input
size="sm"
className="rounded-l-none"
value={idInput}
onChange={(e) => setIdInput(e.target.value)}
autoFocus
/>
</div>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">
{S.project.idPrefixHint}
</span>
</div>
) : (
<Input
label={S.project.id}
size="sm"
value={idInput}
onChange={(e) => setIdInput(e.target.value)}
hint={S.project.idHint}
autoFocus
/>
)}
<Input
label={S.project.name}
size="sm"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</Modal>
);
}
/** Project settings dialog: member management (owner) and deletion (owner); members see a read-only member list. */
export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { user } = useAuth();
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
const [members, setMembers] = useState<MemberInfo[] | null>(null);
const [newMemberId, setNewMemberId] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const projectId = currentProject?.projectId;
const isOwner = currentProject?.role === "owner";
useEffect(() => {
if (!open || !projectId) return;
setMembers(null);
setError(null);
setConfirmDelete(false);
api
.listMembers(projectId)
.then((res) => setMembers(res.members))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
}, [open, projectId]);
if (!currentProject || !projectId) return null;
const addMember = async () => {
if (!newMemberId.trim()) return;
setError(null);
try {
await api.addMember(projectId, { userId: newMemberId.trim() });
setNewMemberId("");
const res = await api.listMembers(projectId);
setMembers(res.members);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
};
const doRemove = async (memberId: string) => {
setError(null);
try {
await api.removeMember(projectId, memberId);
const res = await api.listMembers(projectId);
setMembers(res.members);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
};
const doDelete = async () => {
setError(null);
try {
await api.deleteProject(projectId);
onClose();
const next = projects.find((p) => p.projectId !== projectId);
await reloadProjects();
if (next) setCurrentProjectId(next.projectId);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
};
return (
<Modal open={open} title={S.project.settingsTitle} onClose={onClose}>
<div className="space-y-4">
<div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.project.switcher}</p>
<p className="text-sm">
{projectDisplayName(currentProject)}{" "}
<span className="font-mono text-xs text-gray-400">{projectId}</span>
</p>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
{members === null ? (
<p className="text-xs text-gray-400">{S.common.loading}</p>
) : (
// Member permission table: username / role / actions; cells never wrap.
// Last row (owner only) = add member: small username input + add button (new members are always the member role).
<div className="overflow-x-auto rounded-md border border-gray-200 dark:border-gray-800">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.project.memberUsername}
</th>
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.project.memberRole}
</th>
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
{S.project.memberActions}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
{members.map((m) => (
<tr key={m.userId}>
<td className="whitespace-nowrap px-2.5 py-1.5">{m.userId}</td>
<td className="whitespace-nowrap px-2.5 py-1.5">
<Badge tone="gray">{m.role}</Badge>
</td>
<td className="whitespace-nowrap px-2.5 py-1 text-right">
{isOwner && m.role !== "owner" && m.userId !== user?.userId && (
<Button size="sm" variant="ghost" onClick={() => void doRemove(m.userId)}>
{S.project.removeMember}
</Button>
)}
</td>
</tr>
))}
{isOwner && (
<tr>
<td className="px-2.5 py-1.5">
<Input
placeholder={S.project.memberUsername}
size="sm"
value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addMember();
}}
/>
</td>
<td className="whitespace-nowrap px-2.5 py-1.5">
<Badge tone="gray">member</Badge>
</td>
<td className="whitespace-nowrap px-2.5 py-1 text-right">
<Button
size="sm"
disabled={!newMemberId.trim()}
onClick={() => void addMember()}
>
{S.project.addMember}
</Button>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{isOwner && (
<div className="border-t border-gray-100 pt-3 dark:border-gray-800">
{projectId === "default_project" ? (
<p className="text-xs text-gray-400">{S.project.deleteDefaultForbidden}</p>
) : projects.length <= 1 ? (
// Last accessible Project: deleting it would leave the account with no Project to select
// (the page would get stuck on the skeleton screen), so the frontend hides the entry point outright, matching the server's 409 rejection.
<p className="text-xs text-gray-400">{S.project.deleteLastForbidden}</p>
) : confirmDelete ? (
<div className="space-y-2">
<p className="text-xs text-red-600 dark:text-red-400">{S.project.deleteConfirm}</p>
<div className="flex gap-2">
<Button size="sm" onClick={() => setConfirmDelete(false)}>
{S.common.cancel}
</Button>
<Button size="sm" variant="danger" onClick={() => void doDelete()}>
{S.common.confirm}
</Button>
</div>
</div>
) : (
<Button size="sm" variant="danger" onClick={() => setConfirmDelete(true)}>
{S.project.deleteProject}
</Button>
)}
</div>
)}
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</Modal>
);
}
@@ -0,0 +1,818 @@
/**
* Single-column sidebar, top to bottom:
* Project switcher -> new chat (default_agent draft) + fixed nav (Agents / models / cost center /
* Trace) -> Session area grouped by Agent (group header = Agent name + new chat + Agent settings;
* shows all Agents, including empty groups) -> bottom user config (theme / language / logout).
* Desktop keeps it pinned as the left column; mobile puts the whole thing in a drawer.
* New chats always enter draft state (/chat/new, route state specifies the Agent): Model /
* Workspace / approval mode are all chosen on the draft input card, so there's no longer a
* separate "quick / advanced" pair of new-chat dialogs.
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
*/
import { useState } from "react";
import type { ReactNode } from "react";
import { NavLink, useMatch, useNavigate } from "react-router";
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useAuth } from "../../state/auth";
import { useLocale } from "../../state/locale";
import type { LangPref } from "../../state/locale";
import { ACCENT_SWATCHES, useTheme } from "../../state/theme";
import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme";
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { Dropdown } from "../ui/dropdown";
import { AgentAvatar } from "../ui/agent-avatar";
import { Chevron } from "../ui/chevron";
import { Truncated } from "../ui/truncated";
import { Badge } from "../ui/badge";
import { Modal } from "../ui/modal";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Segmented } from "../ui/segmented";
import { SkeletonList } from "../ui/skeleton";
import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
import { ChangePasswordDialog } from "../account/change-password-dialog";
function Icon({ d, size = 16 }: { d: string; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** Dropdown caret (used by the Project switcher; distinct from the collapse-indicator Chevron). */
function DropdownCaret() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" aria-hidden>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
const NAV_ICONS = {
agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
/** Skill library (an open book: two pages + spine). */
skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
models: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3",
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
traces: "M4 6h16M4 12h10M4 18h13",
/** Benchmark center (a trophy: cup + two handles + base). */
benchmark:
"M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8",
} as const;
/** Standard gear (lucide settings): full tooth outline + center circle, crisp and undistorted at 16px. */
const GEAR_ICON =
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z";
const menuItemClass =
"block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800";
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
function StatusDot({ session }: { session: SessionInfo }) {
if (session.status === "running") {
return (
<span
title={S.chat.statusRunning}
className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-emerald-500"
/>
);
}
if (session.status === "compacting") {
return (
<span
title={S.chat.statusCompacting}
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
/>
);
}
return null;
}
export function Sidebar({
onNavigate,
onCollapse,
}: {
onNavigate?: () => void;
onCollapse?: () => void;
}) {
const navigate = useNavigate();
const { user, logout } = useAuth();
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
useTheme();
const { lang, setLang } = useLocale();
const {
projects,
currentProject,
setCurrentProjectId,
reloadProjects,
agents,
setCurrentAgentId,
} = useProject();
const { byAgent, loading, remove, replace } = useSessions();
const chatMatch = useMatch("/chat/:sessionId");
const activeSessionId = chatMatch?.params.sessionId ?? null;
const [projectOpen, setProjectOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false);
const [createProjectOpen, setCreateProjectOpen] = useState(false);
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
/** Collapsed Agent groups (expanded by default). */
const [collapsedAgents, setCollapsedAgents] = useState<ReadonlySet<string>>(new Set());
/** Expanded "archived" groups (collapsed by default). */
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
/** Session pending delete confirmation (null = none). */
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
const [deletingBusy, setDeletingBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
/** Session currently being renamed (null = none) and the title being typed. */
const [renamingSession, setRenamingSession] = useState<SessionInfo | null>(null);
const [renameText, setRenameText] = useState("");
const [renameBusy, setRenameBusy] = useState(false);
const [renameError, setRenameError] = useState<string | null>(null);
const toggleAgent = (agentId: string) =>
setCollapsedAgents((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
return next;
});
const toggleArchivedGroup = (agentId: string) =>
setOpenArchived((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
return next;
});
/** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
const toggleArchive = async (s: SessionInfo) => {
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
if (!s.archived && s.sessionId === activeSessionId) {
setOpenArchived((prev) => new Set(prev).add(s.agentId));
}
try {
const res = await api.patchSession(s.sessionId, { archived: !s.archived });
replace(res.session);
} catch {
/* Ignore: non-critical operation */
}
};
const confirmRename = async () => {
if (!renamingSession) return;
const title = renameText.trim();
if (!title) return;
setRenameBusy(true);
setRenameError(null);
try {
const res = await api.patchSession(renamingSession.sessionId, { title });
replace(res.session);
setRenamingSession(null);
} catch (e) {
setRenameError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setRenameBusy(false);
}
};
const confirmDeleteSession = async () => {
if (!deletingSession) return;
setDeletingBusy(true);
setDeleteError(null);
const target = deletingSession;
try {
await api.deleteSession(target.sessionId);
remove(target.sessionId);
// The session is gone, so clear its input draft too (no orphaned keys left in localStorage; keys are scoped per user, #68).
if (user) clearDraft(sessionDraftKey(user.userId, target.sessionId));
setDeletingSession(null);
// The deleted session was the one open: jump to another **unarchived** Session in the same
// group, otherwise fall back to the chat home page (never jump into an archived session —
// it's hidden by default, so landing there would look like the chat vanished into thin air).
if (activeSessionId === target.sessionId) {
const rest = (byAgent.get(target.agentId) ?? []).filter(
(s) => s.sessionId !== target.sessionId && !s.archived,
);
navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat");
}
} catch (e) {
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setDeletingBusy(false);
}
};
const go = (to: string) => {
navigate(to);
onNavigate?.();
};
/**
* New chat: enters draft state (/chat/new) without creating a Session — Model / Workspace /
* approval mode are all chosen on the draft input card, and the Session is only actually
* created when the first message is sent. The route state explicitly carries the target
* Agent: the group header's "+" uses that group's Agent, while the menu's "New chat" uses
* default_agent; this explicit intent overrides the previously selected Agent in the draft
* cache (the rest of the draft content, such as the message body, is preserved).
*/
const newChat = (agentId?: string) => {
if (agentId) setCurrentAgentId(agentId);
navigate(`/chat/${DRAFT_SESSION_ID}`, agentId ? { state: { agentId } } : undefined);
onNavigate?.();
};
/** Target of the menu's "New chat": default_agent, falling back to the first Agent (if the list isn't ready yet, resolution is deferred to the draft page). */
const defaultAgentId = (agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId;
const openSession = (s: SessionInfo) => {
// Cross-group click: the current Agent follows this Session's own Agent.
setCurrentAgentId(s.agentId);
go(`/chat/${s.sessionId}`);
};
const navItems: Array<{ to: string; label: string; icon: string }> = [
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
{ to: "/models", label: S.nav.models, icon: NAV_ICONS.models },
{ to: "/usage", label: S.nav.usage, icon: NAV_ICONS.usage },
{ to: "/traces", label: S.nav.traces, icon: NAV_ICONS.traces },
{ to: "/benchmark", label: S.nav.benchmark, icon: NAV_ICONS.benchmark },
];
const themeOptions: ReadonlyArray<{ value: ThemeMode; label: string }> = [
{ value: "light", label: S.settings.themeLight },
{ value: "dark", label: S.settings.themeDark },
{ value: "system", label: S.settings.followSystem },
];
const langOptions: ReadonlyArray<{ value: LangPref; label: string }> = [
{ value: "en", label: S.settings.langEn },
{ value: "zh", label: S.settings.langZh },
{ value: "system", label: S.settings.followSystem },
];
const fontOptions: ReadonlyArray<{ value: FontScale; label: string }> = [
{ value: "sm", label: S.settings.fontSmall },
{ value: "md", label: S.settings.fontMedium },
{ value: "lg", label: S.settings.fontLarge },
];
const currencyOptions: ReadonlyArray<{ value: Currency; label: string }> = [
{ value: "USD", label: S.models.currencyUsd },
{ value: "CNY", label: S.models.currencyCny },
];
return (
<div className="flex h-full w-full flex-col">
{/* Project switcher (+ collapse sidebar) */}
<div className="flex shrink-0 items-center gap-1 px-2 pt-2">
{onCollapse && (
<button
type="button"
title={S.nav.collapseSidebar}
aria-label={S.nav.collapseSidebar}
onClick={onCollapse}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M15 6l-6 6 6 6M4 4v16" size={18} />
</button>
)}
<Dropdown
open={projectOpen}
setOpen={setProjectOpen}
className="min-w-0 flex-1"
menuClass="left-0 right-0 top-full mt-1 origin-top"
button={
<button
type="button"
onClick={() => setProjectOpen(!projectOpen)}
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-base font-semibold transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
>
<span className="min-w-0 flex-1 truncate text-left">
{currentProject ? projectDisplayName(currentProject) : S.common.loading}
</span>
<span className="text-gray-400">
<DropdownCaret />
</span>
</button>
}
>
{projects.map((p) => (
<button
key={p.projectId}
type="button"
onClick={() => {
setCurrentProjectId(p.projectId);
setProjectOpen(false);
}}
className={`flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${
p.projectId === currentProject?.projectId ? "font-semibold" : ""
}`}
>
<span className="truncate">{projectDisplayName(p)}</span>
<Badge tone="gray">{p.role}</Badge>
</button>
))}
<div className="mt-1.5 border-t border-gray-100 pt-1.5 dark:border-gray-800">
<button
type="button"
className={menuItemClass}
onClick={() => {
setProjectOpen(false);
setCreateProjectOpen(true);
}}
>
+ {S.project.create}
</button>
{currentProject && (
<button
type="button"
className={menuItemClass}
onClick={() => {
setProjectOpen(false);
setProjectSettingsOpen(true);
}}
>
{S.project.settings}
</button>
)}
</div>
</Dropdown>
</div>
{/* Fixed nav (new chat pinned at top: default_agent draft): no background fill, shares the
same gray hover/active styling as nav items, distinguished only by its top position and
font-medium; shows the same gray active state while on the draft page. */}
<nav className="shrink-0 space-y-0.5 px-2 pt-2">
<button
type="button"
onClick={() => newChat(defaultAgentId)}
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors duration-150 ${
activeSessionId === DRAFT_SESSION_ID
? "bg-gray-200/70 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
}`}
>
<span className="text-gray-500 dark:text-gray-400">
<Icon d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</span>
{S.chat.newSessionMenu}
</button>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => onNavigate?.()}
className={({ isActive }) =>
`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors duration-150 ${
isActive
? "bg-gray-200/70 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
}`
}
>
<span className="text-gray-500 dark:text-gray-400">
<Icon d={item.icon} />
</span>
{item.label}
</NavLink>
))}
</nav>
{/* Session area grouped by Agent (scrollable) */}
<div className="mt-3 min-h-0 flex-1 overflow-y-auto border-t border-gray-200 px-2 pb-2 dark:border-gray-800">
{loading && agents.length === 0 ? (
<SkeletonList rows={5} />
) : (
agents.map((agent) => {
const list = byAgent.get(agent.agentId) ?? [];
const activeList = list.filter((s) => !s.archived);
const archivedList = list.filter((s) => s.archived);
const collapsed = collapsedAgents.has(agent.agentId);
const archivedOpen = openArchived.has(agent.agentId);
return (
<div key={agent.agentId} className="pt-2.5">
{/* Group header: collapse toggle (Agent name) + new chat + Agent settings */}
<div className="flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleAgent(agent.agentId)}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
<AgentAvatar id={agent.agentId} size={18} className="shrink-0 rounded" />
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{agentDisplayName(agent)}
</span>
{/* Expand/collapse indicator sits right after the Agent name */}
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</div>
{collapsed ? null : (
<>
{activeList.length === 0 && archivedList.length === 0 ? (
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
{S.chat.noSessions}
</p>
) : (
<ul className="space-y-0.5">
{activeList.map((s) => (
<SessionRow
key={s.sessionId}
s={s}
active={s.sessionId === activeSessionId}
onOpen={openSession}
onRename={(x) => {
setRenameError(null);
setRenameText(x.title ?? "");
setRenamingSession(x);
}}
onDelete={(x) => {
setDeleteError(null);
setDeletingSession(x);
}}
onToggleArchive={(x) => void toggleArchive(x)}
/>
))}
</ul>
)}
{/* Archived group (collapsed by default) */}
{archivedList.length > 0 && (
<div className="mt-1">
<button
type="button"
onClick={() => toggleArchivedGroup(agent.agentId)}
className="flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"
>
<Chevron open={archivedOpen} size={12} />
{S.chat.archivedGroup(archivedList.length)}
</button>
{archivedOpen && (
<ul className="space-y-0.5">
{archivedList.map((s) => (
<SessionRow
key={s.sessionId}
s={s}
active={s.sessionId === activeSessionId}
onOpen={openSession}
onRename={(x) => {
setRenameError(null);
setRenameText(x.title ?? "");
setRenamingSession(x);
}}
onDelete={(x) => {
setDeleteError(null);
setDeletingSession(x);
}}
onToggleArchive={(x) => void toggleArchive(x)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
);
})
)}
</div>
{/* Bottom user config */}
<div className="shrink-0 border-t border-gray-200 p-2 dark:border-gray-800">
<Dropdown
open={userOpen}
setOpen={setUserOpen}
menuClass="bottom-full left-0 right-0 mb-1 origin-bottom"
button={
<button
type="button"
onClick={() => setUserOpen(!userOpen)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{user?.userId}</span>
{user?.isAdmin && (
<span className="text-xs text-gray-400 dark:text-gray-500">{S.auth.admin}</span>
)}
</button>
}
>
<div className="space-y-2.5 px-3 py-2">
<SettingRow label={S.settings.theme}>
<Segmented options={themeOptions} value={mode} onChange={setMode} />
</SettingRow>
<SettingRow label={S.settings.fontSize}>
<Segmented options={fontOptions} value={fontScale} onChange={setFontScale} />
</SettingRow>
<SettingRow label={S.settings.accent}>
<AccentPicker value={accent} onChange={setAccent} />
</SettingRow>
<SettingRow label={S.models.currency}>
<Segmented
options={currencyOptions}
value={currency}
onChange={setCurrency}
cols={2}
/>
</SettingRow>
<SettingRow label={S.settings.language}>
<Segmented options={langOptions} value={lang} onChange={setLang} />
</SettingRow>
</div>
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
type="button"
className={menuItemClass}
onClick={() => {
setUserOpen(false);
setChangePasswordOpen(true);
}}
>
{S.account.changePassword}
</button>
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && (
<button
type="button"
className={menuItemClass}
onClick={() => {
setUserOpen(false);
go("/admin/users");
}}
>
{S.admin.users}
</button>
)}
<button
type="button"
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
onClick={() => {
setUserOpen(false);
void logout().then(() => navigate("/login"));
}}
>
{S.auth.logout}
</button>
</div>
</Dropdown>
</div>
<ChangePasswordDialog
open={changePasswordOpen}
onClose={() => setChangePasswordOpen(false)}
/>
<CreateProjectDialog
open={createProjectOpen}
onClose={() => setCreateProjectOpen(false)}
onCreated={(projectId) => {
setCreateProjectOpen(false);
void reloadProjects().then(() => setCurrentProjectId(projectId));
}}
/>
{currentProject && (
<ProjectSettingsDialog
open={projectSettingsOpen}
onClose={() => setProjectSettingsOpen(false)}
/>
)}
{/* Rename chat */}
<Modal
open={renamingSession !== null}
title={S.chat.renameSession}
onClose={() => (renameBusy ? undefined : setRenamingSession(null))}
footer={
<>
<Button onClick={() => setRenamingSession(null)} disabled={renameBusy}>
{S.common.cancel}
</Button>
<Button
variant="primary"
disabled={renameBusy || !renameText.trim()}
onClick={() => void confirmRename()}
>
{S.common.save}
</Button>
</>
}
>
<Input
label={S.chat.renameSessionLabel}
value={renameText}
autoFocus
maxLength={120}
onChange={(e) => setRenameText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && renameText.trim() && !renameBusy) void confirmRename();
}}
/>
{renameError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{renameError}</p>
)}
</Modal>
{/* Delete chat confirmation */}
<Modal
open={deletingSession !== null}
title={S.chat.deleteSession}
onClose={() => (deletingBusy ? undefined : setDeletingSession(null))}
footer={
<>
<Button onClick={() => setDeletingSession(null)} disabled={deletingBusy}>
{S.common.cancel}
</Button>
<Button
variant="danger"
disabled={deletingBusy}
onClick={() => void confirmDeleteSession()}
>
{S.common.delete}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{deletingSession
? S.chat.deleteSessionConfirm(deletingSession.title ?? S.chat.defaultSessionTitle)
: ""}
</p>
{deleteError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
)}
</Modal>
</div>
);
}
/** Single Session row: title + status dot/approval badge + hover action group (rename, archive/unarchive, delete). */
function SessionRow({
s,
active,
onOpen,
onRename,
onDelete,
onToggleArchive,
}: {
s: SessionInfo;
active: boolean;
onOpen: (s: SessionInfo) => void;
onRename: (s: SessionInfo) => void;
onDelete: (s: SessionInfo) => void;
onToggleArchive: (s: SessionInfo) => void;
}) {
const actionBtn =
"flex h-6 w-6 shrink-0 items-center justify-center rounded text-gray-400 opacity-0 transition-all duration-150 focus-visible:opacity-100 group-hover:opacity-100";
return (
<li>
<div
className={`group flex items-center rounded-md pr-1 transition-colors duration-150 ${
active
? "bg-gray-200/70 dark:bg-gray-800"
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/70"
}`}
>
<button
type="button"
onClick={() => onOpen(s)}
className="flex min-w-0 flex-1 items-center gap-1.5 px-2.5 py-1.5 text-left"
>
{/* Only attach a title attribute when the title is actually truncated (hover to see full text); don't duplicate the text otherwise. */}
<Truncated
text={s.title ?? S.chat.defaultSessionTitle}
className={`min-w-0 flex-1 text-sm ${
active
? "font-medium text-gray-900 dark:text-gray-100"
: s.archived
? "text-gray-400 dark:text-gray-500"
: "text-gray-700 dark:text-gray-300"
}`}
/>
{/* Source badge: schedule / sub-session (user-created sessions have no source and show nothing). */}
{s.source && <Badge tone="gray">{S.chat.sourceNames[s.source] ?? s.source}</Badge>}
<StatusDot session={s} />
{s.pendingApprovalCount > 0 && (
<span title={S.chat.pendingApprovals(s.pendingApprovalCount)}>
<Badge tone="amber">{s.pendingApprovalCount}</Badge>
</span>
)}
</button>
{/* Action group: rename + archive/unarchive + delete */}
<div className="flex shrink-0 items-center">
<button
type="button"
title={S.chat.renameSession}
aria-label={S.chat.renameSession}
onClick={() => onRename(s)}
className={`${actionBtn} hover:bg-gray-300/60 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200`}
>
<Icon d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3zM14 7l3 3" size={14} />
</button>
<button
type="button"
title={s.archived ? S.chat.unarchiveSession : S.chat.archiveSession}
aria-label={s.archived ? S.chat.unarchiveSession : S.chat.archiveSession}
onClick={() => onToggleArchive(s)}
className={`${actionBtn} hover:bg-gray-300/60 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200`}
>
<Icon
d={
s.archived
? "M3 8h18M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M4 8l1.5-3h13L20 8M12 17v-5m-2.5 2L12 11l2.5 3"
: "M3 8h18M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M4 8l1.5-3h13L20 8M9.5 13.5 12 16l2.5-2.5"
}
size={14}
/>
</button>
<button
type="button"
title={S.chat.deleteSession}
aria-label={S.chat.deleteSession}
onClick={() => onDelete(s)}
className={`${actionBtn} hover:bg-gray-300/60 hover:text-red-600 dark:hover:bg-gray-700 dark:hover:text-red-400`}
>
{/* Trash can: lid (4..20), handle, body (5..19, symmetric sides), two vertical ribs */}
<Icon
d="M4 6h16M9 6V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V6M6 6v13a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6M10 10.5v6M14 10.5v6"
size={14}
/>
</button>
</div>
</div>
</li>
);
}
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
{children}
</div>
);
}
/** Accent color picker: a row of swatches, with a ring on the selected one. */
function AccentPicker({ value, onChange }: { value: Accent; onChange: (a: Accent) => void }) {
return (
<div className="flex items-center gap-1.5">
{ACCENT_SWATCHES.map((s) => (
<button
key={s.value}
type="button"
title={S.settings.accentNames[s.value]}
aria-label={S.settings.accentNames[s.value]}
aria-pressed={value === s.value}
onClick={() => onChange(s.value)}
className={`h-5 w-5 rounded-full border transition-transform duration-150 hover:scale-110 ${
value === s.value
? "border-gray-500 ring-2 ring-gray-400/50 dark:border-gray-300"
: "border-transparent"
}`}
style={{ backgroundColor: s.color }}
/>
))}
</div>
);
}
@@ -0,0 +1,90 @@
/**
* Agent avatar: a pixel identicon deterministically generated from agentId (the same approach
* GitHub's default avatars use).
*
* 5x5 grid with left-right mirror symmetry (only the left 3 columns are randomized, the right 2
* columns mirror back), a single soft-hue foreground, and a very light background of the same
* color. No external dependencies — seeds mulberry32 with an FNV-1a hash.
*/
/** Grid side length (must be odd for left-right symmetry). */
const N = 5;
/** Number of columns that need randomizing (including the center column). */
const HALF = Math.ceil(N / 2);
function hashStr(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function mulberry32(a: number): () => number {
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Randomly samples points on the left half + center column, mirrored into a full 5x5 boolean grid. */
function buildGrid(rnd: () => number): boolean[][] {
const grid: boolean[][] = Array.from({ length: N }, () => Array.from({ length: N }, () => false));
for (let col = 0; col < HALF; col++) {
for (let row = 0; row < N; row++) {
// The center column has a slightly lower fill rate, to avoid a solid vertical line.
const on = rnd() < (col === HALF - 1 ? 0.4 : 0.55);
grid[row]![col] = on;
grid[row]![N - 1 - col] = on;
}
}
return grid;
}
export function AgentAvatar({
id,
size = 18,
className,
}: {
id: string;
size?: number;
className?: string;
}) {
const rnd = mulberry32(hashStr(id || "agent"));
const hue = Math.floor(rnd() * 360);
const grid = buildGrid(rnd);
// 24x24 viewport: 2px margin on each side, 4x4 cells.
const cell = 4;
const pad = 2;
const fg = `hsl(${hue} 52% 46%)`;
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={className}
aria-hidden
role="img"
>
<rect x="0" y="0" width="24" height="24" rx="5" fill={`hsl(${hue} 55% 50% / 0.14)`} />
{grid.map((cols, row) =>
cols.map((on, col) =>
on ? (
<rect
key={`${row}-${col}`}
x={pad + col * cell}
y={pad + row * cell}
width={cell}
height={cell}
fill={fg}
/>
) : null,
),
)}
</svg>
);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Badge component: a small pill-shaped label for status/type (stop_reason, running status, Trace event type, etc.).
*/
import type { ReactNode } from "react";
export type BadgeTone = "gray" | "brand" | "green" | "amber" | "red";
const toneClass: Record<BadgeTone, string> = {
gray: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300",
brand: "bg-gray-200/80 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200",
green: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
amber: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
red: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
};
export function Badge({ tone = "gray", children }: { tone?: BadgeTone; children: ReactNode }) {
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${toneClass[tone]}`}
>
{children}
</span>
);
}
/** stop_reason -> badge tone (completed usually shows no badge). */
export function stopReasonTone(stopReason: string): BadgeTone {
switch (stopReason) {
case "completed":
return "green";
case "aborted":
return "amber";
default:
return "red"; // failed / timeout / malformed
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Button component: GitHub-style simplicity — small border radius + 1px border + a single brand accent, only color transitions.
*/
import type { ButtonHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "icon";
const variantClass: Record<Variant, string> = {
// primary uses the theme accent variable (defaults to neutral gray/white, switching with light/dark; becomes that color once an accent is selected).
primary:
"bg-[var(--accent-bg)] text-[var(--accent-fg)] border border-[var(--accent-bg)] " +
"transition-opacity hover:opacity-90 disabled:opacity-50",
secondary:
"bg-white text-gray-800 border border-gray-300 hover:bg-gray-50 " +
"dark:bg-gray-900 dark:text-gray-200 dark:border-gray-700 dark:hover:bg-gray-800",
danger:
"bg-white text-red-600 border border-gray-300 hover:border-red-300 hover:bg-red-50 " +
"dark:bg-gray-900 dark:text-red-400 dark:border-gray-700 dark:hover:bg-red-950",
ghost:
"bg-transparent text-gray-600 border border-transparent hover:bg-gray-100 hover:text-gray-900 " +
"dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100",
};
const sizeClass: Record<Size, string> = {
sm: "px-2.5 py-1 text-xs rounded-md",
md: "px-3 py-1.5 text-sm rounded-md",
/** Square icon button (no text; callers must supply title / aria-label). */
icon: "p-1.5 rounded-md",
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export function Button({ variant = "secondary", size = "md", className, ...rest }: ButtonProps) {
return (
<button
type="button"
className={`inline-flex items-center justify-center gap-1 font-medium transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60 ${variantClass[variant]} ${sizeClass[size]} ${className ?? ""}`}
{...rest}
/>
);
}
@@ -0,0 +1,31 @@
/**
* Collapse/expand indicator icon: a `>` shaped chevron that rotates 90 degrees to point down when expanded.
* Shared by every collapsible element site-wide (thinking blocks, tool cards, sub-session cards,
* reasoning groups, sidebar groups, trace tree, trace groups) — no more solid triangle characters.
*/
export function Chevron({
open,
size = 14,
className = "",
}: {
open: boolean;
size?: number;
className?: string;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={`block shrink-0 transition-transform duration-200 ${open ? "rotate-90" : ""} ${className}`}
>
<path d="M9 5l7 7-7 7" />
</svg>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Drawer component (for mobile nav/panels): overlay fades in + panel slides in from the side; Esc or clicking the overlay closes it.
* `side` determines the docking direction; the panel width defaults to 80vw, capped by widthClass.
*/
import { useEffect } from "react";
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
export interface DrawerProps {
open: boolean;
side?: "left" | "right";
title?: string;
onClose: () => void;
children: ReactNode;
/** Panel max-width class (default max-w-xs). */
widthClass?: string;
}
export function Drawer({ open, side = "left", title, onClose, children, widthClass }: DrawerProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div className="anim-fade absolute inset-0 bg-black/45" onMouseDown={onClose} />
<div
className={`absolute inset-y-0 flex w-[80vw] flex-col bg-white shadow-xl dark:bg-gray-900 ${widthClass ?? "max-w-xs"} ${
side === "left"
? "anim-drawer-left left-0 border-r border-gray-200 dark:border-gray-800"
: "anim-drawer-right right-0 border-l border-gray-200 dark:border-gray-800"
}`}
>
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<span className="text-base font-semibold">{title ?? ""}</span>
<button
type="button"
aria-label={S.common.close}
onClick={onClose}
className="rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]">
{children}
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
/**
* Dropdown container (controlled): clicking outside collapses it; the panel is absolutely
* positioned (z-40, per the layering convention — chrome avoids stacking contexts, menus are
* z-40, overlays are z-50). menuClass controls the docking direction and width.
*/
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
export function Dropdown({
button,
open,
setOpen,
children,
menuClass,
className,
}: {
button: ReactNode;
open: boolean;
setOpen: (v: boolean) => void;
children: ReactNode;
/** Panel positioning and size (default: downward, left-aligned, w-64). */
menuClass?: string;
/** Extra classes for the root container (e.g. flex-1 in a flex layout). */
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("mousedown", onClick);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onClick);
window.removeEventListener("keydown", onKey);
};
}, [open, setOpen]);
return (
<div className={`relative ${className ?? ""}`} ref={ref}>
{button}
{open && (
<div
className={`anim-pop absolute z-40 max-h-[70vh] overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900 ${
menuClass ?? "left-0 top-full mt-1 w-64 max-w-[calc(100vw-2rem)] origin-top-left"
}`}
>
{children}
</div>
)}
</div>
);
}
@@ -0,0 +1,22 @@
/**
* Empty state component: a placeholder message for when a list/detail view has no data (plain text, no graphic decoration).
*/
import type { ReactNode } from "react";
export function EmptyState({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<p className="text-sm font-medium text-gray-600 dark:text-gray-300">{title}</p>
{description && <p className="text-xs text-gray-500 dark:text-gray-500">{description}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
@@ -0,0 +1,31 @@
/**
* Unified rendering for stat icons (shared by the chat page's stat row and the Trace page's turn
* cards): a 24x24 line path with stroke set to currentColor, so the color follows the caller's
* text color. See lib/stat-icons.ts for the paths.
*/
export function GlyphIcon({
d,
size = 13,
className = "",
}: {
d: string;
size?: number;
className?: string;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className={`block shrink-0 ${className}`}
aria-hidden
>
<path d={d} />
</svg>
);
}
@@ -0,0 +1,72 @@
/**
* Clickable image that zooms in on click. The thumbnail keeps the caller's styling;
* clicking it opens a lightbox: a bordered image panel with a close glyph in the
* top-right corner (no title bar), closable via Esc or clicking the overlay.
* The lightbox is rendered via portal to body — the thumbnail may be nested inside
* a card with a transform entrance animation or overflow-hidden, and a `fixed`
* layer rendered in place would get hijacked/clipped by that ancestor (same fix
* as the Select dropdown).
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { S } from "../../lib/strings";
export function ZoomableImage({
src,
alt,
className,
}: {
src: string;
alt: string;
/** Style for the thumbnail img (keeps the caller's original class). */
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" className="block cursor-zoom-in" onClick={() => setOpen(true)}>
<img src={src} alt={alt} className={className} />
</button>
{open && <Lightbox src={src} alt={alt} onClose={() => setOpen(false)} />}
</>
);
}
function Lightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
className="anim-fade fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-10"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="anim-pop relative">
{/* Close glyph: top-right inside the frame, floating over the image (dark semi-transparent background keeps it visible on any image). */}
<button
type="button"
aria-label={S.common.close}
onClick={onClose}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white transition-colors duration-150 hover:bg-black/70"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
<img
src={src}
alt={alt}
className="max-h-[85vh] max-w-[88vw] rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"
/>
</div>
</div>,
document.body,
);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Text input component: optional label and hint/error text; rounded corners with
* a hover border darken and brand focus-ring transition.
*/
import { forwardRef } from "react";
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
// Excludes font size and padding: each of Input/Textarea appends its own (see their size).
const baseClass =
"w-full rounded-md border border-gray-300 bg-white text-gray-900 " +
"placeholder:text-gray-400 transition-[border-color,box-shadow] duration-200 " +
"hover:border-gray-400 focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"disabled:cursor-not-allowed disabled:opacity-60 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 " +
"dark:hover:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
/** Size tier: base (form default) / sm (compact contexts like filter bars, keeps the toolbar from growing taller). */
export type ControlSize = "base" | "sm";
export const sizeClass: Record<ControlSize, string> = {
base: "px-3 py-2 text-base",
sm: "px-2 py-1 text-xs",
};
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string;
hint?: string;
error?: string;
/**
* Marks the field red without rendering error text: use this when the input has a
* custom wrapper (prefix glyph, unit suffix, etc.) and the caller places the error
* text outside that wrapper — otherwise the text would get pulled into the
* absolutely-positioned reference frame and skew the prefix/suffix layout.
*/
invalid?: boolean;
size?: ControlSize;
}
/**
* Error state: red border + light red background (a failed field is visible at a
* glance; the error text sits below the box).
* Forced with `!` — baseClass's border-gray-300 / bg-white are the same kind of
* border/background utility classes, and which one wins depends on the order the
* CSS was generated in, not the order of classes in the string (without `!important`
* this would get overridden).
*/
const errorClass =
"!border-red-400 !bg-red-50 hover:!border-red-500 focus:!border-red-500 focus:!ring-red-400/30 " +
"dark:!border-red-800 dark:!bg-red-950/40 dark:hover:!border-red-700 dark:focus:!border-red-600";
export function Input({
label,
hint,
error,
invalid,
size = "base",
className,
...rest
}: InputProps) {
const bad = Boolean(error) || Boolean(invalid);
const control = (
<input
className={`${baseClass} ${sizeClass[size]} ${bad ? errorClass : ""} ${className ?? ""}`}
aria-invalid={bad ? true : undefined}
{...rest}
/>
);
if (!label && !hint && !error) return control;
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
{control}
{error ? (
<span className="mt-1 block text-xs text-red-600 dark:text-red-400">{error}</span>
) : hint ? (
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>
) : null}
</label>
);
}
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
hint?: string;
/** Monospace font (for editing Prompts/parameters). */
mono?: boolean;
/** Font size: base (default, matches body text) or sm (smaller, for editing long Prompts). */
size?: "base" | "sm";
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ label, hint, mono, size = "base", className, ...rest },
ref,
) {
const control = (
<textarea
ref={ref}
className={`${baseClass} px-3 py-2 ${size === "sm" ? "text-xs leading-relaxed" : "text-base"} ${mono ? "font-mono" : ""} ${className ?? ""}`}
{...rest}
/>
);
if (!label && !hint) return control;
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
{control}
{hint && <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>}
</label>
);
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Modal dialog component: overlay fades in, panel rises into place; closes on
* Esc or clicking the overlay. Docked to the bottom on narrow screens (bottom
* sheet style), centered card at >=sm.
*
* **Rendered via portal to body**: the panel has its own transform entrance
* animation (anim-pop), which makes it a containing block for descendant `fixed`
* elements — if a nested modal (e.g. a delete confirmation inside a settings
* modal) rendered in place, its overlay would be confined to the parent panel's
* rectangle, leaving a misaligned edge (a white sliver showing through). After
* portaling, every modal is a sibling child of body and stacks naturally in DOM
* order.
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
export interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
/** Panel width class (defaults to sm:max-w-md). */
widthClass?: string;
}
/**
* Stack of currently open Modals: Escape only closes the **topmost** one. When
* dialogs are nested (e.g. a confirmation popped inside a settings modal), each
* Modal registers its own window keydown->onClose; without checking the top of
* the stack, a single Escape would close both layers at once and discard unsaved
* edits in the outer modal. Pushed in mount order, so the top of the stack is the
* visually topmost dialog.
*/
const modalStack: symbol[] = [];
export function Modal({ open, title, onClose, children, footer, widthClass }: ModalProps) {
useEffect(() => {
if (!open) return;
const id = Symbol("modal");
modalStack.push(id);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
const i = modalStack.lastIndexOf(id);
if (i !== -1) modalStack.splice(i, 1);
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="anim-fade fixed inset-0 z-50 flex items-end justify-center bg-black/45 p-0 sm:items-center sm:p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={`anim-pop w-full ${widthClass ?? "sm:max-w-md"} rounded-t-lg border border-gray-200 bg-white pb-[env(safe-area-inset-bottom)] shadow-xl sm:rounded-lg sm:pb-0 dark:border-gray-800 dark:bg-gray-900`}
>
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<h2 className="text-base font-semibold">{title}</h2>
<button
type="button"
aria-label={S.common.close}
onClick={onClose}
className="rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-4 py-4">{children}</div>
{footer && (
<div className="flex justify-end gap-2 border-t border-gray-200 px-4 py-3 dark:border-gray-800">
{footer}
</div>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,192 @@
/**
* Option menu (controlled): the trigger button shows a compact current value, and
* clicking it expands a panel where each row has a title + description text + a
* selected-state checkmark. Replaces select controls that show only an abbreviation
* and rely on the native `title` hover tooltip for details (tooltips are poorly
* discoverable — users have no idea they should hover before they've clicked once).
*
* The trigger button shares the sizeClass size tier with Input/Select, and the panel
* row styling matches the Select menu (py-1.5, bold selected row + SVG checkmark),
* keeping visuals consistent when mixed with existing form controls.
*
* The panel is mounted via createPortal to document.body and positioned with
* `position: fixed` against viewport coordinates — it does not reuse the Dropdown
* primitive's in-place absolute positioning. Reason: a Dropdown panel is a DOM
* descendant of its trigger button, so if an ancestor in the chain has a container
* like overflow-x-auto (e.g. this component used inside a tool table), the CSS spec
* says that when one axis has non-visible overflow and the other is visible, the
* visible axis gets forced to `auto` — making that ancestor clip vertically
* overflowing descendants, and absolute positioning is not exempt. A portaled node
* is outside that ancestor's DOM subtree, so it is fundamentally unaffected by its
* overflow, without having to audit every call site's ancestor chain.
* Positioning and close behavior live in use-portal-panel.ts.
*
* The panel uses z-[60] (above the modal overlay's z-50): a portaled node sits in
* the root stacking context, and this component may also be used inside a Modal
* form, where z-40 would get covered by the overlay.
*/
import { useState } from "react";
import { createPortal } from "react-dom";
import { sizeClass } from "./input";
import type { ControlSize } from "./input";
import { usePortalPanel } from "./use-portal-panel";
export interface OptionMenuChoice<T extends string> {
value: T;
/** Compact text on the trigger button (e.g. "rw"). */
triggerLabel: string;
/** Panel row title (e.g. "Read & write"). */
label: string;
/** Panel row description text explaining when this option actually takes effect. */
description: string;
}
const PANEL_WIDTH = 288; // w-72
export function OptionMenu<T extends string>({
options,
value,
onChange,
placeholder,
mono,
label,
fullWidth,
size = "base",
"aria-label": ariaLabel,
}: {
options: ReadonlyArray<OptionMenuChoice<T>>;
/** null/undefined means unset/default: the trigger shows the placeholder and no panel row is selected. */
value: T | null | undefined;
onChange: (value: T) => void;
/** Placeholder text on the trigger when value is empty. */
placeholder?: string;
/** Use a monospace font for the trigger text. */
mono?: boolean;
/** Field title above the control (same typography as Input); omit to render a bare trigger button (e.g. for table cell usage). */
label?: string;
/** Stretch the trigger button to fill the container width, as a replacement for native Select in dense form areas. */
fullWidth?: boolean;
/** Same size tier as Input/Select (sizeClass), for pixel-perfect alignment when mixed together. */
size?: ControlSize;
"aria-label"?: string;
}) {
const [open, setOpen] = useState(false);
const { triggerRef, panelRef, position } = usePortalPanel({
open,
onClose: () => setOpen(false),
// Row height is roughly 48px (title + description two lines + py-1.5) + panel top/bottom padding.
estimatedHeight: options.length * 48 + 16,
panelWidth: PANEL_WIDTH,
});
const current = value != null ? options.find((o) => o.value === value) : undefined;
const control = (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={
"flex items-center gap-2 rounded-md border border-gray-300 bg-white text-gray-900 " +
"transition-[border-color,box-shadow] duration-200 hover:border-gray-400 " +
"focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-gray-600 " +
"dark:focus:border-gray-400 dark:focus:ring-gray-500/30 " +
sizeClass[size] +
(fullWidth ? " w-full justify-between" : "")
}
>
<span className={`min-w-0 truncate ${mono ? "font-mono" : ""}`}>
{current?.triggerLabel ?? placeholder ?? "—"}
</span>
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
aria-hidden
className="size-3 shrink-0 text-gray-400"
>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open &&
position &&
createPortal(
<div
ref={panelRef}
role="listbox"
style={{
position: "fixed",
top: position.topPx,
bottom: position.bottomPx,
left: position.left,
minWidth: position.triggerWidth,
}}
className="anim-pop z-[60] max-h-[70vh] w-72 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="option"
aria-selected={opt.value === value}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`block w-full px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${
opt.value === value ? "bg-gray-100 dark:bg-gray-800" : ""
}`}
>
<span className="flex items-center justify-between gap-2">
<span
className={`text-sm ${
opt.value === value
? "font-medium text-gray-900 dark:text-gray-100"
: "text-gray-700 dark:text-gray-300"
}`}
>
{opt.label}
</span>
{opt.value === value && (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-500 dark:text-gray-400"
aria-hidden
>
<path
d="M5 12l4 4L19 6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
<span className="mt-0.5 block text-xs text-gray-500 dark:text-gray-400">
{opt.description}
</span>
</button>
))}
</div>,
document.body,
)}
</>
);
if (!label) return control;
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
{control}
</label>
);
}
@@ -0,0 +1,76 @@
/**
* Password input: a password variant of Input with an embedded show/hide toggle
* (eye icon button) on the right. The label/hint/error wrapper structure mirrors
* Input — the toggle button must be positioned relative to the **input element
* itself**; if label/hint were handed to the inner Input as well, the button would
* get pulled into the positioning reference frame along with the hint text and
* shift out of place. So error is only passed down as `invalid` (red border), and
* the error text is rendered below the box by this component.
*/
import { useState } from "react";
import { Input, type InputProps } from "./input";
import { S } from "../../lib/strings";
/** Eye (click to show) and a crossed-out closed eye (click to hide). */
const EYE = "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z";
const EYE_OFF =
"M3 3l18 18M10.6 5.1A11 11 0 0 1 12 5c6.4 0 10 7 10 7a17.6 17.6 0 0 1-3.2 3.9M6.6 6.6C4 8.3 2 12 2 12s3.6 7 10 7a10 10 0 0 0 4.8-1.2M9.9 9.9a3 3 0 0 0 4.2 4.2";
export function PasswordInput({
label,
hint,
error,
invalid,
size = "base",
className,
...rest
}: Omit<InputProps, "type">) {
const [visible, setVisible] = useState(false);
const toggleLabel = visible ? S.auth.hidePassword : S.auth.showPassword;
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
<div className="relative">
<Input
{...rest}
size={size}
type={visible ? "text" : "password"}
invalid={Boolean(error) || Boolean(invalid)}
className={`${size === "sm" ? "pr-8" : "pr-10"} ${className ?? ""}`}
/>
<button
type="button"
aria-label={toggleLabel}
title={toggleLabel}
onClick={() => setVisible((v) => !v)}
className={`absolute inset-y-0 right-0 flex items-center justify-center text-gray-400 transition-colors duration-150 hover:text-gray-600 dark:hover:text-gray-300 ${
size === "sm" ? "w-8" : "w-10"
}`}
>
<svg
width={size === "sm" ? 14 : 16}
height={size === "sm" ? 14 : 16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={visible ? EYE_OFF : EYE} />
</svg>
</button>
</div>
{error ? (
<span className="mt-1 block text-xs text-red-600 dark:text-red-400">{error}</span>
) : hint ? (
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>
) : null}
</label>
);
}
@@ -0,0 +1,19 @@
/**
* Brand logo (colored penguin emblem): source asset is public/penguin-logo.svg
* (an important asset, do not modify — the same file also serves as the favicon).
* The original image is a square on a white background, with className controlling
* size and rounded-corner cropping — it blends into the page background in light
* theme, and reads as an app-icon-style white rounded square in dark theme. Purely
* decorative, hidden from screen readers.
*/
export function PenguinLogo({ className }: { className?: string }) {
return (
<img
src="/penguin-logo.svg"
alt=""
aria-hidden
draggable={false}
className={`select-none ${className ?? "h-9 w-9 rounded-lg"}`}
/>
);
}
@@ -0,0 +1,93 @@
/**
* Provider logo icons (24x24 viewBox, colored via currentColor, legible in both
* light and dark themes).
*
* Anthropic / OpenAI / Google Gemini / DeepSeek / Moonshot AI / OpenRouter /
* SiliconFlow use each vendor's brand mark (for recognition purposes, not under
* trademark license); Z.AI uses a simplified geometric approximation of its
* branded glyph (not an exact reproduction of the trademark); unknown vendors and
* custom models use a generic cube. All are pure paths, no external image assets.
*/
import type { ReactNode } from "react";
interface Glyph {
/** Solid marks (brand mark) use fill; line art uses stroke. */
stroke?: boolean;
/** Custom viewBox (when the brand mark's original aspect ratio isn't square); defaults to 24x24. */
viewBox?: string;
path: ReactNode;
}
const GLYPHS: Record<string, Glyph> = {
anthropic: {
path: (
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
),
},
openai: {
path: (
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
),
},
google: {
path: (
<path d="M12 24A14.304 14.304 0 0 0 0 12 14.304 14.304 0 0 0 12 0a14.305 14.305 0 0 0 12 12 14.305 14.305 0 0 0-12 12" />
),
},
deepseek: {
path: (
<path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.328.14a5.526 5.526 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 0 1-.465.137 9.597 9.597 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.034-.237.23-.256a4.173 4.173 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.615zm1-6.44a.306.306 0 0 1 .415-.287.302.302 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 0 1-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.191-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.196-.02.354-.25.452z" />
),
},
zhipu: {
path: (
<path d="M4.6 3.2h14.8a.9.9 0 0 1 .73 1.43L9.9 18.6h9.2a.9.9 0 0 1 0 1.8H4.6a.9.9 0 0 1-.73-1.43L14.1 5.4H4.6a1.1 1.1 0 0 1 0-2.2Z" />
),
},
moonshot: {
path: (
<path d="m1.053 16.91 9.538 2.55a21 20.981 0 0 0 .06 2.031l5.956 1.592a12 11.99 0 0 1-15.554-6.172m-1.02-5.79 11.352 3.035a21 20.981 0 0 0-.469 2.01l10.817 2.89a12 11.99 0 0 1-1.845 2.004L.658 15.918a12 11.99 0 0 1-.625-4.796m1.593-5.146L13.573 9.17a21 20.981 0 0 0-1.01 1.874l11.297 3.02a21 20.981 0 0 1-.67 2.362l-11.55-3.087L.125 10.26a12 11.99 0 0 1 1.499-4.285ZM6.067 1.58l11.285 3.016a21 20.981 0 0 0-1.688 1.719l7.824 2.091a21 20.981 0 0 1 .513 2.664L2.107 5.218a12 11.99 0 0 1 3.96-3.638M21.68 4.866 7.222 1.003A12 11.99 0 0 1 21.68 4.866" />
),
},
openrouter: {
path: (
<path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z" />
),
},
siliconflow: {
viewBox: "0 0 53.021 25.354",
path: (
<path d="M50.7172 0H27.6622C26.3877 0 25.3586 1.03397 25.3586 2.30358V9.21911C25.3586 10.4935 24.3294 11.5227 23.055 11.5227H2.30357C1.02914 11.5227 0 12.5567 0 13.8263V23.0502C0 24.3246 1.03395 25.3538 2.30357 25.3538H25.3586C26.633 25.3538 27.6622 24.3198 27.6622 23.0502V16.1347C27.6622 14.8602 28.6913 13.8311 29.9657 13.8311H50.7172C51.9916 13.8311 53.0207 12.7971 53.0207 11.5275V2.30358C53.0207 1.02916 51.9868 0 50.7172 0Z" />
),
},
custom: {
stroke: true,
path: (
<>
<path d="M12 2.8 20.5 7.4v9.2L12 21.2 3.5 16.6V7.4L12 2.8Z" />
<path d="M3.5 7.4 12 12l8.5-4.6M12 12v9.2" />
</>
),
},
};
export function ProviderLogo({ provider, className }: { provider: string; className?: string }) {
const glyph = GLYPHS[provider] ?? GLYPHS.custom!;
return (
<svg
viewBox={glyph.viewBox ?? "0 0 24 24"}
className={className}
aria-hidden
{...(glyph.stroke
? {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinejoin: "round" as const,
}
: { fill: "currentColor" })}
>
{glyph.path}
</svg>
);
}
@@ -0,0 +1,33 @@
/** Segmented control (for 2-3-way choices like theme/language): small grayscale style. Shared by the sidebar user menu and the login page. */
export function Segmented<T extends string>({
options,
value,
onChange,
cols = 3,
}: {
options: ReadonlyArray<{ value: T; label: string }>;
value: T;
onChange: (v: T) => void;
cols?: 2 | 3;
}) {
return (
<div
className={`grid ${cols === 2 ? "grid-cols-2" : "grid-cols-3"} gap-0.5 rounded-md bg-gray-100 p-0.5 dark:bg-gray-800`}
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`rounded px-1 py-1 text-xs transition-colors duration-150 ${
value === opt.value
? "bg-white font-medium text-gray-900 shadow-sm dark:bg-gray-600 dark:text-gray-100"
: "text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Dropdown select component: **custom-drawn** (not the native browser select),
* keeping the same API — parses `<option>` children and follows the
* `value` / `onChange(e.target.value)` convention. The menu is rendered via
* portal to body (fixed positioning, computed from the button's rect), so it
* is never clipped by a Modal or scroll container; it closes on outside click,
* Esc, or scroll. Styling matches Input.
*/
import { Children, isValidElement, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ChangeEvent, ReactNode, SelectHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { sizeClass } from "./input";
import type { ControlSize } from "./input";
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
label?: string;
hint?: string;
/** Same size tier as Input: sm is for filter bars, keeps the toolbar from growing taller. */
size?: ControlSize;
}
interface Opt {
value: string;
label: ReactNode;
disabled?: boolean;
}
/** Parses the option list out of `<option>` children. */
function parseOptions(children: ReactNode): Opt[] {
const out: Opt[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child) || child.type !== "option") return;
const p = child.props as { value?: string | number; children?: ReactNode; disabled?: boolean };
out.push({
value: p.value !== undefined ? String(p.value) : "",
label: p.children ?? "",
...(p.disabled ? { disabled: true } : {}),
});
});
return out;
}
const CONTROL_CLASS =
"flex w-full items-center gap-2 rounded-md border border-gray-300 bg-white text-left text-gray-900 " +
"transition-[border-color,box-shadow] duration-200 hover:border-gray-400 " +
"focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"disabled:cursor-not-allowed disabled:opacity-60 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-gray-600 " +
"dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
export function Select({
label,
hint,
size = "base",
className,
children,
value,
onChange,
disabled,
}: SelectProps) {
const options = parseOptions(children);
const current = String(value ?? "");
const selected = options.find((o) => o.value === current);
const btnRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number; width: number; up: boolean } | null>(
null,
);
const place = () => {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const below = window.innerHeight - r.bottom;
const up = below < 240 && r.top > below; // Not enough room below and more room above -> open upward
setPos({ left: r.left, top: up ? r.top : r.bottom, width: r.width, up });
};
useLayoutEffect(() => {
if (open) place();
}, [open]);
useEffect(() => {
if (!open) return;
const onDocDown = (e: MouseEvent) => {
const t = e.target as Node;
if (btnRef.current?.contains(t)) return;
if ((t as HTMLElement).closest?.("[data-select-menu]")) return;
setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
// Page scroll closes the menu (a fixed-positioned menu doesn't scroll with the
// page, so leaving it in place would misalign it). But **the menu's own internal
// scroll** (when there are more options than max-h-60) doesn't count: a
// capture-phase listener would catch that too, and closing on it would mean the
// menu shuts the moment the user scrolls, so they can never reach options below.
const onScroll = (e: Event) => {
const t = e.target;
if (t instanceof Element && t.closest("[data-select-menu]")) return;
setOpen(false);
};
const onResize = () => setOpen(false);
document.addEventListener("mousedown", onDocDown);
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
document.removeEventListener("mousedown", onDocDown);
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
const pick = (v: string) => {
setOpen(false);
// Synthesize a minimal event object, following the caller's onChange(e.target.value) convention.
onChange?.({ target: { value: v } } as unknown as ChangeEvent<HTMLSelectElement>);
btnRef.current?.focus();
};
const control = (
<>
<button
ref={btnRef}
type="button"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={`${CONTROL_CLASS} ${sizeClass[size]} ${className ?? ""}`}
>
<span className="min-w-0 flex-1 truncate">
{selected?.label ?? options[0]?.label ?? ""}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-400"
aria-hidden
>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open &&
pos &&
createPortal(
<ul
data-select-menu
role="listbox"
className="anim-pop fixed z-[60] max-h-60 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
style={{
left: pos.left,
width: pos.width,
...(pos.up ? { bottom: window.innerHeight - pos.top + 4 } : { top: pos.top + 4 }),
}}
>
{options.map((o, i) => (
<li key={`${o.value}-${i}`}>
<button
type="button"
role="option"
aria-selected={o.value === current}
disabled={o.disabled}
onClick={() => pick(o.value)}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors duration-150 disabled:opacity-50 ${
o.value === current
? "bg-gray-100 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.value === current && (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-500 dark:text-gray-400"
aria-hidden
>
<path
d="M5 12l4 4L19 6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
</li>
))}
</ul>,
document.body,
)}
</>
);
if (!label && !hint) return control;
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
{control}
{hint && <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>}
</label>
);
}
+323
View File
@@ -0,0 +1,323 @@
/**
* Bottom sheet (used by the mobile Files panel): a top-to-bottom layout with two
* snap points, half (browse) / full (preview).
*
* Interaction spec (following Apple's Designing Fluid Interfaces):
* - The header (grab handle + title row) is the drag surface, tracking Pointer
* Events 1:1 while respecting the grab offset; the content area only scrolls
* and never drags (v1 intentionally skips scroll/drag disambiguation).
* - On release, project the velocity to pick the nearest snap point (a fast
* downward flick naturally projects to close); velocity hands off to a spring,
* so drag and animation feel seamless; dragging up past full applies
* progressive rubberband damping.
* - Mid-animation, grabbing again takes over from the current rendered value
* with no jump; programmatic moves use critical damping with no overshoot,
* while a gesture release allows slight springy overshoot.
* - The overlay's opacity is driven by the same translateY progress (no separate
* animation); prefers-reduced-motion degrades to a fade plus a direct snap
* into position.
* - Body scroll is locked while open; Esc or clicking the overlay closes it;
* focus returns to the element that had it before opening.
*
* Coordinate system: the panel sits at bottom-0 with height 92dvh (FULL_FRACTION
* and the class must be changed together), translateY y is in [0, panel height],
* 0 = full open, panel height = fully off-screen; half's y is derived from the
* 60dvh visible height.
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { S } from "../../lib/strings";
import { SPRING_DEFAULT, SPRING_MOMENTUM, createSpringDriver } from "../../lib/spring";
import type { SpringDriver } from "../../lib/spring";
import { nearestSnap, project, rubberband } from "../../lib/sheet-physics";
export type SheetSnap = "half" | "full";
/** Fraction of viewport height the full-state panel occupies — same fact as the panel class `h-[92dvh]`, change one and you must change the other. */
const FULL_FRACTION = 0.92;
/** Fraction of viewport height that's visible in the half state. */
const HALF_VISIBLE_FRACTION = 0.6;
export interface SheetProps {
open: boolean;
/** Target snap point; a change while open animates to it (e.g. tapping into preview from list state promotes it to full). */
snap: SheetSnap;
/** Reported back after a gesture lands on a new snap point, to keep parent state in sync. */
onSnapChange?: (snap: SheetSnap) => void;
onClose: () => void;
title?: string;
children: ReactNode;
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
export function Sheet({ open, snap, onSnapChange, onClose, title, children }: SheetProps) {
// Don't unmount immediately when open=false: unmount only after the exit animation finishes (onSettle).
const [mounted, setMounted] = useState(open);
const panelRef = useRef<HTMLDivElement | null>(null);
const scrimRef = useRef<HTMLDivElement | null>(null);
const driverRef = useRef<SpringDriver | null>(null);
/** Actual panel height (= full-state travel distance); re-measured on resize/keyboard popup. */
const heightRef = useRef(0);
const draggingRef = useRef(false);
const closingRef = useRef(false);
/** Target of the most recent spring command: prevents the prop change that flows
* back from onSnapChange after a gesture release from restarting the spring as
* a "programmatic" move, which would kill the momentum's springiness. */
const lastTargetRef = useRef<number | null>(null);
/** Drag velocity samples (keeps only the most recent ~100ms window). */
const historyRef = useRef<{ y: number; t: number }[]>([]);
const grabRef = useRef({ pointerY: 0, sheetY: 0 });
const openRef = useRef(open);
openRef.current = open;
const restoreFocusRef = useRef<HTMLElement | null>(null);
const reduced = usePrefersReducedMotion();
/** The same progress value drives both panel translation and overlay opacity (writes the DOM directly, bypassing React rendering). */
const applyFrame = useCallback((y: number) => {
const h = heightRef.current || 1;
if (panelRef.current) panelRef.current.style.transform = `translate3d(0, ${y}px, 0)`;
if (scrimRef.current) {
scrimRef.current.style.opacity = String(Math.min(1, Math.max(0, 1 - y / h)));
}
}, []);
const snapYFor = useCallback((s: SheetSnap) => {
const h = heightRef.current;
return s === "full" ? 0 : h * (1 - HALF_VISIBLE_FRACTION / FULL_FRACTION);
}, []);
useEffect(() => {
if (open) setMounted(true);
}, [open]);
// Measure height on mount and place the panel fully off-screen (the orchestration effect below then animates it into view).
useLayoutEffect(() => {
if (!mounted) return;
const panel = panelRef.current;
if (!panel) return;
heightRef.current = panel.offsetHeight;
if (!driverRef.current) {
driverRef.current = createSpringDriver(heightRef.current, applyFrame);
driverRef.current.set(heightRef.current);
}
}, [mounted, applyFrame]);
// Open/close/snap-point orchestration.
useEffect(() => {
if (!mounted) return;
const driver = driverRef.current;
if (!driver) return;
if (open) {
closingRef.current = false;
if (draggingRef.current) return; // While dragging, the gesture is in control — don't let a prop change interfere
const target = snapYFor(snap);
if (lastTargetRef.current !== null && Math.abs(lastTargetRef.current - target) < 1) return;
lastTargetRef.current = target;
if (reduced) {
driver.set(target);
return;
}
driver.animateTo(target, SPRING_DEFAULT);
} else {
closingRef.current = true;
lastTargetRef.current = heightRef.current;
if (reduced) {
setMounted(false);
return;
}
driver.animateTo(heightRef.current, SPRING_DEFAULT, { onSettle: () => setMounted(false) });
}
}, [mounted, open, snap, reduced, snapYFor]);
// Unmount cleanup: when mounted becomes false, dispose the driver (rebuilt and
// re-measured on next open) and reset gesture state — pointerup may never fire
// (interrupted by a system gesture, tab switch, or lost capture), and without
// this reset a leaked draggingRef would make the orchestration effect refuse to
// animate the entrance next time it opens.
useEffect(() => {
if (mounted) return;
driverRef.current?.dispose();
driverRef.current = null;
lastTargetRef.current = null;
draggingRef.current = false;
closingRef.current = false;
}, [mounted]);
useEffect(() => () => driverRef.current?.dispose(), []);
// Lock body scroll while open (on iOS, the page behind the overlay would otherwise keep scrolling).
useEffect(() => {
if (!mounted) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [mounted]);
useEffect(() => {
if (!mounted || !open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [mounted, open, onClose]);
// Focus restoration: return to the element that had focus before opening, after closing.
useEffect(() => {
if (!mounted) return;
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
return () => restoreFocusRef.current?.focus();
}, [mounted]);
// Viewport change (rotation/soft keyboard): re-measure height and snap directly to the current snap point, no animation.
useEffect(() => {
if (!mounted) return;
const onResize = () => {
const panel = panelRef.current;
if (!panel) return;
heightRef.current = panel.offsetHeight;
if (draggingRef.current || closingRef.current) return;
const target = snapYFor(snap);
lastTargetRef.current = target;
driverRef.current?.set(target);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [mounted, snap, snapYFor]);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (closingRef.current) return;
const driver = driverRef.current;
if (!driver) return;
e.currentTarget.setPointerCapture(e.pointerId);
const [y] = driver.stop(); // Take over: continue from the rendered value, a mid-flight panel stops the instant it's grabbed
draggingRef.current = true;
grabRef.current = { pointerY: e.clientY, sheetY: y };
historyRef.current = [{ y: e.clientY, t: performance.now() }];
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
const driver = driverRef.current;
if (!driver) return;
const raw = grabRef.current.sheetY + (e.clientY - grabRef.current.pointerY);
const h = heightRef.current;
// Above full (raw<0) applies progressive damping; below fully-off-screen is clamped.
const y = raw < 0 ? -rubberband(-raw, h) : Math.min(raw, h);
driver.set(y);
const now = performance.now();
const hist = historyRef.current;
hist.push({ y: e.clientY, t: now });
while (hist.length > 2 && now - hist[0]!.t > 100) hist.shift();
};
/** Shared by release/cancel: pick a snap point via projection, hand velocity off to the spring. */
const release = (velocity: number) => {
draggingRef.current = false;
const driver = driverRef.current;
if (!driver) return;
const h = heightRef.current;
const halfY = snapYFor("half");
// Parent already requested a close (e.g. a session switch during the drag): go straight to the exit animation.
const projected = openRef.current ? driver.value + project(velocity) : h;
const target = nearestSnap(projected, [0, halfY, h]);
lastTargetRef.current = target;
if (target === h) {
closingRef.current = true;
if (reduced) {
setMounted(false);
onClose();
return;
}
driver.animateTo(h, SPRING_MOMENTUM, {
velocity,
onSettle: () => {
setMounted(false);
onClose();
},
});
return;
}
driver.animateTo(target, SPRING_MOMENTUM, { velocity });
const nextSnap: SheetSnap = target === 0 ? "full" : "half";
if (nextSnap !== snap) onSnapChange?.(nextSnap);
};
const onPointerUp = () => {
if (!draggingRef.current) return;
const hist = historyRef.current;
const last = hist[hist.length - 1];
const first = hist[0];
const dt = last && first ? last.t - first.t : 0;
release(dt > 0 ? ((last!.y - first!.y) / dt) * 1000 : 0);
};
const onPointerCancel = () => {
if (!draggingRef.current) return;
release(0);
};
if (!mounted) return null;
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={title}>
<div
ref={scrimRef}
className={`absolute inset-0 bg-black/45 ${reduced ? "anim-fade" : ""}`}
style={{ opacity: 0 }}
onPointerDown={() => onClose()}
/>
<div
ref={panelRef}
className={`absolute inset-x-0 bottom-0 flex h-[92dvh] flex-col overflow-hidden rounded-t-2xl border-t border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900 ${
reduced ? "anim-fade" : ""
}`}
style={{ transform: "translate3d(0, 100%, 0)" }}
>
{/* Drag surface: grab handle + title row. touch-none ensures pointermove isn't interrupted by scrolling during a touch drag. */}
<div
className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
// Fallback for when capture is lost with no pointercancel (system preemption, etc.): settle at zero velocity.
// Also fires after a normal pointerup — the draggingRef guard in onPointerCancel prevents double handling.
onLostPointerCapture={onPointerCancel}
>
<div className="mx-auto mt-2 h-1 w-9 rounded-full bg-gray-300 dark:bg-gray-600" />
<div className="flex items-center justify-between px-4 pb-2 pt-1.5">
<span className="text-base font-semibold">{title ?? ""}</span>
<button
type="button"
aria-label={S.common.close}
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
className="rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]">
{children}
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
/**
* Skeleton screen component (list and detail views use
* skeleton screens while loading).
* Placeholder blocks share the same border/rounding/background as the real
* container, giving zero layout shift from "loading" to "loaded" (matching the
* LangSmith-style feel of pre-loading placeholders shaped like the real layout).
*/
import type { ReactNode } from "react";
export function Skeleton({ className }: { className?: string }) {
return (
<div
className={`animate-pulse rounded bg-gray-200 dark:bg-gray-800 ${className ?? "h-4 w-full"}`}
/>
);
}
/** Multi-row skeleton (placeholder for list loading). */
export function SkeletonList({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-2 p-2">
{Array.from({ length: rows }, (_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
);
}
/**
* Card skeleton: same border/rounding as a real card; defaults to a three-part
* placeholder (title + main value + subline), and children can override the
* inner layout. className fully replaces the default p-4 (when Tailwind classes
* for the same property coexist, which one wins depends on stylesheet order, so
* appending an override is unreliable) — when passing className, include padding
* as well.
*/
export function SkeletonCard({
className,
children,
}: {
className?: string;
children?: ReactNode;
}) {
return (
<div
className={`rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900 ${className ?? "p-4"}`}
>
{children ?? (
<>
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-2.5 h-6 w-28" />
<Skeleton className="mt-2.5 h-3 w-24" />
</>
)}
</div>
);
}
@@ -0,0 +1,65 @@
/**
* Run state icon (shared by the chat page's "reasoning & tools" group and tool
* cards): a spinning ring while running, a static glyph otherwise — "still
* running" vs. "done" can be told apart at a glance, no need to read the text.
*/
export type RunState = "running" | "waiting" | "done" | "failed";
const GLYPH: Record<Exclude<RunState, "running">, string> = {
// Hourglass (waiting for approval)
waiting: "M6 3h12M6 21h12M8 3v3.5L12 10l4-3.5V3M8 21v-3.5L12 14l4 3.5V21",
// Checkmark (completed)
done: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm-3.5-9.2 2.4 2.5 4.6-4.8",
// X (failed / interrupted)
failed: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9 9l6 6m0-6-6 6",
};
const TONE: Record<RunState, string> = {
running: "text-emerald-500 dark:text-emerald-400",
waiting: "text-amber-500 dark:text-amber-400",
done: "text-gray-400 dark:text-gray-500",
failed: "text-red-500 dark:text-red-400",
};
export function StatusIcon({
state,
size = 13,
label,
}: {
state: RunState;
size?: number;
/** The icon carries no text of its own; this supplies a status description (visible on hover). */
label?: string;
}) {
if (state === "running") {
return (
<span
{...(label
? { title: label, "aria-label": label, role: "status" }
: { "aria-hidden": true })}
style={{ width: size, height: size }}
className={`inline-block shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent ${TONE.running}`}
/>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
// Without a label, this icon is decorative (the status is already conveyed by
// adjacent text): it must not have role="img" without an accessible name, or
// a screen reader would announce it as an anonymous image.
{...(label ? { role: "img" as const, "aria-label": label } : { "aria-hidden": true })}
className={`block shrink-0 ${TONE[state]}`}
>
{label && <title>{label}</title>}
<path d={GLYPH[state]} />
</svg>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Tab switcher component (controlled): GitHub-style underline; scrolls
* horizontally on narrow screens.
*/
export interface TabItem<K extends string = string> {
key: K;
label: string;
}
export function Tabs<K extends string>({
items,
active,
onChange,
}: {
items: ReadonlyArray<TabItem<K>>;
active: K;
onChange: (key: K) => void;
}) {
return (
<div
role="tablist"
// overflow-y-hidden: only allow horizontal scroll on narrow screens, otherwise some browsers reserve a vertical scrollbar gutter for overflow-x-auto.
className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden border-b border-gray-200 dark:border-gray-800"
>
{items.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={item.key === active}
onClick={() => onChange(item.key)}
className={`-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors duration-150 ${
item.key === active
? "border-gray-900 font-semibold text-gray-900 dark:border-gray-100 dark:text-gray-100"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-800 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:text-gray-200"
}`}
>
{item.label}
</button>
))}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Top overlay toast: results like "saved successfully" or "connection failed"
* all pop up at the top of the page and disappear automatically after a few
* seconds.
*
* Rendered via portal to body with a z-index above Modal/drawer (z-50), so a
* toast triggered inside a dialog is still visible; the container doesn't
* intercept mouse events (pointer-events-none) — only the toast itself is
* interactive (clicking it dismisses immediately).
*
* Usage: call `toastSuccess("Saved")` / `toastError("Connection failed: ...")`
* from anywhere, no context needed — a module-level subscriber list plus a
* single `<Toaster />` mounted at the app root is all it takes.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
type ToastKind = "success" | "error";
interface ToastItem {
id: number;
kind: ToastKind;
text: string;
/** Leaving: plays the exit animation first, removed from the list once it finishes. */
leaving?: boolean;
}
/** Display duration: error messages are usually longer and more important to read, so give them more time. */
const DURATION: Record<ToastKind, number> = { success: 2500, error: 6000 };
let items: ToastItem[] = [];
let nextId = 1;
const listeners = new Set<(items: ToastItem[]) => void>();
function emit(): void {
for (const l of listeners) l(items);
}
/** Exit animation duration (matches toast-out in styles.css). */
const LEAVE_MS = 160;
function dismiss(id: number): void {
const item = items.find((i) => i.id === id);
if (!item || item.leaving) return;
// Mark as leaving first (triggering the exit animation), then actually remove it once the animation ends — otherwise the toast would just vanish abruptly.
items = items.map((i) => (i.id === id ? { ...i, leaving: true } : i));
emit();
setTimeout(() => {
items = items.filter((i) => i.id !== id);
emit();
}, LEAVE_MS);
}
function push(kind: ToastKind, text: string): void {
if (!text) return;
const id = nextId++;
items = [...items, { id, kind, text }];
emit();
setTimeout(() => dismiss(id), DURATION[kind]);
}
export const toastSuccess = (text: string): void => push("success", text);
export const toastError = (text: string): void => push("error", text);
const KIND_CLASS: Record<ToastKind, string> = {
success:
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
error:
"border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200",
};
/** Toast container: mount once at the app root. */
export function Toaster() {
const [list, setList] = useState<ToastItem[]>(items);
useEffect(() => {
listeners.add(setList);
return () => {
listeners.delete(setList);
};
}, []);
if (list.length === 0) return null;
return createPortal(
<div className="pointer-events-none fixed inset-x-0 top-3 z-[100] flex flex-col items-center gap-2 px-4">
{list.map((t) => (
<button
key={t.id}
type="button"
onClick={() => dismiss(t.id)}
className={`pointer-events-auto max-w-lg break-words rounded-md border px-3 py-2 text-left text-sm shadow-lg ${t.leaving ? "anim-toast-out" : "anim-toast-in"} ${KIND_CLASS[t.kind]}`}
>
{t.text}
</button>
))}
</div>,
document.body,
);
}
@@ -0,0 +1,112 @@
/**
* Segmented context-usage donut ring: used in the top-right corner of each round
* (Task) card on the Trace page — shows both **usage ratio** (sum of arc lengths
* / limit) and **segment composition** (three segments). Draws, clockwise from
* the top (12 o'clock), the cacheRead, cacheWrite, and output segments in order,
* with the remainder left as an empty ring; the limit `max` = the model's context
* window (defaults to 128000 via resolveContextWindow when the caller doesn't
* supply one). If all three buckets are 0, only the base ring is drawn; when
* usage exceeds the limit, the ring is filled proportionally to usage and
* colored by threshold (>80% amber / >95% red) to signal approaching/exceeding
* the limit; exact values are given via a `<title>` hover.
* Segments use the site-wide TOKEN_COLORS (works in both light/dark); the base
* ring uses a currentColor gray.
* The context usage under the chat page's input box is a **single-color,
* single-value** ring (total only), custom-drawn in chat-input — it doesn't use
* this component.
*/
import { TOKEN_COLORS } from "../../lib/token-colors";
import { humanizeTokens } from "../../lib/format";
import { S } from "../../lib/strings";
export function TokenDonut({
cacheRead,
cacheWrite,
output,
max,
size = 44,
}: {
cacheRead: number;
cacheWrite: number;
output: number;
/** Limit (context window): when usage doesn't exceed it, values are normalized against this, leaving an empty ring. */
max: number;
/** Outer diameter in pixels. */
size?: number;
}) {
const total = cacheRead + cacheWrite + output;
const strokeWidth = Math.max(2, Math.round(size * 0.16));
const center = size / 2;
const r = (size - strokeWidth) / 2;
const c = 2 * Math.PI * r;
// Normalization denominator: when usage doesn't exceed the limit, use the limit
// (leaves an empty ring); when it exceeds, use usage itself so the ring fills
// completely (arcs never wrap past a full circle).
const denom = Math.max(max, total, 1);
const pct = max > 0 ? total / max : 0;
// The base ring's (i.e. "empty ring / remainder") color shifts to amber / red as usage approaches the limit, as a warning.
const ringTone =
pct > 0.95 ? "text-red-500" : pct > 0.8 ? "text-amber-500" : "text-gray-300 dark:text-gray-700";
const segs = [
{
key: "cacheRead",
value: cacheRead,
color: TOKEN_COLORS.cacheRead,
label: S.usage.colCacheRead,
},
{
key: "cacheWrite",
value: cacheWrite,
color: TOKEN_COLORS.cacheWrite,
label: S.usage.colCacheWrite,
},
{ key: "output", value: output, color: TOKEN_COLORS.output, label: S.usage.colOutput },
];
const title =
`${S.chat.contextUsage} ${humanizeTokens(total)}/${humanizeTokens(max)}` +
segs.map((s) => ` · ${s.label} ${humanizeTokens(s.value)}`).join("");
let acc = 0;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={`block shrink-0 ${ringTone}`}
role="img"
aria-label={title}
>
<title>{title}</title>
{/* Base ring (empty ring / remainder) */}
<circle
cx={center}
cy={center}
r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.35}
strokeWidth={strokeWidth}
/>
{/* Three arc segments: clockwise, starting at 12 o'clock (rotate -90), positioned via cumulative dashoffset per segment. */}
{segs.map((seg) => {
if (seg.value <= 0) return null;
const len = (seg.value / denom) * c;
const offset = -(acc / denom) * c;
acc += seg.value;
return (
<circle
key={seg.key}
cx={center}
cy={center}
r={r}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeDasharray={`${len} ${c}`}
strokeDashoffset={offset}
transform={`rotate(-90 ${center} ${center})`}
/>
);
})}
</svg>
);
}
@@ -0,0 +1,40 @@
/**
* Single-line truncated text: attaches `title` (hover to see the full text)
* **only when actually truncated**.
*
* The site-wide rule is "don't duplicate `title` when the element already shows
* the text", but text whose tail is cut off by `truncate` isn't fully shown —
* this is the one exception. Whether it overflows must be measured
* (`scrollWidth > clientWidth`), and re-measured whenever the container's size
* changes (sidebar collapse / window resize).
*/
import { useLayoutEffect, useRef, useState } from "react";
export function Truncated({ text, className = "" }: { text: string; className?: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
// className is also a dependency: when the caller switches to font-medium in the
// selected state, the font weight changes and content width (scrollWidth) changes
// with it, but this element is a flex child whose width is constrained by its
// parent, so clientWidth doesn't move -> ResizeObserver won't fire. Without
// re-measuring, this would under-report (should have `title` but doesn't) or
// over-report (should remove `title` but keeps it).
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// Subpixel rounding can make even monospace text off by 1px; allow a 1px tolerance to avoid a spurious title.
const measure = () => setOverflowing(el.scrollWidth > el.clientWidth + 1);
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [text, className]);
return (
<span ref={ref} className={`truncate ${className}`} {...(overflowing ? { title: text } : {})}>
{text}
</span>
);
}
@@ -0,0 +1,103 @@
/**
* Positioning and close behavior for a portaled popup panel (used by OptionMenu,
* shared for future popup controls): the panel is mounted via createPortal to
* document.body and positioned with `position: fixed` against viewport
* coordinates, so it isn't clipped by any ancestor's overflow (Modal content
* area overflow-y-auto, table overflow-x-auto, etc.).
*
* Positioned once on expand: opens upward if there isn't enough room below and
* there's more room above, and the left edge is clamped within the viewport.
* Closes on: outside click / Esc / any scroll / window resize. Esc uses capture
* and stops propagation — Modal also listens for Esc during the window bubble
* phase and registers earlier, so without stopping propagation it would close
* both the panel and the dialog together; scroll likewise uses capture (scroll
* doesn't bubble), and the panel collapses directly the moment its position
* would go stale from scrolling, rather than leaving it floating out of place.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
export interface PortalPanelPosition {
topPx?: number;
bottomPx?: number;
left: number;
/** Trigger button width (px): the panel uses this as either min-width or a fixed width, as needed. */
triggerWidth: number;
}
const PANEL_GAP = 4;
const VIEWPORT_MARGIN = 16;
export function usePortalPanel({
open,
onClose,
estimatedHeight,
panelWidth,
}: {
open: boolean;
onClose: () => void;
/** Estimated panel height (px), used only to decide whether to open upward or downward. */
estimatedHeight: number;
/** Fixed panel width (px), used for left-edge clamping; if omitted, the panel width follows the trigger button. */
panelWidth?: number;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<PortalPanelPosition | null>(null);
// Store onClose in a ref: callers mostly pass inline arrow functions, and putting it directly in the dependency array would re-attach the listener on every render.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const height = Math.min(estimatedHeight, window.innerHeight * 0.7);
const openUpward = spaceBelow < height && spaceAbove > spaceBelow;
const width = panelWidth ?? rect.width;
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(rect.left, window.innerWidth - width - VIEWPORT_MARGIN),
);
setPosition({
topPx: openUpward ? undefined : rect.bottom + PANEL_GAP,
bottomPx: openUpward ? window.innerHeight - rect.top + PANEL_GAP : undefined,
left,
triggerWidth: rect.width,
});
}, [open, estimatedHeight, panelWidth]);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
onCloseRef.current();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onCloseRef.current();
}
};
// The panel's own internal scroll (when the list exceeds max-h) must not trigger a close: only scrolling of an outer container invalidates the position.
const onScroll = (e: Event) => {
if (panelRef.current?.contains(e.target as Node)) return;
onCloseRef.current();
};
const onResize = () => onCloseRef.current();
window.addEventListener("mousedown", onPointerDown);
window.addEventListener("keydown", onKey, true);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("mousedown", onPointerDown);
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
return { triggerRef, panelRef, position };
}