feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview
- AI Tools moves from a Settings section into a modal behind a button at the Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs, /settings#ai-tools); rows get an enable switch, Default badge, and an Update button for every tool whose catalog names an updater (no version lookup on open); not-installed tools fold behind a disclosure, a finished job's log folds to one line; shared AiToolIcon - codex updates through its own `codex update`, openclaw/hermes gain `update --yes`; update-all runs every installed updater and skips an npm one only when npm does not manage the binary - Add-lane tool picker is one button showing the chosen tool that opens the enabled tools as buttons - Claude Code / Codex AgentHub sub-tab folded into Overview (home editor, hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME; HomeDirSetting shows the saved value only while the field differs - Preview/Raw toggle for markdown files in the config FileViewer and the Changes panel's whole-file view (MarkdownPreview: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) - docs and the CLAUDE.md marker updated
This commit is contained in:
@@ -1,44 +1,32 @@
|
||||
/**
|
||||
* @file AiToolsSettings.tsx
|
||||
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools), split into the ones found on PATH — each with
|
||||
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
|
||||
* new-version badge — and the ones available to install, with Install where
|
||||
* the catalog knows how. Install/Update/Update tools confirm the exact command
|
||||
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
|
||||
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
|
||||
* Default badge or Set default, and Update whenever the catalog names an
|
||||
* updater — no version lookup, the updater itself knows whether it has work.
|
||||
* The ones available to install fold behind a disclosure, with Install where
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
|
||||
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
|
||||
* A disabled tool is hidden from Add lane; existing lanes keep working.
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
function Avatar({ tool }: { tool: AiTool }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!failed) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const GHOST_BTN =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50";
|
||||
|
||||
function Homepage({ tool }: { tool: AiTool }) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -57,12 +45,51 @@ function Homepage({ tool }: { tool: AiTool }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
tool,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
tool: AiTool;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
||||
tool.enabled ? "bg-accent" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||
tool.enabled ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function JobLog({ job }: { job: AiToolJob }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLPreElement | null>(null);
|
||||
// A finished job folds to its status line; running or failed stays open.
|
||||
const [open, setOpen] = useState(job.status !== "ok");
|
||||
useEffect(() => {
|
||||
if (job.status === "ok") setOpen(false);
|
||||
}, [job.status]);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [job.log]);
|
||||
}, [job.log, open]);
|
||||
const state =
|
||||
job.status === "running"
|
||||
? t("aiTools.jobRunning")
|
||||
@@ -71,18 +98,29 @@ function JobLog({ job }: { job: AiToolJob }) {
|
||||
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<p
|
||||
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{state}
|
||||
</p>
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
|
||||
{state}
|
||||
</span>
|
||||
{job.status !== "running" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-0 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,31 +131,16 @@ export function AiToolsSettings() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const [showAvailable, setShowAvailable] = useState(false);
|
||||
|
||||
// The Add-lane modal links here as /settings#ai-tools; the section renders
|
||||
// after the browser's own hash jump, so it has to scroll itself into view.
|
||||
useEffect(() => {
|
||||
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
|
||||
}, []);
|
||||
|
||||
// Deferred like useAiTools' load: an unreachable server (or a mocked api
|
||||
// without these calls) degrades to "no job, no badges", never a throw.
|
||||
const loadVersions = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.versions())
|
||||
.then(setVersions)
|
||||
.catch(() => undefined);
|
||||
|
||||
// A page opened mid-job picks it up; versions are cached a day server-side.
|
||||
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
||||
// unreachable server (or a mocked api without it) means "no job".
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.job())
|
||||
.then(setJob)
|
||||
.catch(() => undefined);
|
||||
void loadVersions();
|
||||
}, []);
|
||||
|
||||
const running = job?.status === "running";
|
||||
@@ -127,10 +150,7 @@ export function AiToolsSettings() {
|
||||
try {
|
||||
const j = await api.aiTools.job();
|
||||
setJob(j);
|
||||
if (j?.status !== "running") {
|
||||
void reload();
|
||||
void loadVersions();
|
||||
}
|
||||
if (j?.status !== "running") void reload();
|
||||
} catch {
|
||||
/* next tick */
|
||||
}
|
||||
@@ -160,14 +180,10 @@ export function AiToolsSettings() {
|
||||
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
||||
return "";
|
||||
};
|
||||
// Mirrors the server's update-all: badged npm tools, then every self updater.
|
||||
const updateAllTools = tools.filter(
|
||||
(x) =>
|
||||
x.installed &&
|
||||
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
|
||||
);
|
||||
const canUpdate = (tool: AiTool) =>
|
||||
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
// Mirrors the server's update-all: every installed tool with an updater.
|
||||
const updateAllTools = installed.filter((x) => x.update);
|
||||
|
||||
const startConfirmed = async () => {
|
||||
const c = confirm;
|
||||
@@ -186,8 +202,6 @@ export function AiToolsSettings() {
|
||||
|
||||
const jobBusy = running || busy !== null;
|
||||
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
|
||||
const confirmLines =
|
||||
confirm?.kind === "update-all"
|
||||
@@ -197,149 +211,139 @@ export function AiToolsSettings() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Bot className="w-4 h-4 text-fg-muted" />
|
||||
{t("aiTools.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("aiTools.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
disabled={jobBusy || updateAllTools.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
disabled={jobBusy}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("aiTools.updateTools")}
|
||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void run("refresh", async () => {
|
||||
await refresh();
|
||||
await loadVersions();
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const v = versions[tool.id];
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
||||
>
|
||||
<Avatar tool={tool} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
|
||||
{tool.label}
|
||||
<Homepage tool={tool} />
|
||||
{v?.updateAvailable && v.latest && (
|
||||
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
|
||||
{t("aiTools.newVersion", { version: v.latest })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
{v?.installed ? ` · v${v.installed}` : ""}
|
||||
{tool.update?.kind === "npm" && v && !v.npmManaged && (
|
||||
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
return (
|
||||
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||
<span className="truncate">{tool.label}</span>
|
||||
<Homepage tool={tool} />
|
||||
{isDefault && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("aiTools.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canUpdate(tool) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
{isDefault ? (
|
||||
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
onToggle={() =>
|
||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
|
||||
}
|
||||
className={`w-24 rounded-md border px-2 py-1 text-xs disabled:opacity-50 ${
|
||||
tool.enabled
|
||||
? "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
: "border-border bg-surface-2 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
</button>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t("aiTools.available", { count: available.length })}
|
||||
</p>
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAvailable}
|
||||
onClick={() => setShowAvailable((s) => !s)}
|
||||
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{t("aiTools.available", { count: available.length })}
|
||||
</button>
|
||||
{showAvailable && (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{available.map((tool) => (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
|
||||
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
|
||||
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
||||
}`}
|
||||
>
|
||||
<Avatar tool={tool} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{tool.label}
|
||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
|
||||
<AiToolIcon tool={tool} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-fg-muted">
|
||||
{tool.command}
|
||||
</span>
|
||||
</span>
|
||||
<Homepage tool={tool} />
|
||||
{tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -350,14 +354,13 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.install")}
|
||||
</button>
|
||||
)}
|
||||
<Homepage tool={tool} />
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
@@ -384,6 +387,6 @@ export function AiToolsSettings() {
|
||||
{confirmLines.join("\n")}
|
||||
</pre>
|
||||
</ConfirmModal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user