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:
Yaowei Zheng
2026-07-24 18:43:18 +08:00
committed by GitHub
parent c7465625a4
commit 3f868ac385
50 changed files with 2391 additions and 1190 deletions
@@ -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>
);
}
+197 -142
View File
@@ -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 };
}
+2 -11
View File
@@ -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}
+92
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+41 -50
View File
@@ -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>
);
});
+20 -15
View File
@@ -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">
+20 -40
View File
@@ -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>
);
}
+52 -126
View File
@@ -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>
);
}
+2 -12
View File
@@ -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)]">
+9 -6
View File
@@ -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. */