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