ui(settings): Quit/Uninstall as real buttons with icons and a busy spinner; code in row descriptions; dimmed unavailable rows; smoother switch

This commit is contained in:
2026-10-16 10:58:14 +07:00
parent 538aec3ed9
commit 5e0a9b526b
4 changed files with 151 additions and 45 deletions
+6 -3
View File
@@ -33,13 +33,16 @@ export function Switch({
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
// Hover darkens the track (enabled only), so it reads as clickable.
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
checked
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
}`}
>
<span
aria-hidden
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
checked ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
@@ -9,17 +9,24 @@
* boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* the machine, database included (scripts/uninstall.js). Quit is a plain
* button and Uninstall a danger one, both the same width with an icon, and
* each spins while its request runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Power, Trash2 } from "lucide-react";
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
// The two action buttons share one width, so they line up under the switches.
const ACTION = "min-w-[6.5rem] justify-center text-xs";
const ICON = "h-3.5 w-3.5 shrink-0";
export function AutostartSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null);
@@ -126,9 +133,11 @@ export function AutostartSettings() {
type="button"
data-testid="quit-agenthub"
disabled={quitting}
aria-busy={quitting}
onClick={() => void quit()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
className={`btn-secondary ${ACTION}`}
>
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
{t("quit.button")}
</button>
}
@@ -141,9 +150,15 @@ export function AutostartSettings() {
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
aria-busy={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
className={`btn-danger ${ACTION}`}
>
{uninstalling ? (
<Loader2 className={ICON + " animate-spin"} />
) : (
<Trash2 className={ICON} />
)}
{t("uninstall.button")}
</button>
}
+31 -4
View File
@@ -4,7 +4,8 @@
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its
* rows in order.
* rows in order. A string description shows its `backticked` words as code,
* and a `disabled` row dims its label, so an unavailable control reads as one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,30 +33,55 @@ export function SettingsSection({
);
}
/** `text` with every `backticked` run drawn as inline code (a command to type). */
export function withCode(text: string): ReactNode {
const parts = text.split(/`([^`]+)`/);
if (parts.length === 1) return text;
return parts.map((p, i) =>
i % 2 ? (
<code
key={i}
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
{p}
</code>
) : (
p
)
);
}
export function SettingsRow({
label,
description,
control,
alignTop = false,
disabled = false,
id,
}: {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
alignTop?: boolean;
/** The control cannot be used here: the label is dimmed with it. */
disabled?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string;
}) {
return (
<div
id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
<div className="min-w-0">
<p className="text-sm text-fg-secondary">{label}</p>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
{description && (
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
{typeof description === "string" ? withCode(description) : description}
</p>
)}
</div>
<div className="shrink-0">{control}</div>
</div>
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
id={id}
label={label}
description={description}
disabled={disabled}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
/>
);