Let a project turn a globally-enabled Claude Code setting back off
The six boolean settings were plain `bool`s merged with
`if p.x { true } else { g.x }`, so a project could only ever add to the
global set. There was no project value that produced `false` — turning a
switch off at project level simply fell through to the global value and
the control did nothing.
Widen them to `Option<bool>`. `None` means "not set at this level":
inherit the global on a project, leave Claude Code's own default alone
globally. `Some(false)` is a deliberate off and wins outright.
The fingerprint now formats with `{:?}` rather than `{}` — `None` and
`Some(false)` mean different things, and conflating them would leave the
container un-recreated when a project switched from inherit to off.
The project editor grows a third "Global" state per switch; the global
editor has nothing to inherit from, so it stays a plain toggle and keeps
collapsing to null at the default. Its three existing tests passed
unchanged and caught a first attempt that rendered unset as off, which
would have told every user their session recap was disabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -3,10 +3,18 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
|
||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||
|
||||
function renderEditor(settings: ClaudeCodeSettings | null) {
|
||||
function renderEditor(
|
||||
settings: ClaudeCodeSettings | null,
|
||||
scope: "global" | "project" = "global",
|
||||
) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<ClaudeCodeSettingsEditor settings={settings} disabled={false} onSave={onSave} />,
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope={scope}
|
||||
settings={settings}
|
||||
disabled={false}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
return onSave;
|
||||
}
|
||||
@@ -59,4 +67,57 @@ describe("ClaudeCodeSettingsEditor", () => {
|
||||
).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
||||
});
|
||||
|
||||
describe("project scope", () => {
|
||||
it("offers Global as a third state so a project can decline to have an opinion", () => {
|
||||
renderEditor(null, "project");
|
||||
const focus = screen.getByLabelText("Focus mode");
|
||||
expect(
|
||||
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["global", "off", "on"]);
|
||||
expect((focus as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("stores a deliberate false so the project can turn a global On back off", () => {
|
||||
// The reason the field widened from boolean to boolean|null. Under the
|
||||
// old merge there was no project value that could produce this.
|
||||
const onSave = renderEditor(null, "project");
|
||||
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ focus_mode: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a deliberate off to null", () => {
|
||||
// `null` means inherit. Collapsing here would silently hand the setting
|
||||
// straight back to the global value the user just overrode.
|
||||
const onSave = renderEditor(null, "project");
|
||||
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||
expect(onSave).not.toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("round-trips the inverted fields through the disabled sense", () => {
|
||||
// Session recap stores `session_recap_disabled`, so choosing "off" has to
|
||||
// store `true` and choosing "on" has to store `false`.
|
||||
const onSave = renderEditor(null, "project");
|
||||
const recap = screen.getByLabelText("Session recap");
|
||||
|
||||
fireEvent.change(recap, { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: true }),
|
||||
);
|
||||
|
||||
fireEvent.change(recap, { target: { value: "on" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a stored override rather than the inherited state", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe(
|
||||
"off",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,29 +8,44 @@ interface Props {
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||
/**
|
||||
* `"project"` adds a third "Global" state to every switch, because a project
|
||||
* has somewhere to inherit *from*. The global editor has no such fallback —
|
||||
* unset there just means Claude Code's own default — so it stays a plain
|
||||
* on/off and never renders the extra choice.
|
||||
*/
|
||||
scope?: "global" | "project";
|
||||
}
|
||||
|
||||
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: false,
|
||||
focus_mode: false,
|
||||
show_thinking_summaries: false,
|
||||
session_recap_disabled: false,
|
||||
env_scrub: false,
|
||||
prompt_caching_1h: false,
|
||||
auto_scroll_disabled: null,
|
||||
focus_mode: null,
|
||||
show_thinking_summaries: null,
|
||||
session_recap_disabled: null,
|
||||
env_scrub: null,
|
||||
prompt_caching_1h: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* "Nothing is set at this level", which is saved as `null` rather than as a
|
||||
* struct of nulls so that a project with no opinion is indistinguishable from
|
||||
* one that never opened this editor.
|
||||
*
|
||||
* Note `false` is *not* a default any more: it is a deliberate off that
|
||||
* overrides a global on, so a settings object holding one has to be persisted.
|
||||
*/
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === false &&
|
||||
s.focus_mode === false &&
|
||||
s.show_thinking_summaries === false &&
|
||||
s.session_recap_disabled === false &&
|
||||
s.env_scrub === false &&
|
||||
s.prompt_caching_1h === false
|
||||
s.auto_scroll_disabled === null &&
|
||||
s.focus_mode === null &&
|
||||
s.show_thinking_summaries === null &&
|
||||
s.session_recap_disabled === null &&
|
||||
s.env_scrub === null &&
|
||||
s.prompt_caching_1h === null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +98,7 @@ export default function ClaudeCodeSettingsEditor({
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
scope = "global",
|
||||
}: Props) {
|
||||
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||
@@ -151,23 +167,71 @@ export default function ClaudeCodeSettingsEditor({
|
||||
}
|
||||
/>
|
||||
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => {
|
||||
const stored = local[key];
|
||||
|
||||
if (scope === "global") {
|
||||
// No level above this one to inherit from, so "unset" and "off" are
|
||||
// the same instruction here and a plain switch is the honest control.
|
||||
// Unset therefore has to *display* as Claude Code's own default —
|
||||
// which for the two inverted fields is on, not off.
|
||||
const checked = invert ? stored !== true : stored === true;
|
||||
return (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
checked={invert ? !local[key] : local[key]}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
apply({ [key]: invert ? !v : v } as Partial<ClaudeCodeSettings>)
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
label={label}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(v) => {
|
||||
// Collapse back to null at the default rather than storing
|
||||
// a redundant `false`, so an untouched global stays
|
||||
// indistinguishable from one that was never opened.
|
||||
const atDefault = invert ? v : !v;
|
||||
apply({
|
||||
[key]: atDefault ? null : invert ? !v : v,
|
||||
} as Partial<ClaudeCodeSettings>);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
// `stored` holds the deviation from Claude Code's default, so an
|
||||
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
||||
const selected =
|
||||
stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<select
|
||||
value={selected}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const choice = e.target.value;
|
||||
const next =
|
||||
choice === "global" ? null : invert ? choice === "off" : choice === "on";
|
||||
apply({ [key]: next } as Partial<ClaudeCodeSettings>);
|
||||
}}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="on">On</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,9 +109,10 @@ export default function RuntimeSection({
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. These override the global defaults in Settings."
|
||||
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On."
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope="project"
|
||||
settings={project.claude_code_settings}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
|
||||
+12
-6
@@ -133,22 +133,28 @@ export interface OpenAiCompatibleConfig {
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field is three-state. `null` means "not set at this level": on a
|
||||
* project that is "inherit the global value", and on the global settings it is
|
||||
* "leave Claude Code's own default alone". `false` is a deliberate off, which
|
||||
* is what lets a project turn a globally-enabled setting back off.
|
||||
*/
|
||||
export interface ClaudeCodeSettings {
|
||||
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
|
||||
tui_mode: string | null;
|
||||
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
|
||||
effort: string | null;
|
||||
auto_scroll_disabled: boolean;
|
||||
auto_scroll_disabled: boolean | null;
|
||||
/** Written as `viewMode: "focus"`. */
|
||||
focus_mode: boolean;
|
||||
show_thinking_summaries: boolean;
|
||||
focus_mode: boolean | null;
|
||||
show_thinking_summaries: boolean | null;
|
||||
/**
|
||||
* Turns the session recap **off**. Held in the disabled sense because Claude
|
||||
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
|
||||
*/
|
||||
session_recap_disabled: boolean;
|
||||
env_scrub: boolean;
|
||||
prompt_caching_1h: boolean;
|
||||
session_recap_disabled: boolean | null;
|
||||
env_scrub: boolean | null;
|
||||
prompt_caching_1h: boolean | null;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
|
||||
Reference in New Issue
Block a user