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