feat(nexus): add Telegram, About, and DangerZone settings cards (phase 13)

TelegramSection wraps /telegram/token (masked password Input) and
/telegram/status so the bot token can be set or replaced inline.
Values are never logged.

AboutSection renders app name, version, fork lineage, and MIT license
with an outbound link.

DangerZoneSection marks the reset-workspace and delete-all-
conversations actions from spec §8.1 as disabled placeholders — their
server endpoints are not yet wired, and fabricating fake ones would
break the "preserve existing functionality" rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:25:26 +00:00
parent 9b772aa1bd
commit 6aeadeeb11
8 changed files with 447 additions and 5 deletions

View file

@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AboutSection } from "./AboutSection";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("AboutSection", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function render() {
root = createRoot(container);
act(() => {
root!.render(<AboutSection />);
});
}
it("renders the About header and version", () => {
render();
expect(container.querySelector("h2")?.textContent).toBe("About");
expect(container.textContent).toContain("Nexus");
expect(container.textContent).toContain("1.7-dev");
expect(container.textContent).toContain("MIT");
});
it("links the MIT label to the opensource license page", () => {
render();
const links = Array.from(container.querySelectorAll("a"));
const mit = links.find((a) => a.textContent === "MIT");
expect(mit?.getAttribute("href")).toBe("https://opensource.org/license/mit");
});
});

View file

@ -0,0 +1,39 @@
import { VOCAB } from "@paperclipai/branding";
import { SettingsSection, SettingsRow } from "./SettingsSection";
const NEXUS_VERSION = "1.7-dev";
export function AboutSection() {
return (
<SettingsSection title="About">
<SettingsRow label="Version">
<span className="font-mono text-xs text-muted-foreground">
{VOCAB.appName} {NEXUS_VERSION}
</span>
</SettingsRow>
<SettingsRow label="Fork of">
<span className="font-mono text-xs text-muted-foreground">Paperclip</span>
</SettingsRow>
<SettingsRow label="License">
<a
href="https://opensource.org/license/mit"
target="_blank"
rel="noreferrer"
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
MIT
</a>
</SettingsRow>
<SettingsRow label="Source">
<a
href="https://github.com/paperclip-ai/paperclip"
target="_blank"
rel="noreferrer"
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
github.com/paperclip-ai/paperclip
</a>
</SettingsRow>
</SettingsSection>
);
}

View file

@ -94,10 +94,10 @@ describe("CloudProvidersSection", () => {
</QueryClientProvider>,
);
});
// Allow the secrets query to resolve across several microtask ticks.
for (let i = 0; i < 10; i++) {
// Allow the secrets query to resolve.
for (let i = 0; i < 20; i++) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}

View file

@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DangerZoneSection } from "./DangerZoneSection";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("DangerZoneSection", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function render() {
root = createRoot(container);
act(() => {
root!.render(<DangerZoneSection />);
});
}
it("renders both destructive actions as disabled placeholders", () => {
render();
expect(container.querySelector("h2")?.textContent).toBe("Danger zone");
const resetBtn = container.querySelector(
"button[aria-label='Reset workspace (not yet available)']",
) as HTMLButtonElement | null;
const deleteBtn = container.querySelector(
"button[aria-label='Delete all conversations (not yet available)']",
) as HTMLButtonElement | null;
expect(resetBtn).not.toBeNull();
expect(deleteBtn).not.toBeNull();
expect(resetBtn?.disabled).toBe(true);
expect(deleteBtn?.disabled).toBe(true);
});
});

View file

@ -0,0 +1,63 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingsRow } from "./SettingsSection";
/**
* Destructive workspace actions.
*
* Both endpoints (reset workspace, delete all conversations) are not yet
* wired on the server. The buttons render disabled with a "coming soon"
* hint so we neither fabricate API calls nor silently drop the spec row.
*/
export function DangerZoneSection() {
return (
<SettingsSection
title="Danger zone"
description="Destructive actions that cannot be undone. Handle with care."
className="border-destructive/40"
>
<div
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
role="note"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>
These actions are not yet wired up in this build. The buttons are
placeholders to mark the intended surface from spec §8.1.
</p>
</div>
<SettingsRow
label="Reset workspace"
description="Clear all local workspace state and reconfigure from scratch through the onboarding wizard."
>
<Button
type="button"
variant="outline"
size="sm"
disabled
aria-label="Reset workspace (not yet available)"
className="border-destructive/40 text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
>
Reset workspace
</Button>
</SettingsRow>
<SettingsRow
label="Delete all conversations"
description="Permanently delete every stored assistant conversation in this workspace."
>
<Button
type="button"
variant="outline"
size="sm"
disabled
aria-label="Delete all conversations (not yet available)"
className="border-destructive/40 text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
>
Delete conversations
</Button>
</SettingsRow>
</SettingsSection>
);
}

View file

@ -77,9 +77,9 @@ describe("SkillsSection", () => {
</QueryClientProvider>,
);
});
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 20; i++) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}

View file

@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { apiGetSpy, apiPostSpy, pushToastSpy } = vi.hoisted(() => ({
apiGetSpy: vi.fn(),
apiPostSpy: vi.fn(),
pushToastSpy: vi.fn(),
}));
vi.mock("@/api/client", () => ({
api: {
get: apiGetSpy,
post: apiPostSpy,
},
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({
pushToast: pushToastSpy,
dismissToast: () => {},
clearToasts: () => {},
toasts: [],
}),
}));
import { TelegramSection } from "./TelegramSection";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("TelegramSection", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
let queryClient: QueryClient;
beforeEach(() => {
apiGetSpy.mockReset();
apiPostSpy.mockReset();
pushToastSpy.mockReset();
apiGetSpy.mockResolvedValue({ running: false });
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
queryClient.clear();
});
async function renderAndFlush() {
root = createRoot(container);
act(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<TelegramSection />
</QueryClientProvider>,
);
});
for (let i = 0; i < 20; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
it("renders the Telegram bridge header and 'Not running' status", async () => {
await renderAndFlush();
expect(container.querySelector("h2")?.textContent).toBe("Telegram bridge");
expect(container.textContent).toContain("Not running");
});
it("renders a masked password input when Set token is clicked", async () => {
await renderAndFlush();
const setBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent === "Set token",
);
expect(setBtn).toBeDefined();
act(() => {
setBtn!.click();
});
const input = container.querySelector("input");
expect(input?.getAttribute("type")).toBe("password");
expect(input?.getAttribute("aria-label")).toBe("Telegram bot token input");
});
});

View file

@ -0,0 +1,139 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import { useToast } from "@/context/ToastContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { SettingsSection, SettingsRow } from "./SettingsSection";
interface TelegramStatus {
running: boolean;
}
interface TelegramTokenResponse {
ok: true;
botUsername: string;
}
const TELEGRAM_STATUS_KEY = ["settings", "telegram", "status"] as const;
export function TelegramSection() {
const [token, setToken] = useState("");
const [editing, setEditing] = useState(false);
const queryClient = useQueryClient();
const { pushToast } = useToast();
const statusQuery = useQuery({
queryKey: TELEGRAM_STATUS_KEY,
queryFn: () => api.get<TelegramStatus>("/telegram/status"),
refetchInterval: 30_000,
});
const saveMutation = useMutation({
mutationFn: (value: string) =>
api.post<TelegramTokenResponse>("/telegram/token", { token: value }),
onSuccess: async (data) => {
setEditing(false);
setToken("");
pushToast({
type: "success",
message: `Telegram bot @${data.botUsername} saved.`,
});
await queryClient.invalidateQueries({ queryKey: TELEGRAM_STATUS_KEY });
},
onError: (error) => {
pushToast({
type: "error",
message:
error instanceof Error ? error.message : "Failed to save Telegram token.",
});
},
});
const running = statusQuery.data?.running === true;
return (
<SettingsSection
title="Telegram bridge"
description="Send notifications and quick replies from your phone through a Telegram bot. Set up the bot via @BotFather."
>
<SettingsRow
label="Bot status"
description="Running status of the Telegram bot daemon."
>
<span
className={cn(
"text-xs font-medium",
running ? "text-[#166534] dark:text-primary" : "text-muted-foreground",
)}
>
{statusQuery.isLoading ? "..." : running ? "Running" : "Not running"}
</span>
</SettingsRow>
{editing ? (
<SettingsRow
label="Bot token"
description="Paste the token from @BotFather. It is stored encrypted and never logged."
>
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveMutation.mutate(token);
}}
>
<Input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="123456:ABC-DEF..."
autoComplete="off"
aria-label="Telegram bot token input"
className="h-8 w-64 font-mono text-xs focus-visible:ring-offset-background"
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={saveMutation.isPending || !token.trim()}
>
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setEditing(false);
setToken("");
}}
>
Cancel
</Button>
</form>
</SettingsRow>
) : (
<SettingsRow
label="Bot token"
description="Paste the token from @BotFather. It is stored encrypted and never logged."
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{running ? "set" : "not set"}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditing(true)}
>
{running ? "Replace" : "Set token"}
</Button>
</div>
</SettingsRow>
)}
</SettingsSection>
);
}