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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:17:40 +00:00
parent 7aa98d385f
commit 8953fb13ab
2 changed files with 385 additions and 0 deletions

View file

@ -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<typeof createRoot> | 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(
<QueryClientProvider client={queryClient}>
<WorkspaceSection />
</QueryClientProvider>,
);
});
}
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");
});
});

View file

@ -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 (
<button
type="button"
data-slot="toggle"
aria-label={ariaLabel}
aria-pressed={enabled}
disabled={disabled}
onClick={onClick}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
"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-60",
enabled ? "bg-success" : "bg-muted",
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
);
}
export function WorkspaceSection() {
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
const { openOnboarding } = useDialog();
const [actionError, setActionError] = useState<string | null>(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 (
<SettingsSection title="Workspace">
{actionError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
<SettingsRow label="Theme" description="Choose the visual theme for Nexus.">
<div className="flex flex-wrap gap-2">
{ORDERED_THEMES.map((id) => {
const meta = THEME_META[id];
return (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
aria-pressed={theme === id}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
theme === id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-transparent text-muted-foreground hover:border-ring hover:text-primary",
)}
>
<span
className="h-3 w-3 rounded-full border border-border/50 shrink-0"
style={{ backgroundColor: meta.primary }}
/>
{meta.label}
</button>
);
})}
</div>
</SettingsRow>
<SettingsRow
label="Re-run onboarding"
description="Restart the first-run wizard to reconfigure providers, voice, and phone access."
>
<Button
type="button"
variant="outline"
size="sm"
className="focus-visible:ring-offset-background"
onClick={() => openOnboarding()}
>
Open wizard
</Button>
</SettingsRow>
<SettingsRow
label="Keyboard shortcuts"
description="Enable global keyboard shortcuts for inbox navigation and quick actions."
>
<ToggleSwitch
enabled={keyboardShortcuts}
disabled={updateGeneralMutation.isPending || generalQuery.isLoading}
ariaLabel="Toggle keyboard shortcuts"
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
/>
</SettingsRow>
<SettingsRow
label="Censor username in logs"
description="Mask the username segment in home-directory paths visible to operators."
>
<ToggleSwitch
enabled={censorUsernameInLogs}
disabled={updateGeneralMutation.isPending || generalQuery.isLoading}
ariaLabel="Toggle username log censoring"
onClick={() =>
updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })
}
/>
</SettingsRow>
<SettingsRow
label="AI feedback sharing"
description="Control whether thumbs up and thumbs down votes send the voted AI output to Paperclip Labs. Votes are always saved locally."
>
<div className="flex flex-wrap gap-2">
{[
{ value: "allowed", label: "Allow" },
{ value: "not_allowed", label: "Local only" },
].map((option) => {
const active = feedbackDataSharingPreference === option.value;
return (
<button
key={option.value}
type="button"
disabled={updateGeneralMutation.isPending || generalQuery.isLoading}
aria-pressed={active}
onClick={() =>
updateGeneralMutation.mutate({
feedbackDataSharingPreference: option.value as "allowed" | "not_allowed",
})
}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs transition-colors",
"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-60",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-transparent text-muted-foreground hover:border-ring hover:text-primary",
)}
>
{option.label}
</button>
);
})}
</div>
</SettingsRow>
{feedbackDataSharingPreference === "prompt" ? (
<p className="-mt-2 text-xs text-muted-foreground">
No default saved yet the next vote will ask once.{" "}
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="underline underline-offset-4 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Read terms of service
</a>
) : null}
</p>
) : null}
<SettingsRow
label="Isolated workspaces"
description="Experimental: show execution workspace controls in project configuration for isolated runs."
>
<ToggleSwitch
enabled={enableIsolatedWorkspaces}
disabled={updateExperimentalMutation.isPending || experimentalQuery.isLoading}
ariaLabel="Toggle isolated workspaces experimental setting"
onClick={() =>
updateExperimentalMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })
}
/>
</SettingsRow>
<SettingsRow
label="Auto-restart dev server when idle"
description="Experimental: in pnpm dev, wait for runs to finish then restart the server when backend changes are pending."
>
<ToggleSwitch
enabled={autoRestartDevServerWhenIdle}
disabled={updateExperimentalMutation.isPending || experimentalQuery.isLoading}
ariaLabel="Toggle guarded dev-server auto-restart"
onClick={() =>
updateExperimentalMutation.mutate({
autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle,
})
}
/>
</SettingsRow>
</SettingsSection>
);
}