From 6aeadeeb117c382867f8dbe6643ae1980bebf1aa Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:25:26 +0000 Subject: [PATCH] feat(nexus): add Telegram, About, and DangerZone settings cards (phase 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/settings/AboutSection.test.tsx | 52 +++++++ ui/src/components/settings/AboutSection.tsx | 39 +++++ .../settings/CloudProvidersSection.test.tsx | 6 +- .../settings/DangerZoneSection.test.tsx | 52 +++++++ .../components/settings/DangerZoneSection.tsx | 63 ++++++++ .../settings/SkillsSection.test.tsx | 4 +- .../settings/TelegramSection.test.tsx | 97 ++++++++++++ .../components/settings/TelegramSection.tsx | 139 ++++++++++++++++++ 8 files changed, 447 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/settings/AboutSection.test.tsx create mode 100644 ui/src/components/settings/AboutSection.tsx create mode 100644 ui/src/components/settings/DangerZoneSection.test.tsx create mode 100644 ui/src/components/settings/DangerZoneSection.tsx create mode 100644 ui/src/components/settings/TelegramSection.test.tsx create mode 100644 ui/src/components/settings/TelegramSection.tsx diff --git a/ui/src/components/settings/AboutSection.test.tsx b/ui/src/components/settings/AboutSection.test.tsx new file mode 100644 index 00000000..31a18fe5 --- /dev/null +++ b/ui/src/components/settings/AboutSection.test.tsx @@ -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 | 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(); + }); + } + + 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"); + }); +}); diff --git a/ui/src/components/settings/AboutSection.tsx b/ui/src/components/settings/AboutSection.tsx new file mode 100644 index 00000000..42f1b40c --- /dev/null +++ b/ui/src/components/settings/AboutSection.tsx @@ -0,0 +1,39 @@ +import { VOCAB } from "@paperclipai/branding"; +import { SettingsSection, SettingsRow } from "./SettingsSection"; + +const NEXUS_VERSION = "1.7-dev"; + +export function AboutSection() { + return ( + + + + {VOCAB.appName} {NEXUS_VERSION} + + + + Paperclip + + + + MIT + + + + + github.com/paperclip-ai/paperclip + + + + ); +} diff --git a/ui/src/components/settings/CloudProvidersSection.test.tsx b/ui/src/components/settings/CloudProvidersSection.test.tsx index a01dd4de..fc39bb29 100644 --- a/ui/src/components/settings/CloudProvidersSection.test.tsx +++ b/ui/src/components/settings/CloudProvidersSection.test.tsx @@ -94,10 +94,10 @@ describe("CloudProvidersSection", () => { , ); }); - // 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)); }); } } diff --git a/ui/src/components/settings/DangerZoneSection.test.tsx b/ui/src/components/settings/DangerZoneSection.test.tsx new file mode 100644 index 00000000..62ac681f --- /dev/null +++ b/ui/src/components/settings/DangerZoneSection.test.tsx @@ -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 | 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(); + }); + } + + 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); + }); +}); diff --git a/ui/src/components/settings/DangerZoneSection.tsx b/ui/src/components/settings/DangerZoneSection.tsx new file mode 100644 index 00000000..5813ad7a --- /dev/null +++ b/ui/src/components/settings/DangerZoneSection.tsx @@ -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 ( + +
+
+ + + + + + + + +
+ ); +} diff --git a/ui/src/components/settings/SkillsSection.test.tsx b/ui/src/components/settings/SkillsSection.test.tsx index c9afdeea..d5eefeb3 100644 --- a/ui/src/components/settings/SkillsSection.test.tsx +++ b/ui/src/components/settings/SkillsSection.test.tsx @@ -77,9 +77,9 @@ describe("SkillsSection", () => { , ); }); - 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)); }); } } diff --git a/ui/src/components/settings/TelegramSection.test.tsx b/ui/src/components/settings/TelegramSection.test.tsx new file mode 100644 index 00000000..aa804f38 --- /dev/null +++ b/ui/src/components/settings/TelegramSection.test.tsx @@ -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 | 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( + + + , + ); + }); + 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"); + }); +}); diff --git a/ui/src/components/settings/TelegramSection.tsx b/ui/src/components/settings/TelegramSection.tsx new file mode 100644 index 00000000..db7e5ed4 --- /dev/null +++ b/ui/src/components/settings/TelegramSection.tsx @@ -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("/telegram/status"), + refetchInterval: 30_000, + }); + + const saveMutation = useMutation({ + mutationFn: (value: string) => + api.post("/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 ( + + + + {statusQuery.isLoading ? "..." : running ? "Running" : "Not running"} + + + + {editing ? ( + +
{ + e.preventDefault(); + saveMutation.mutate(token); + }} + > + 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" + /> + + +
+
+ ) : ( + +
+ + {running ? "set" : "not set"} + + +
+
+ )} +
+ ); +}