refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { Button } from "../ui/button";
|
||||
import { PasswordInput } from "../ui/password-input";
|
||||
@@ -17,34 +18,42 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<{ old?: string; new?: string; confirm?: string }>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const clearErrors = () => setErrors((p) => (p.old || p.new || p.confirm ? {} : p));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setError(null);
|
||||
setErrors({});
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(S.account.passwordMismatch);
|
||||
const next: { old?: string; new?: string; confirm?: string } = {};
|
||||
if (!oldPassword) next.old = S.common.requiredField;
|
||||
if (!newPassword) next.new = S.common.requiredField;
|
||||
if (!confirmPassword) next.confirm = S.common.requiredField;
|
||||
if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch;
|
||||
if (next.old || next.new || next.confirm) {
|
||||
setErrors(next);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setErrors({});
|
||||
try {
|
||||
await api.changePassword({ oldPassword, newPassword });
|
||||
await refresh();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
// Route by error code: invalid_password is about the NEW password's strength;
|
||||
// password_mismatch (and anything unrecognized) is about the current one.
|
||||
if (e instanceof ApiError && e.code === "invalid_password") {
|
||||
setErrors({ new: apiErrorText(e) });
|
||||
} else {
|
||||
setErrors({ old: apiErrorText(e) });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -69,29 +78,43 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
|
||||
<div className="space-y-3">
|
||||
<PasswordInput
|
||||
label={S.account.oldPassword}
|
||||
required
|
||||
size="sm"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setOldPassword(e.target.value);
|
||||
clearErrors();
|
||||
}}
|
||||
error={errors.old}
|
||||
autoComplete="current-password"
|
||||
hint={S.account.oldPasswordHint}
|
||||
autoFocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={S.account.newPassword}
|
||||
required
|
||||
size="sm"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
clearErrors();
|
||||
}}
|
||||
error={errors.new}
|
||||
autoComplete="new-password"
|
||||
hint={S.auth.passwordHint}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={S.account.confirmPassword}
|
||||
required
|
||||
size="sm"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
clearErrors();
|
||||
}}
|
||||
error={errors.confirm}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useMatch, useNavigate } from "react-router";
|
||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||
import { S } from "../../lib/strings";
|
||||
import { latestConversation } from "../../lib/session-grouping";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
@@ -43,16 +43,8 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
|
||||
/** On some conversation (any non-draft /chat/:id): the "you are here" state of the last-conversation entry. */
|
||||
const onConversation = activeSessionId !== null && activeSessionId !== DRAFT_SESSION_ID;
|
||||
|
||||
/** Newest non-archived Session across the current Project by createdAt (the flat list is only ordered per Agent). */
|
||||
const lastSession = useMemo(
|
||||
() =>
|
||||
sessions.reduce<SessionInfo | null>(
|
||||
(best, s) =>
|
||||
!s.archived && (!best || Date.parse(s.createdAt) > Date.parse(best.createdAt)) ? s : best,
|
||||
null,
|
||||
),
|
||||
[sessions],
|
||||
);
|
||||
/** Newest loaded conversation across the current Project (active/schedule only — archived and subagent rows are never auto-opened; the flat list is only ordered per Agent). */
|
||||
const lastSession = useMemo(() => latestConversation(sessions), [sessions]);
|
||||
|
||||
/** Mirrors Sidebar.openSession: the current Agent follows the opened Session's Agent. */
|
||||
const openLastSession = () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
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 { apiErrorText } from "../../lib/api-error";
|
||||
import {
|
||||
PROJECT_ID_MAX_LENGTH,
|
||||
PROJECT_SUFFIX_PATTERN,
|
||||
@@ -16,7 +16,10 @@ import { projectDisplayName, useProject } from "../../state/project";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { FieldError, FieldHint, FieldLabel } from "../ui/field";
|
||||
import { toastError } from "../ui/toast";
|
||||
import { Modal } from "../ui/modal";
|
||||
import { ConfirmModal } from "../ui/confirm-modal";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
export function CreateProjectDialog({
|
||||
@@ -33,7 +36,8 @@ export function CreateProjectDialog({
|
||||
const prefix = user && !user.isAdmin ? `${user.userId}-` : "";
|
||||
const [idInput, setIdInput] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The id is the only validated field; format problems and the server's rejection (e.g. duplicate id) both land beside it.
|
||||
const [idError, setIdError] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// No draft is kept: the form starts empty every time it opens.
|
||||
@@ -41,13 +45,13 @@ export function CreateProjectDialog({
|
||||
if (!open) return;
|
||||
setIdInput("");
|
||||
setName("");
|
||||
setError(null);
|
||||
setIdError(undefined);
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const id = prefix + idInput.trim();
|
||||
if (!idInput.trim()) {
|
||||
setError(S.common.requiredField);
|
||||
setIdError(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.
|
||||
@@ -55,11 +59,11 @@ export function CreateProjectDialog({
|
||||
? 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);
|
||||
setIdError(prefix ? S.project.idPrefixHint : S.project.idHint);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setIdError(undefined);
|
||||
try {
|
||||
const res = await api.createProject({
|
||||
projectId: id,
|
||||
@@ -67,7 +71,7 @@ export function CreateProjectDialog({
|
||||
});
|
||||
onCreated(res.project.projectId);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setIdError(apiErrorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -90,9 +94,7 @@ export function CreateProjectDialog({
|
||||
<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>
|
||||
<FieldLabel required>{S.project.id}</FieldLabel>
|
||||
<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}
|
||||
@@ -101,20 +103,31 @@ export function CreateProjectDialog({
|
||||
size="sm"
|
||||
className="rounded-l-none"
|
||||
value={idInput}
|
||||
onChange={(e) => setIdInput(e.target.value)}
|
||||
invalid={Boolean(idError)}
|
||||
onChange={(e) => {
|
||||
setIdInput(e.target.value);
|
||||
setIdError(undefined);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">
|
||||
{S.project.idPrefixHint}
|
||||
</span>
|
||||
{idError ? (
|
||||
<FieldError>{idError}</FieldError>
|
||||
) : (
|
||||
<FieldHint>{S.project.idPrefixHint}</FieldHint>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
label={S.project.id}
|
||||
required
|
||||
size="sm"
|
||||
value={idInput}
|
||||
onChange={(e) => setIdInput(e.target.value)}
|
||||
error={idError}
|
||||
onChange={(e) => {
|
||||
setIdInput(e.target.value);
|
||||
setIdError(undefined);
|
||||
}}
|
||||
hint={S.project.idHint}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -125,7 +138,6 @@ export function CreateProjectDialog({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -137,7 +149,8 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
|
||||
const [members, setMembers] = useState<MemberInfo[] | null>(null);
|
||||
const [newMemberId, setNewMemberId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Only the initial member-list load shows inline (in place of the table); action failures pop a toast.
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const projectId = currentProject?.projectId;
|
||||
@@ -146,42 +159,39 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
setMembers(null);
|
||||
setError(null);
|
||||
setLoadError(null);
|
||||
setConfirmDelete(false);
|
||||
api
|
||||
.listMembers(projectId)
|
||||
.then((res) => setMembers(res.members))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
.catch((e: unknown) => setLoadError(apiErrorText(e)));
|
||||
}, [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);
|
||||
toastError(apiErrorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
toastError(apiErrorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteProject(projectId);
|
||||
onClose();
|
||||
@@ -189,7 +199,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
await reloadProjects();
|
||||
if (next) setCurrentProjectId(next.projectId);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
toastError(apiErrorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,7 +216,9 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
|
||||
{members === null ? (
|
||||
{loadError ? (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
|
||||
) : members === null ? (
|
||||
<p className="text-xs text-gray-400">{S.common.loading}</p>
|
||||
) : (
|
||||
// Member permission table: username / role / actions; cells never wrap.
|
||||
@@ -216,13 +228,11 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
<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}
|
||||
{S.common.username}
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">{S.common.role}</th>
|
||||
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
|
||||
{S.project.memberActions}
|
||||
{S.common.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -246,7 +256,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
<tr>
|
||||
<td className="px-2.5 py-1.5">
|
||||
<Input
|
||||
placeholder={S.project.memberUsername}
|
||||
placeholder={S.common.username}
|
||||
size="sm"
|
||||
value={newMemberId}
|
||||
onChange={(e) => setNewMemberId(e.target.value)}
|
||||
@@ -283,18 +293,6 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
// 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}
|
||||
@@ -302,9 +300,17 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation (shared ConfirmModal, stacked above the settings dialog). */}
|
||||
<ConfirmModal
|
||||
open={confirmDelete}
|
||||
title={S.project.deleteProject}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={() => void doDelete()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{S.project.deleteConfirm}</p>
|
||||
</ConfirmModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink, useMatch, useNavigate } from "react-router";
|
||||
import type { SessionInfo, SessionSource } from "@prismshadow/penguin-server/api";
|
||||
import type {
|
||||
SessionCategory,
|
||||
SessionCategoryCounts,
|
||||
SessionInfo,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import type { LangPref } from "../../state/locale";
|
||||
@@ -30,20 +34,25 @@ import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme";
|
||||
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import {
|
||||
FOLDER_CATEGORIES,
|
||||
SIDEBAR_PAGE_SIZE,
|
||||
groupAgentsWithMore,
|
||||
aggregateWorkspaceCounts,
|
||||
groupSessionsByWorkspace,
|
||||
partitionSessions,
|
||||
pinnedFirst,
|
||||
sessionCategory,
|
||||
workspaceGroupKey,
|
||||
} from "../../lib/session-grouping";
|
||||
import type { SessionPartition } from "../../lib/session-grouping";
|
||||
import type { FolderCategory, SessionPartition } from "../../lib/session-grouping";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { AgentAvatar } from "../ui/agent-avatar";
|
||||
import { Chevron } from "../ui/chevron";
|
||||
import { ChevronDown } from "../ui/icons";
|
||||
import { toastError } from "../ui/toast";
|
||||
import { Truncated } from "../ui/truncated";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Modal } from "../ui/modal";
|
||||
import { ConfirmModal } from "../ui/confirm-modal";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Segmented } from "../ui/segmented";
|
||||
@@ -71,15 +80,6 @@ function Icon({ d, size = 16 }: { d: string; size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */
|
||||
export 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",
|
||||
@@ -152,11 +152,11 @@ function saveGroupSet(storageKey: string | null, next: ReadonlySet<string>): voi
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-state key of a per-origin folder (subagent / scheduled) inside a group: each folder
|
||||
* has its own state. "\0" never appears in Agent ids or Workspace paths, so the composite
|
||||
* never collides across groups or with plain group keys.
|
||||
* Open-state key of a collapsed folder (subagent / scheduled / archived) inside a group:
|
||||
* each folder has its own state. "\0" never appears in Agent ids or Workspace paths, so
|
||||
* the composite never collides across groups or with plain group keys.
|
||||
*/
|
||||
const sourceFolderKey = (groupKey: string, source: SessionSource) => `${source}\0${groupKey}`;
|
||||
const folderKey = (groupKey: string, category: FolderCategory) => `${category}\0${groupKey}`;
|
||||
|
||||
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
|
||||
function StatusDot({ session }: { session: SessionInfo }) {
|
||||
@@ -200,8 +200,18 @@ export function Sidebar({
|
||||
currentAgent,
|
||||
setCurrentAgentId,
|
||||
} = useProject();
|
||||
const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } =
|
||||
useSessions();
|
||||
const {
|
||||
sessions,
|
||||
byAgent,
|
||||
countsByAgent,
|
||||
workspaceCountsByAgent,
|
||||
isLoadedFor,
|
||||
hasMoreFor,
|
||||
loadMoreFor,
|
||||
loading,
|
||||
remove,
|
||||
replace,
|
||||
} = useSessions();
|
||||
const chatMatch = useMatch("/chat/:sessionId");
|
||||
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
||||
|
||||
@@ -228,16 +238,15 @@ export function Sidebar({
|
||||
setCollapsedGroups(loadGroupSet(collapseStoreKey));
|
||||
setPinnedGroups(loadGroupSet(pinStoreKey));
|
||||
}, [collapseStoreKey, pinStoreKey]);
|
||||
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
|
||||
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Expanded per-origin folders (subagent / scheduled Sessions; collapsed by default), keyed by sourceFolderKey — each folder has its own open state. */
|
||||
const [openSourceFolders, setOpenSourceFolders] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */
|
||||
const [openFolders, setOpenFolders] = useState<ReadonlySet<string>>(new Set());
|
||||
/** "More" rows with a fetch in flight, keyed `${category}\0${groupKey}` — the row disables and reads "loading" so a page that lands entirely in other groups still visibly did something. */
|
||||
const [pendingLoads, setPendingLoads] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Per-group display cap for active rows (keyed by group key; absent = SIDEBAR_PAGE_SIZE). "More" raises it a page at a time. */
|
||||
const [groupCaps, setGroupCaps] = useState<ReadonlyMap<string, number>>(new Map());
|
||||
/** 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("");
|
||||
@@ -252,6 +261,12 @@ export function Sidebar({
|
||||
/** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */
|
||||
const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]);
|
||||
|
||||
/** Workspace-mode per-group exact server totals (folded from the per-Agent per-Workspace counts). */
|
||||
const workspaceGroupCounts = useMemo(
|
||||
() => aggregateWorkspaceCounts(workspaceCountsByAgent),
|
||||
[workspaceCountsByAgent],
|
||||
);
|
||||
|
||||
// Pinned groups first within each mode; inside each partition the existing order is kept
|
||||
// (recency for Workspace groups, the configured Agent order for Agents).
|
||||
const orderedAgents = useMemo(
|
||||
@@ -286,22 +301,42 @@ export function Sidebar({
|
||||
saveGroupSet(pinStoreKey, next);
|
||||
};
|
||||
|
||||
const toggleArchivedGroup = (key: string) =>
|
||||
setOpenArchived((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
/** In-flight key of one group's category "More" (folderKey shares the same composite for folder categories). */
|
||||
const loadKey = (groupKey: string, category: SessionCategory) => `${category}\0${groupKey}`;
|
||||
|
||||
const toggleSourceFolder = (groupKey: string, source: SessionSource) =>
|
||||
setOpenSourceFolders((prev) => {
|
||||
const key = sourceFolderKey(groupKey, source);
|
||||
/** loadMoreFor with an in-flight marker for the triggering "More" row (disable + loading text). */
|
||||
const trackedLoadMore = (groupKey: string, category: SessionCategory, agentIds: string[]) => {
|
||||
const key = loadKey(groupKey, category);
|
||||
setPendingLoads((prev) => new Set(prev).add(key));
|
||||
void loadMoreFor(agentIds, category).finally(() => {
|
||||
setPendingLoads((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/close a group's folder. A folder's content is loaded on demand: the first
|
||||
* expand fetches its category's first page for every contributing Agent that hasn't
|
||||
* been asked yet (already-loaded rows stay put — re-expanding never refetches; the
|
||||
* folder's own "More" row does the paging from there).
|
||||
*/
|
||||
const toggleFolder = (groupKey: string, category: FolderCategory, agentIds: string[]) => {
|
||||
const key = folderKey(groupKey, category);
|
||||
const opening = !openFolders.has(key);
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
if (opening) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
if (opening) {
|
||||
const unloaded = agentIds.filter((id) => !isLoadedFor(id, category));
|
||||
if (unloaded.length > 0) void loadMoreFor(unloaded, category);
|
||||
}
|
||||
};
|
||||
|
||||
// The open chat is an automation-created Session: expand exactly its origin's folder in its
|
||||
// group, so the active row is never hidden inside a collapsed folder (mirrors the archived
|
||||
@@ -315,20 +350,25 @@ export function Sidebar({
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
const s = sessions.find((x) => x.sessionId === activeSessionId);
|
||||
if (!s || !s.source || s.archived) return;
|
||||
if (!s) return;
|
||||
const category = sessionCategory(s);
|
||||
if (category === "active" || category === "archived") return;
|
||||
const guard = `${groupMode}\0${activeSessionId}`;
|
||||
if (lastAutoExpandedRef.current === guard) return;
|
||||
lastAutoExpandedRef.current = guard;
|
||||
const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
|
||||
const key = sourceFolderKey(groupKey, s.source);
|
||||
setOpenSourceFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
||||
}, [activeSessionId, sessions, groupMode]);
|
||||
const key = folderKey(groupKey, category);
|
||||
setOpenFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
||||
// Same on-demand load a click-expand does, for this Session's own Agent (siblings of
|
||||
// other contributing Agents stay behind the folder's "More").
|
||||
if (!isLoadedFor(s.agentId, category)) void loadMoreFor([s.agentId], category);
|
||||
}, [activeSessionId, sessions, groupMode, isLoadedFor, loadMoreFor]);
|
||||
|
||||
/** 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.
|
||||
// Archiving the currently open chat: expand the "archived" folder 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(sessionGroupKey(s)));
|
||||
setOpenFolders((prev) => new Set(prev).add(folderKey(sessionGroupKey(s), "archived")));
|
||||
}
|
||||
try {
|
||||
const res = await api.patchSession(s.sessionId, { archived: !s.archived });
|
||||
@@ -349,7 +389,7 @@ export function Sidebar({
|
||||
replace(res.session);
|
||||
setRenamingSession(null);
|
||||
} catch (e) {
|
||||
setRenameError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setRenameError(apiErrorText(e));
|
||||
} finally {
|
||||
setRenameBusy(false);
|
||||
}
|
||||
@@ -358,7 +398,6 @@ export function Sidebar({
|
||||
const confirmDeleteSession = async () => {
|
||||
if (!deletingSession) return;
|
||||
setDeletingBusy(true);
|
||||
setDeleteError(null);
|
||||
const target = deletingSession;
|
||||
try {
|
||||
await api.deleteSession(target.sessionId);
|
||||
@@ -366,17 +405,21 @@ export function Sidebar({
|
||||
// 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).
|
||||
// The deleted session was the one open: jump to this Agent's next conversation, otherwise
|
||||
// fall back to the chat home page. Auto-opened conversations are never archived (hidden by
|
||||
// default — landing there would look like the chat vanished into thin air) and never
|
||||
// subagent children (they belong to some other conversation).
|
||||
if (activeSessionId === target.sessionId) {
|
||||
const rest = (byAgent.get(target.agentId) ?? []).filter(
|
||||
(s) => s.sessionId !== target.sessionId && !s.archived,
|
||||
);
|
||||
const rest = (byAgent.get(target.agentId) ?? []).filter((s) => {
|
||||
const category = sessionCategory(s);
|
||||
return (
|
||||
s.sessionId !== target.sessionId && (category === "active" || category === "schedule")
|
||||
);
|
||||
});
|
||||
navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat");
|
||||
}
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
toastError(apiErrorText(e));
|
||||
} finally {
|
||||
setDeletingBusy(false);
|
||||
}
|
||||
@@ -440,10 +483,7 @@ export function Sidebar({
|
||||
setRenameText(x.title ?? "");
|
||||
setRenamingSession(x);
|
||||
}}
|
||||
onDelete={(x) => {
|
||||
setDeleteError(null);
|
||||
setDeletingSession(x);
|
||||
}}
|
||||
onDelete={(x) => setDeletingSession(x)}
|
||||
onToggleArchive={(x) => void toggleArchive(x)}
|
||||
/>
|
||||
))}
|
||||
@@ -453,65 +493,102 @@ export function Sidebar({
|
||||
const folderClass =
|
||||
"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";
|
||||
|
||||
/** Collapsed-by-default per-origin folder (subagent / scheduled), parallel to the archived folder. */
|
||||
const renderSourceFolder = (
|
||||
/**
|
||||
* Collapsed-by-default lazy folder (subagent / scheduled / archived): nothing is
|
||||
* fetched until the first expand, and once open the folder pages independently with
|
||||
* its own "More" row. Everything is driven by the group's **own** exact server share
|
||||
* (`totals` — the Agent's counts in agent mode, the per-Workspace fold in workspace
|
||||
* mode): the folder exists only while its share is non-zero, the label shows that
|
||||
* share, and "More" shows only while loaded rows fall short of it — an Agent's
|
||||
* content in *other* Workspaces can never surface a folder here.
|
||||
*/
|
||||
const renderFolder = (
|
||||
groupKey: string,
|
||||
source: SessionSource,
|
||||
rows: SessionInfo[],
|
||||
category: FolderCategory,
|
||||
parts: SessionPartition,
|
||||
withAgentHint: boolean,
|
||||
/** Agents that may hold this group's rows of this category (fetch fan-out set). */
|
||||
agentIds: string[],
|
||||
totals: SessionCategoryCounts | undefined,
|
||||
) => {
|
||||
if (rows.length === 0) return null;
|
||||
const open = openSourceFolders.has(sourceFolderKey(groupKey, source));
|
||||
const rows = parts[category];
|
||||
// Loaded rows win a disagreement with the totals (counts refresh only on reload).
|
||||
const total = Math.max(totals?.[category] ?? 0, rows.length);
|
||||
if (total === 0) return null;
|
||||
const open = openFolders.has(folderKey(groupKey, category));
|
||||
// More while the group's share isn't fully loaded AND somewhere is left to fetch from
|
||||
// (counts drifting above reality would otherwise leave a dead button until reload).
|
||||
const more = rows.length < total && agentIds.some((id) => hasMoreFor(id, category));
|
||||
const pending = pendingLoads.has(loadKey(groupKey, category));
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div key={category} className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSourceFolder(groupKey, source)}
|
||||
onClick={() => toggleFolder(groupKey, category, agentIds)}
|
||||
className={folderClass}
|
||||
>
|
||||
<Chevron open={open} size={12} />
|
||||
{S.chat.sourceGroups[source](rows.length)}
|
||||
{S.chat.folderGroups[category](total)}
|
||||
</button>
|
||||
{open && renderRows(rows, withAgentHint)}
|
||||
{/* The folder's own paging, independent of the active list's "More". In workspace
|
||||
mode a fetched page can land rows in other groups' folders too, so one click may
|
||||
grow this folder by fewer than a full page — the row shows a loading state while
|
||||
the fetch runs and stays until this group's share is fully loaded. */}
|
||||
{open && more && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.loadMore}
|
||||
disabled={pending}
|
||||
onClick={() => trackedLoadMore(groupKey, category, agentIds)}
|
||||
className={`${folderClass} disabled:opacity-60`}
|
||||
>
|
||||
<span className="w-3" aria-hidden />
|
||||
{pending ? S.common.loading : S.chat.loadMore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** "More": reveal one more page of already-loaded active rows AND fetch the next server page for every contributing Agent that still has one. */
|
||||
const showMore = (groupKey: string, moreAgents: string[]) => {
|
||||
/** Active-list "More": reveal one more page of already-loaded active rows AND fetch the next active server page for every Agent that still has one. */
|
||||
const showMore = (groupKey: string, agentIds: string[]) => {
|
||||
setGroupCaps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE);
|
||||
return next;
|
||||
});
|
||||
if (moreAgents.length > 0) void loadMoreFor(moreAgents);
|
||||
if (agentIds.length > 0) trackedLoadMore(groupKey, "active", agentIds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Expanded group body shared by both modes: user rows (display-capped; "More" reveals and
|
||||
* loads further pages) + the collapsed-by-default subagent / scheduled / archived subgroups
|
||||
* (keyed by the group key; rendered uncapped over loaded data — they are collapsed by
|
||||
* default and only ever hold what the pages brought in).
|
||||
* Expanded group body shared by both modes: active user rows (display-capped; "More"
|
||||
* reveals and loads further **active-only** pages — the folders below never feed it) +
|
||||
* the collapsed-by-default subagent / scheduled / archived folders, each loading on
|
||||
* first expand and paging on its own. `totals` / `agentsFor` carry the group's exact
|
||||
* server share and its fetch fan-out set per category.
|
||||
*/
|
||||
const renderGroupBody = (
|
||||
groupKey: string,
|
||||
parts: SessionPartition,
|
||||
withAgentHint: boolean,
|
||||
/** Agents contributing to this group that still have unfetched server pages. */
|
||||
moreAgents: string[],
|
||||
totals: SessionCategoryCounts | undefined,
|
||||
agentsFor: (category: SessionCategory) => string[],
|
||||
) => {
|
||||
const archivedOpen = openArchived.has(groupKey);
|
||||
const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE;
|
||||
const shownActive = parts.active.slice(0, cap);
|
||||
// "More" while hidden loaded rows exist OR any contributing Agent has server-side pages
|
||||
// left; a fetched page can also land rows in the folders below, so one click may grow
|
||||
// the visible list by fewer than a full page — the row simply stays until exhausted.
|
||||
const hasMore = parts.active.length > cap || moreAgents.length > 0;
|
||||
const empty =
|
||||
parts.active.length === 0 &&
|
||||
parts.subagent.length === 0 &&
|
||||
parts.schedule.length === 0 &&
|
||||
parts.archived.length === 0;
|
||||
// Only the outer active rows drive the group's "More" — the folders never feed it:
|
||||
// hidden loaded rows exist, or the group's own active share isn't fully loaded yet.
|
||||
const activeAgents = agentsFor("active");
|
||||
const activeTotal = Math.max(totals?.active ?? 0, parts.active.length);
|
||||
const hasMore =
|
||||
parts.active.length > cap ||
|
||||
(parts.active.length < activeTotal && activeAgents.some((id) => hasMoreFor(id, "active")));
|
||||
const folders = FOLDER_CATEGORIES.map((category) =>
|
||||
renderFolder(groupKey, category, parts, withAgentHint, agentsFor(category), totals),
|
||||
);
|
||||
const empty = parts.active.length === 0 && folders.every((f) => f === null);
|
||||
const activePending = pendingLoads.has(loadKey(groupKey, "active"));
|
||||
return (
|
||||
<>
|
||||
{empty ? (
|
||||
@@ -527,33 +604,19 @@ export function Sidebar({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.loadMore}
|
||||
onClick={() => showMore(groupKey, moreAgents)}
|
||||
className={`${folderClass} mt-0.5`}
|
||||
disabled={activePending}
|
||||
onClick={() => showMore(groupKey, activeAgents)}
|
||||
className={`${folderClass} mt-0.5 disabled:opacity-60`}
|
||||
>
|
||||
<span className="w-3" aria-hidden />
|
||||
{S.chat.loadMore}
|
||||
{activePending ? S.common.loading : S.chat.loadMore}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Per-origin folders (collapsed by default, above Archived): subagent first — spawned
|
||||
from the conversations at hand — then scheduled background runs. */}
|
||||
{renderSourceFolder(groupKey, "subagent", parts.subagent, withAgentHint)}
|
||||
{renderSourceFolder(groupKey, "schedule", parts.schedule, withAgentHint)}
|
||||
|
||||
{/* Archived group (collapsed by default; archived wins over the per-origin folders) */}
|
||||
{parts.archived.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleArchivedGroup(groupKey)}
|
||||
className={folderClass}
|
||||
>
|
||||
<Chevron open={archivedOpen} size={12} />
|
||||
{S.chat.archivedGroup(parts.archived.length)}
|
||||
</button>
|
||||
{archivedOpen && renderRows(parts.archived, withAgentHint)}
|
||||
</div>
|
||||
)}
|
||||
{/* Folders (collapsed by default): subagent first — spawned from the conversations
|
||||
at hand — then scheduled background runs, then archived (archived wins over the
|
||||
origin folders). */}
|
||||
{folders}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -617,7 +680,7 @@ export function Sidebar({
|
||||
{currentProject ? projectDisplayName(currentProject) : S.common.loading}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
<DropdownCaret />
|
||||
<ChevronDown />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@@ -803,7 +866,8 @@ export function Sidebar({
|
||||
agent.agentId,
|
||||
parts,
|
||||
false,
|
||||
hasMoreByAgent.get(agent.agentId) === true ? [agent.agentId] : [],
|
||||
countsByAgent.get(agent.agentId),
|
||||
() => [agent.agentId],
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -820,6 +884,12 @@ export function Sidebar({
|
||||
const parts = partitionSessions(group.sessions);
|
||||
const collapsed = collapsedGroups.has(group.key);
|
||||
const pinned = pinnedGroups.has(group.key);
|
||||
/** This group's exact server share (per-Workspace fold) and its per-category fetch fan-out. */
|
||||
const counts = workspaceGroupCounts.get(group.key);
|
||||
const contributingAgents = [...new Set(group.sessions.map((s) => s.agentId))];
|
||||
const agentsFor = (category: SessionCategory) => [
|
||||
...new Set([...(counts?.agents[category] ?? []), ...contributingAgents]),
|
||||
];
|
||||
return (
|
||||
<div key={group.key} className="pt-2.5">
|
||||
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + pin + new chat in this Workspace.
|
||||
@@ -842,9 +912,10 @@ export function Sidebar({
|
||||
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{group.temp ? S.chat.tempWorkspaces : group.label}
|
||||
</span>
|
||||
{/* Header count = non-archived Sessions (user + automation-created; unchanged semantics) */}
|
||||
{/* Header count = the group's active conversations only (exact server share;
|
||||
loaded rows win a disagreement) — the folders never feed it. */}
|
||||
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{parts.active.length + parts.subagent.length + parts.schedule.length}
|
||||
{Math.max(counts?.totals.active ?? 0, parts.active.length)}
|
||||
</span>
|
||||
<Chevron open={!collapsed} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
@@ -862,15 +933,13 @@ export function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* A workspace group can span Agents: "More" fans out to every contributing Agent that still has pages. */}
|
||||
{/* A workspace group can span Agents: the group body fans folder loads and "More"
|
||||
out per category to the Agents whose share of THIS group is non-zero (plus the
|
||||
Agents already contributing loaded rows) — the active list and each folder
|
||||
page independently. */}
|
||||
{collapsed
|
||||
? null
|
||||
: renderGroupBody(
|
||||
group.key,
|
||||
parts,
|
||||
true,
|
||||
groupAgentsWithMore(group.sessions, hasMoreByAgent),
|
||||
)}
|
||||
: renderGroupBody(group.key, parts, true, counts?.totals, agentsFor)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -1001,47 +1070,34 @@ export function Sidebar({
|
||||
<Input
|
||||
label={S.chat.renameSessionLabel}
|
||||
value={renameText}
|
||||
error={renameError ?? undefined}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
onChange={(e) => setRenameText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setRenameText(e.target.value);
|
||||
if (renameError) setRenameError(null);
|
||||
}}
|
||||
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
|
||||
{/* Delete chat confirmation (shared ConfirmModal) */}
|
||||
<ConfirmModal
|
||||
open={deletingSession !== null}
|
||||
title={S.chat.deleteSession}
|
||||
confirmLabel={S.common.delete}
|
||||
busy={deletingBusy}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => void confirmDeleteSession()}
|
||||
>
|
||||
<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>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1129,8 +1185,7 @@ function SessionRow({
|
||||
: "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>}
|
||||
{/* No per-row source tag: subagent / scheduled Sessions live in their own labelled, collapsed folders, so a badge on the title would just repeat the folder. */}
|
||||
<StatusDot session={s} />
|
||||
{s.pendingApprovalCount > 0 && (
|
||||
<span title={S.chat.pendingApprovals(s.pendingApprovalCount)}>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Confirmation dialog for destructive or overwriting actions (delete,
|
||||
* overwrite-on-update, save-to-file, …), deliberately minimal: **no title bar** —
|
||||
* just the tone icon, the message and a small Cancel / Confirm pair, so every
|
||||
* confirmation in the app is one compact, identical card. `tone` picks the look —
|
||||
* danger (red) for deletions, primary for saves and other overwrites. The message
|
||||
* and any details (e.g. a version list) go in children; `title` only names the
|
||||
* dialog for assistive tech (never rendered).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Modal } from "./modal";
|
||||
import { Button } from "./button";
|
||||
import { S } from "../../lib/strings";
|
||||
|
||||
/** Tinted icon badge per tone: warning triangle on red for danger, pencil-on-gray for confirmations that overwrite/save. */
|
||||
function ToneBadge({ tone }: { tone: "danger" | "primary" }) {
|
||||
const glyph =
|
||||
tone === "danger"
|
||||
? // Triangle alert (lucide): outline + exclamation.
|
||||
"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3zM12 9v4m0 4h.01"
|
||||
: // Pencil-line (lucide): writing changes down.
|
||||
"M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z";
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
|
||||
tone === "danger"
|
||||
? "bg-red-100 text-red-600 dark:bg-red-950/60 dark:text-red-400"
|
||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d={glyph} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
tone = "danger",
|
||||
busy = false,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** Accessible dialog name only — the compact card renders no title bar. */
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
/** Confirm button text (defaults to the shared "Confirm"). */
|
||||
confirmLabel?: string;
|
||||
/** Confirm button variant: danger for deletions, primary for saves and other overwrites. */
|
||||
tone?: "danger" | "primary";
|
||||
busy?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={onClose} headerless widthClass="sm:max-w-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<ToneBadge tone={tone} />
|
||||
<div className="min-w-0 flex-1 pt-1.5">{children}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button size="sm" onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button size="sm" variant={tone} disabled={busy} onClick={onConfirm}>
|
||||
{confirmLabel ?? S.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared confirm-before-save flow for the settings forms: `requestSave(run)` opens a
|
||||
* standard "save these changes?" dialog and Confirm executes `run`. The caller decides
|
||||
* beforehand whether there is anything to save (no changes → an info toast, not a
|
||||
* dialog). Render `element` once per surface.
|
||||
*/
|
||||
export function useSaveConfirm(): {
|
||||
requestSave: (run: () => void) => void;
|
||||
element: ReactNode;
|
||||
} {
|
||||
const [pending, setPending] = useState<(() => void) | null>(null);
|
||||
const element = (
|
||||
<ConfirmModal
|
||||
open={pending !== null}
|
||||
title={S.common.confirmSaveTitle}
|
||||
tone="primary"
|
||||
confirmLabel={S.common.save}
|
||||
onClose={() => setPending(null)}
|
||||
onConfirm={() => {
|
||||
pending?.();
|
||||
setPending(null);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{S.common.confirmSaveBody}</p>
|
||||
</ConfirmModal>
|
||||
);
|
||||
return { requestSave: (run: () => void) => setPending(() => run), element };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { CloseButton } from "./icons";
|
||||
|
||||
export interface DrawerProps {
|
||||
open: boolean;
|
||||
@@ -39,16 +39,7 @@ export function Drawer({ open, side = "left", title, onClose, children, widthCla
|
||||
>
|
||||
<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>
|
||||
<CloseButton onClose={onClose} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]">
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Shared field scaffolding: the label / hint / error text and the wrapping
|
||||
* <label>, plus the class strings for a control's "look" and a menu row. Every
|
||||
* form control (Input, Textarea, Select, OptionMenu, PasswordInput) and every
|
||||
* form that shows a field error builds on these, instead of the five-plus
|
||||
* near-identical copies that used to live in each control and call site.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* The control "look" shared by Input, Select and OptionMenu: rounded box, gray
|
||||
* border that darkens on hover, brand focus ring, dark-mode variants. Excludes
|
||||
* layout (width/flex), text size, placeholder and disabled styling — each
|
||||
* control appends what it needs.
|
||||
*/
|
||||
export const controlBase =
|
||||
"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";
|
||||
|
||||
/** Base padding/alignment/transition for a menu row (Select, OptionMenu, and the chat composer menus); callers add flex/block and the hover/selected colors. */
|
||||
export const menuRowClass = "w-full px-3 py-1.5 text-left transition-colors duration-150";
|
||||
|
||||
export function FieldLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||
return (
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{children}
|
||||
{required && (
|
||||
<span className="ml-0.5 text-red-500 dark:text-red-400" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldHint({ children }: { children: ReactNode }) {
|
||||
return <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{children}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-level error text: placed directly below the offending input (the input itself
|
||||
* is highlighted red via the control's error/invalid state). role="alert" announces the
|
||||
* message to assistive tech the moment it appears; `id` is the anchor the control's
|
||||
* aria-describedby points at, so AT users can also find *what* is wrong from the input.
|
||||
*/
|
||||
export function FieldError({ id, children }: { id?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span id={id} role="alert" className="mt-1 block text-xs text-red-600 dark:text-red-400">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard vertical field layout: an optional bold label above the control, and
|
||||
* below it a red error if present, otherwise a gray hint. Renders the bare
|
||||
* control (no wrapper) when there's no label/hint/error, so a caller can drop it
|
||||
* into a toolbar or table cell unchanged.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
errorId,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
/** id for the error text — the control passes the same id in aria-describedby. */
|
||||
errorId?: string;
|
||||
/** Renders a red "*" after the label to mark the field as required. */
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!label && !hint && !error) return <>{children}</>;
|
||||
return (
|
||||
<label className="block">
|
||||
{label != null && label !== "" && <FieldLabel required={required}>{label}</FieldLabel>}
|
||||
{children}
|
||||
{error ? (
|
||||
<FieldError id={errorId}>{error}</FieldError>
|
||||
) : hint ? (
|
||||
<FieldHint>{hint}</FieldHint>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Shared single-path icons and the dialog close button, replacing SVGs that were
|
||||
* inlined identically at many call sites. Note `chevron.tsx` is a *different*
|
||||
* glyph (the rotating right-caret used by collapsibles) and stays separate.
|
||||
*/
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
|
||||
/** Downward caret on Select / OptionMenu / composer dropdown triggers. Color follows currentColor (callers add text-gray-400). */
|
||||
export function ChevronDown({ size = 12, className = "" }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden
|
||||
className={`shrink-0 ${className}`}
|
||||
>
|
||||
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Selected-row checkmark in the Select / OptionMenu menus. */
|
||||
export function CheckIcon({ size = 13, className = "" }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden
|
||||
className={`shrink-0 ${className}`}
|
||||
>
|
||||
<path d="M5 12l4 4L19 6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Add" plus glyph used by create buttons / new-row affordances. */
|
||||
export function PlusIcon({
|
||||
size = 14,
|
||||
strokeWidth = 1.7,
|
||||
className = "",
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden
|
||||
className={`shrink-0 ${className}`}
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The X close button shared by the Modal / Drawer / Sheet headers: same glyph,
|
||||
* padding and hover treatment. Extra button props (e.g. Sheet's onPointerDown
|
||||
* guard) pass through.
|
||||
*/
|
||||
export function CloseButton({
|
||||
onClose,
|
||||
className = "",
|
||||
...rest
|
||||
}: { onClose: () => void; className?: string } & Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"onClick"
|
||||
>) {
|
||||
return (
|
||||
<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 ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
|
||||
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* Text input component: optional label and hint/error text; rounded corners with
|
||||
* a hover border darken and brand focus-ring transition.
|
||||
* a hover border darken and brand focus-ring transition. Shares the control look
|
||||
* and the label/hint/error scaffolding with the other form controls via field.tsx.
|
||||
*/
|
||||
import { forwardRef } from "react";
|
||||
import { forwardRef, useId } from "react";
|
||||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { Field, controlBase } from "./field";
|
||||
|
||||
// Excludes font size and padding: each of Input/Textarea appends its own (see their size).
|
||||
// Adds width, placeholder and disabled styling on top of the shared control look; each of Input/Textarea appends its own font size and padding (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";
|
||||
`w-full ${controlBase} ` +
|
||||
"placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-60 " +
|
||||
"dark:placeholder:text-gray-500";
|
||||
|
||||
/** Size tier: base (form default) / sm (compact contexts like filter bars, keeps the toolbar from growing taller). */
|
||||
export type ControlSize = "base" | "sm";
|
||||
@@ -38,13 +37,14 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
||||
|
||||
/**
|
||||
* Error state: red border + light red background (a failed field is visible at a
|
||||
* glance; the error text sits below the box).
|
||||
* glance; the error text sits below the box). Shared with Select/OptionMenu so a
|
||||
* failed dropdown reads exactly like a failed input.
|
||||
* 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 =
|
||||
export 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";
|
||||
|
||||
@@ -53,39 +53,35 @@ export function Input({
|
||||
hint,
|
||||
error,
|
||||
invalid,
|
||||
required,
|
||||
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;
|
||||
const errorId = useId();
|
||||
// `required` drives the label's asterisk + aria-required only; the native `required`
|
||||
// attribute is intentionally not forwarded (the app validates on submit, so browser
|
||||
// validation bubbles would collide with the inline field errors).
|
||||
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>
|
||||
<Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
|
||||
<input
|
||||
className={`${baseClass} ${sizeClass[size]} ${bad ? errorClass : ""} ${className ?? ""}`}
|
||||
aria-invalid={bad ? true : undefined}
|
||||
aria-required={required || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
/** Marks the field red without rendering error text (see Input.invalid). */
|
||||
invalid?: boolean;
|
||||
/** Monospace font (for editing Prompts/parameters). */
|
||||
mono?: boolean;
|
||||
/** Font size: base (default, matches body text) or sm (smaller, for editing long Prompts). */
|
||||
@@ -93,26 +89,21 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{ label, hint, mono, size = "base", className, ...rest },
|
||||
{ label, hint, error, invalid, required, 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;
|
||||
const bad = Boolean(error) || Boolean(invalid);
|
||||
const errorId = useId();
|
||||
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>
|
||||
<Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`${baseClass} px-3 py-2 ${size === "sm" ? "text-xs leading-relaxed" : "text-base"} ${mono ? "font-mono" : ""} ${bad ? errorClass : ""} ${className ?? ""}`}
|
||||
aria-invalid={bad ? true : undefined}
|
||||
aria-required={required || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,16 +14,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { CloseButton } from "./icons";
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
/** Dialog name: rendered as the header bar, or (headerless) exposed as the panel's aria-label only. */
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** Panel width class (defaults to sm:max-w-md). */
|
||||
widthClass?: string;
|
||||
/** No header bar (no visible title, no close button): compact dialogs like confirmations — the title still names the dialog for assistive tech. */
|
||||
headerless?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +39,15 @@ export interface ModalProps {
|
||||
*/
|
||||
const modalStack: symbol[] = [];
|
||||
|
||||
export function Modal({ open, title, onClose, children, footer, widthClass }: ModalProps) {
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
widthClass,
|
||||
headerless,
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = Symbol("modal");
|
||||
@@ -61,21 +72,15 @@ export function Modal({ open, title, onClose, children, footer, widthClass }: Mo
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...(headerless ? { role: "dialog", "aria-label": title } : {})}
|
||||
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>
|
||||
{!headerless && (
|
||||
<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>
|
||||
<CloseButton onClose={onClose} />
|
||||
</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">
|
||||
|
||||
@@ -25,10 +25,12 @@
|
||||
* 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 { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { sizeClass } from "./input";
|
||||
import { errorClass, sizeClass } from "./input";
|
||||
import type { ControlSize } from "./input";
|
||||
import { Field, controlBase, menuRowClass } from "./field";
|
||||
import { CheckIcon, ChevronDown } from "./icons";
|
||||
import { usePortalPanel } from "./use-portal-panel";
|
||||
|
||||
export interface OptionMenuChoice<T extends string> {
|
||||
@@ -55,6 +57,8 @@ export function OptionMenu<T extends string>({
|
||||
placeholder,
|
||||
mono,
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
fullWidth,
|
||||
size = "base",
|
||||
"aria-label": ariaLabel,
|
||||
@@ -69,6 +73,10 @@ export function OptionMenu<T extends string>({
|
||||
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;
|
||||
/** Field-value error: red border + message below, exactly like Input. */
|
||||
error?: string;
|
||||
/** Renders a red "*" after the label to mark the field as required. */
|
||||
required?: boolean;
|
||||
/** 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. */
|
||||
@@ -84,6 +92,7 @@ export function OptionMenu<T extends string>({
|
||||
panelWidth: PANEL_WIDTH,
|
||||
});
|
||||
const current = value != null ? options.find((o) => o.value === value) : undefined;
|
||||
const errorId = useId();
|
||||
|
||||
const control = (
|
||||
<>
|
||||
@@ -93,29 +102,19 @@ export function OptionMenu<T extends string>({
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-required={required || undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
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] +
|
||||
`flex items-center gap-2 ${controlBase} ${sizeClass[size]} ${error ? errorClass : ""}` +
|
||||
(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>
|
||||
<ChevronDown className="text-gray-400" />
|
||||
</button>
|
||||
{open &&
|
||||
position &&
|
||||
@@ -142,7 +141,7 @@ export function OptionMenu<T extends string>({
|
||||
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 ${
|
||||
className={`block ${menuRowClass} hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
||||
opt.value === value ? "bg-gray-100 dark:bg-gray-800" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -157,22 +156,7 @@ export function OptionMenu<T extends string>({
|
||||
{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>
|
||||
<CheckIcon className="text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
@@ -187,13 +171,9 @@ export function OptionMenu<T extends string>({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
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>
|
||||
<Field label={label} error={error} errorId={errorId} required={required}>
|
||||
{control}
|
||||
</label>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* 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 { useId, useState } from "react";
|
||||
import { Input, type InputProps } from "./input";
|
||||
import { Field } from "./field";
|
||||
import { S } from "../../lib/strings";
|
||||
|
||||
/** Eye (click to show) and a crossed-out closed eye (click to hide). */
|
||||
@@ -21,25 +22,27 @@ export function PasswordInput({
|
||||
hint,
|
||||
error,
|
||||
invalid,
|
||||
required,
|
||||
size = "base",
|
||||
className,
|
||||
...rest
|
||||
}: Omit<InputProps, "type">) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const toggleLabel = visible ? S.auth.hidePassword : S.auth.showPassword;
|
||||
// The error text renders in THIS component's Field (see the header comment), so the
|
||||
// association is wired here too: the inner Input only gets `invalid` and points its
|
||||
// aria-describedby at the outer FieldError.
|
||||
const errorId = useId();
|
||||
return (
|
||||
<label className="block">
|
||||
{label && (
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...rest}
|
||||
required={required}
|
||||
size={size}
|
||||
type={visible ? "text" : "password"}
|
||||
invalid={Boolean(error) || Boolean(invalid)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`${size === "sm" ? "pr-8" : "pr-10"} ${className ?? ""}`}
|
||||
/>
|
||||
<button
|
||||
@@ -68,11 +71,6 @@ export function PasswordInput({
|
||||
</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>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
* 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
|
||||
* portal to body (fixed positioning from the shared usePortalPanel hook), so it
|
||||
* is never clipped by a Modal or scroll container; it closes on outside click,
|
||||
* Esc, or scroll. Styling matches Input.
|
||||
* Esc, scroll, or resize. Styling matches Input.
|
||||
*/
|
||||
import { Children, isValidElement, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Children, isValidElement, useId, useState } from "react";
|
||||
import type { ChangeEvent, ReactNode, SelectHTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { sizeClass } from "./input";
|
||||
import { errorClass, sizeClass } from "./input";
|
||||
import type { ControlSize } from "./input";
|
||||
import { Field, controlBase, menuRowClass } from "./field";
|
||||
import { CheckIcon, ChevronDown } from "./icons";
|
||||
import { usePortalPanel } from "./use-portal-panel";
|
||||
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
/** Field-value error: red border + message below, exactly like Input. */
|
||||
error?: string;
|
||||
/** Same size tier as Input: sm is for filter bars, keeps the toolbar from growing taller. */
|
||||
size?: ControlSize;
|
||||
}
|
||||
@@ -40,13 +45,7 @@ function parseOptions(children: ReactNode): Opt[] {
|
||||
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";
|
||||
const CONTROL_CLASS = `flex w-full items-center gap-2 text-left ${controlBase} disabled:cursor-not-allowed disabled:opacity-60`;
|
||||
|
||||
/** Menu-row text follows the control's size tier, so the dropdown reads exactly like an Input of the same tier (review: dropdown text = input text). */
|
||||
const menuTextClass: Record<ControlSize, string> = { base: "text-base", sm: "text-xs" };
|
||||
@@ -54,6 +53,8 @@ const menuTextClass: Record<ControlSize, string> = { base: "text-base", sm: "tex
|
||||
export function Select({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
size = "base",
|
||||
className,
|
||||
children,
|
||||
@@ -64,158 +65,83 @@ export function Select({
|
||||
const options = parseOptions(children);
|
||||
const current = String(value ?? "");
|
||||
const selected = options.find((o) => o.value === current);
|
||||
const errorId = useId();
|
||||
|
||||
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 { triggerRef, panelRef, position } = usePortalPanel({
|
||||
open,
|
||||
onClose: () => setOpen(false),
|
||||
// Row height is roughly 36px (px-3 py-1.5 + text) — only used to decide up vs down.
|
||||
estimatedHeight: options.length * 36 + 8,
|
||||
});
|
||||
|
||||
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();
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const control = (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-required={required || undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`${CONTROL_CLASS} ${sizeClass[size]} ${className ?? ""}`}
|
||||
className={`${CONTROL_CLASS} ${sizeClass[size]} ${error ? errorClass : ""} ${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>
|
||||
<ChevronDown className="text-gray-400" />
|
||||
</button>
|
||||
{open &&
|
||||
pos &&
|
||||
position &&
|
||||
createPortal(
|
||||
<ul
|
||||
data-select-menu
|
||||
<div
|
||||
ref={panelRef}
|
||||
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 }),
|
||||
left: position.left,
|
||||
width: position.triggerWidth,
|
||||
top: position.topPx,
|
||||
bottom: position.bottomPx,
|
||||
}}
|
||||
>
|
||||
{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 ${menuTextClass[size]} 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>
|
||||
<button
|
||||
key={`${o.value}-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={o.value === current}
|
||||
disabled={o.disabled}
|
||||
onClick={() => pick(o.value)}
|
||||
className={`flex items-center ${menuRowClass} ${menuTextClass[size]} 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 && <CheckIcon className="text-gray-500 dark:text-gray-400" />}
|
||||
</button>
|
||||
))}
|
||||
</ul>,
|
||||
</div>,
|
||||
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>
|
||||
)}
|
||||
<Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
|
||||
{control}
|
||||
{hint && <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>}
|
||||
</label>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { CloseButton } from "./icons";
|
||||
import { SPRING_DEFAULT, SPRING_MOMENTUM, createSpringDriver } from "../../lib/spring";
|
||||
import type { SpringDriver } from "../../lib/spring";
|
||||
import { nearestSnap, project, rubberband } from "../../lib/sheet-physics";
|
||||
@@ -301,17 +301,7 @@ export function Sheet({ open, snap, onSnapChange, onClose, title, children }: Sh
|
||||
<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>
|
||||
<CloseButton onClose={onClose} onPointerDown={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]">
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
* 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.
|
||||
* Usage: call `toastSuccess("Saved")` / `toastInfo("Already up to date")` /
|
||||
* `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";
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
@@ -25,8 +26,8 @@ interface ToastItem {
|
||||
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 };
|
||||
/** Display duration: error messages are usually longer and more important to read, so give them more time; info sits in between. */
|
||||
const DURATION: Record<ToastKind, number> = { success: 2500, info: 4000, error: 6000 };
|
||||
|
||||
let items: ToastItem[] = [];
|
||||
let nextId = 1;
|
||||
@@ -61,12 +62,14 @@ function push(kind: ToastKind, text: string): void {
|
||||
|
||||
export const toastSuccess = (text: string): void => push("success", text);
|
||||
export const toastError = (text: string): void => push("error", text);
|
||||
export const toastInfo = (text: string): void => push("info", 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",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200",
|
||||
};
|
||||
|
||||
/** Toast container: mount once at the app root. */
|
||||
|
||||
Reference in New Issue
Block a user