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:
parent
7aa98d385f
commit
8953fb13ab
2 changed files with 385 additions and 0 deletions
128
ui/src/components/settings/WorkspaceSection.test.tsx
Normal file
128
ui/src/components/settings/WorkspaceSection.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
257
ui/src/components/settings/WorkspaceSection.tsx
Normal file
257
ui/src/components/settings/WorkspaceSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue