feat(FN-1375): add null-as-delete pattern for settings with Reset button

- Send null instead of undefined when clearing token cap to explicitly delete setting
- Handle null values as delete operations in updateSettings (for clearing keys)
- Add Reset button in Settings modal to clear token cap with one click
- Update placeholder and help text for token cap input
- Fix TypeScript cast error for config.settings
This commit is contained in:
gsxdsm
2026-04-09 12:43:38 -07:00
parent d0cb2e7faf
commit 3ef12726d1
3 changed files with 36 additions and 11 deletions

View File

@@ -27,6 +27,7 @@
- Browser directory pickers (`webkitdirectory`) cannot provide a server filesystem path; for dashboard import flows, parse selected `AGENTS.md` files client-side and send `{ agents }` payloads instead of trying to submit a directory `source` path.
- For conditionally rendered mobile inputs in dashboard components, prefer React `autoFocus` on the input over effect+`setTimeout` focus logic keyed to open-state booleans; mount timing is more reliable and simpler.
- Checkout leasing is explicit: use `checkoutTask`/`releaseTask` (or `/api/tasks/:id/checkout` + `/release`) for ownership, treat 409 conflicts as non-retryable contention, and let `HeartbeatMonitor.executeHeartbeat()` only validate `checkedOutBy` (never auto-acquire leases).
- The null-as-delete pattern for settings: In `TaskStore.updateSettings()`, `null` values in the settings patch are treated as "delete this key from settings" (since `JSON.stringify` drops `undefined` keys). This allows the frontend to explicitly clear a setting by sending `null`. The key is deleted from both `config.settings` and `projectPatch` before merging, so cleared settings fall back to `DEFAULT_SETTINGS`.
## Color Theme System

View File

@@ -595,6 +595,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this.withConfigLock(async () => {
const config = await this.readConfig();
// Handle null values as "delete this key from settings"
// This allows the frontend to explicitly clear a setting by sending null
// (since JSON.stringify drops undefined keys, we use null as a sentinel)
for (const key of Object.keys(projectPatch)) {
if ((projectPatch as Record<string, unknown>)[key] === null) {
delete (config.settings as unknown as Record<string, unknown>)[key];
delete (projectPatch as Record<string, unknown>)[key];
}
}
const globalSettings = await this.globalSettingsStore.getSettings();
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
const updatedProjectSettings = { ...config.settings, ...projectPatch };

View File

@@ -746,17 +746,30 @@ export function SettingsModal({
<div className="form-group">
<label htmlFor="tokenCap">Token Cap</label>
<input
id="tokenCap"
type="number"
placeholder="100000"
value={(form as any).tokenCap ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : undefined } as any));
}}
/>
<small>Automatically compact context when approaching this token count. Leave empty to use default behavior (compact only on overflow errors).</small>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input
id="tokenCap"
type="number"
placeholder="No cap"
value={(form as any).tokenCap ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as any));
}}
/>
{(form as any).tokenCap != null && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to default (no cap)"
onClick={() => setForm((f) => ({ ...f, tokenCap: null } as any))}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
<small>Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count.</small>
</div>
{/* --- Planning & Validation --- */}