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"} + + +
+
+ )} +
+ ); +}