From 8953fb13abe8cc1282fd550acf1dfb9bd676eda6 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:17:40 +0000 Subject: [PATCH] feat(nexus): add WorkspaceSection settings card (phase 13) First of eight section cards for the consolidated Settings page. Folds in theme toggle, re-run onboarding trigger, keyboard shortcuts, log censoring, feedback sharing preference, and the two experimental toggles (isolated workspaces, dev-server auto-restart) that previously lived at /instance/settings/experimental. Preserves all existing form validation, save handlers, and error toasting from InstanceGeneralSettings and InstanceExperimentalSettings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/WorkspaceSection.test.tsx | 128 +++++++++ .../components/settings/WorkspaceSection.tsx | 257 ++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 ui/src/components/settings/WorkspaceSection.test.tsx create mode 100644 ui/src/components/settings/WorkspaceSection.tsx diff --git a/ui/src/components/settings/WorkspaceSection.test.tsx b/ui/src/components/settings/WorkspaceSection.test.tsx new file mode 100644 index 00000000..151073fc --- /dev/null +++ b/ui/src/components/settings/WorkspaceSection.test.tsx @@ -0,0 +1,128 @@ +// @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 openOnboardingSpy = vi.fn(); +const setThemeSpy = vi.fn(); + +vi.mock("@/context/DialogContext", () => ({ + useDialog: () => ({ + onboardingOpen: false, + openOnboarding: openOnboardingSpy, + closeOnboarding: () => {}, + }), +})); + +vi.mock("@/context/ThemeContext", () => ({ + useTheme: () => ({ + theme: "dark", + setTheme: setThemeSpy, + toggleTheme: () => {}, + applyCustomTheme: () => {}, + }), + THEME_META: { + dark: { label: "Dark", dark: true, bg: "#000000", primary: "#faff69" }, + light: { label: "Light", dark: false, bg: "#fafafa", primary: "#166534" }, + }, + ORDERED_THEMES: ["dark", "light"], +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: { + getGeneral: vi.fn(async () => ({ + censorUsernameInLogs: false, + keyboardShortcuts: true, + feedbackDataSharingPreference: "prompt", + })), + updateGeneral: vi.fn(async (patch: unknown) => patch), + getExperimental: vi.fn(async () => ({ + enableIsolatedWorkspaces: false, + autoRestartDevServerWhenIdle: false, + })), + updateExperimental: vi.fn(async (patch: unknown) => patch), + }, +})); + +import { WorkspaceSection } from "./WorkspaceSection"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("WorkspaceSection", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + let queryClient: QueryClient; + + beforeEach(() => { + openOnboardingSpy.mockClear(); + setThemeSpy.mockClear(); + 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(); + }); + + function render() { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + } + + it("renders the Workspace section header", () => { + render(); + const heading = container.querySelector("h2"); + expect(heading?.textContent).toBe("Workspace"); + }); + + it("exposes a Re-run onboarding button that opens the wizard", () => { + render(); + const wizardButton = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Open wizard", + ); + expect(wizardButton).toBeDefined(); + act(() => { + wizardButton!.click(); + }); + expect(openOnboardingSpy).toHaveBeenCalledTimes(1); + }); + + it("offers dark and light theme buttons and calls setTheme", () => { + render(); + const darkBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Dark"), + ); + const lightBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Light"), + ); + expect(darkBtn).toBeDefined(); + expect(lightBtn).toBeDefined(); + expect(darkBtn?.getAttribute("aria-pressed")).toBe("true"); + act(() => { + lightBtn!.click(); + }); + expect(setThemeSpy).toHaveBeenCalledWith("light"); + }); +}); diff --git a/ui/src/components/settings/WorkspaceSection.tsx b/ui/src/components/settings/WorkspaceSection.tsx new file mode 100644 index 00000000..0b7cf899 --- /dev/null +++ b/ui/src/components/settings/WorkspaceSection.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { useDialog } from "@/context/DialogContext"; +import { useTheme, THEME_META, ORDERED_THEMES } from "@/context/ThemeContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { SettingsSection, SettingsRow } from "./SettingsSection"; + +const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; + +function ToggleSwitch({ + enabled, + disabled, + ariaLabel, + onClick, +}: { + enabled: boolean; + disabled?: boolean; + ariaLabel: string; + onClick: () => void; +}) { + return ( + + ); +} + +export function WorkspaceSection() { + const queryClient = useQueryClient(); + const { theme, setTheme } = useTheme(); + const { openOnboarding } = useDialog(); + const [actionError, setActionError] = useState(null); + + const generalQuery = useQuery({ + queryKey: queryKeys.instance.generalSettings, + queryFn: () => instanceSettingsApi.getGeneral(), + }); + + const experimentalQuery = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + + const updateGeneralMutation = useMutation({ + mutationFn: instanceSettingsApi.updateGeneral, + onSuccess: async () => { + setActionError(null); + await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings }); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to update general settings."); + }, + }); + + const updateExperimentalMutation = useMutation({ + mutationFn: instanceSettingsApi.updateExperimental, + onSuccess: async () => { + setActionError(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }), + queryClient.invalidateQueries({ queryKey: queryKeys.health }), + ]); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to update experimental settings."); + }, + }); + + const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; + const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; + const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; + const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; + const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; + + return ( + + {actionError ? ( +
+ {actionError} +
+ ) : null} + + +
+ {ORDERED_THEMES.map((id) => { + const meta = THEME_META[id]; + return ( + + ); + })} +
+
+ + + + + + + updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })} + /> + + + + + updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs }) + } + /> + + + +
+ {[ + { value: "allowed", label: "Allow" }, + { value: "not_allowed", label: "Local only" }, + ].map((option) => { + const active = feedbackDataSharingPreference === option.value; + return ( + + ); + })} +
+
+ {feedbackDataSharingPreference === "prompt" ? ( +

+ No default saved yet — the next vote will ask once.{" "} + {FEEDBACK_TERMS_URL ? ( + + Read terms of service + + ) : null} +

+ ) : null} + + + + updateExperimentalMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces }) + } + /> + + + + + updateExperimentalMutation.mutate({ + autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle, + }) + } + /> + +
+ ); +}