From 9b51c21f52c9c59c44d764fbc9d943a758846da6 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:20:43 -0500 Subject: [PATCH 1/6] test(compression): cover stale full-config saves from the settings tab CompressionSettingsTab loads the compression config once and each save PUTs that whole snapshot, and updateCompressionSettings overwrites every key it receives. A setting changed elsewhere after the tab loaded is written back to its old value by the next save in the tab. The cases drive the tab against a fake route that overwrites each key it gets: Auto-Clarity turned off on the caveman page comes back on after the embedded tab saves the cache TTL, outputStyles saved from another tab reset to [], and a failed save leaves the new value on screen, alone or next to a newer save that succeeds. All four fail on this commit. --- ...ression-settings-tab-partial-save.test.tsx | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/unit/ui/compression-settings-tab-partial-save.test.tsx diff --git a/tests/unit/ui/compression-settings-tab-partial-save.test.tsx b/tests/unit/ui/compression-settings-tab-partial-save.test.tsx new file mode 100644 index 000000000000..8a0b65e6d196 --- /dev/null +++ b/tests/unit/ui/compression-settings-tab-partial-save.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab"; +import CavemanContextPageClient from "@/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient"; + +// next-intl echoes the key, so labels are the translation keys. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => ( + + ), + SegmentedControl: ({ + options, + onChange, + }: { + options: { value: string; label: string }[]; + onChange: (value: string) => void; + }) => ( +
+ {options.map((option) => ( + + ))} +
+ ), +})); + +type Settings = Record; + +const STORED: Settings = { + enabled: true, + defaultMode: "standard", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + cavemanConfig: { + enabled: true, + compressRoles: ["user"], + skipRules: [], + minMessageLength: 50, + preservePatterns: [], + intensity: "full", + }, + cavemanOutputMode: { enabled: true, intensity: "full", autoClarity: true }, + outputStyles: [], + rtkConfig: { enabled: true, intensity: "standard" }, +}; + +// Stands in for the compression settings route. Like updateCompressionSettings, a PUT +// overwrites every key in its body. /api/context/caveman/config re-exports the same handler. +function startServer(failPut: (body: Settings) => boolean = () => false) { + let stored: Settings = JSON.parse(JSON.stringify(STORED)); + const puts: Settings[] = []; + const respond = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const { pathname } = new URL(String(input), "http://localhost"); + if (pathname === "/api/settings/compression" || pathname === "/api/context/caveman/config") { + if (init?.method !== "PUT") return respond(stored); + const body = JSON.parse(String(init.body)) as Settings; + puts.push(body); + if (failPut(body)) return respond({ error: "Save failed" }, 500); + stored = { ...stored, ...body }; + return respond(stored); + } + if (pathname === "/api/compression/rules") return respond({ rules: [] }); + if (pathname === "/api/compression/language-packs") return respond({ packs: [] }); + return respond(null, 404); + }) + ); + return { + get stored() { + return stored; + }, + puts, + // A save made from another browser tab after this one loaded. + write(patch: Settings) { + stored = { ...stored, ...patch }; + }, + }; +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function inputFor(labelKey: string): HTMLInputElement { + const input = screen.getByText(labelKey).closest("label")?.querySelector("input"); + if (!input) throw new Error(`no input next to ${labelKey}`); + return input; +} + +async function renderTab() { + render(); + await settle(); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("CompressionSettingsTab saves only what changed", () => { + it("keeps Auto-Clarity off on the caveman page when the embedded tab saves", async () => { + const server = startServer(); + render(); + await settle(); + fireEvent.click(screen.getByText("advancedMode")); + await settle(); + + const autoClarity = screen.getByLabelText("autoClarity") as HTMLInputElement; + expect(autoClarity.checked).toBe(true); + fireEvent.click(autoClarity); + await settle(); + expect(server.stored.cavemanOutputMode).toMatchObject({ autoClarity: false }); + + fireEvent.change(inputFor("compressionCacheTTL"), { target: { value: "10" } }); + await settle(); + + expect(server.stored.cacheMinutes).toBe(10); + expect(server.stored.cavemanOutputMode).toMatchObject({ autoClarity: false }); + expect(autoClarity.checked).toBe(false); + }); + + it("leaves outputStyles saved from another tab in place", async () => { + const server = startServer(); + await renderTab(); + server.write({ outputStyles: [{ id: "caveman", level: "full" }] }); + + fireEvent.change(inputFor("compressionCacheTTL"), { target: { value: "10" } }); + await settle(); + + expect(server.stored.outputStyles).toEqual([{ id: "caveman", level: "full" }]); + expect(server.puts.at(-1)).toEqual({ cacheMinutes: 10 }); + }); + + it("rolls the field back when its save fails", async () => { + startServer(() => true); + await renderTab(); + const cache = inputFor("compressionCacheTTL"); + + fireEvent.change(cache, { target: { value: "10" } }); + await settle(); + + expect(cache.value).toBe("5"); + expect(screen.getByText("saveFailed")).toBeTruthy(); + }); + + it("rolls back only the failed save when a newer one succeeds", async () => { + const server = startServer((body) => "cacheMinutes" in body); + await renderTab(); + const cache = inputFor("compressionCacheTTL"); + const autoTrigger = inputFor("compressionAutoTrigger"); + + fireEvent.change(cache, { target: { value: "10" } }); + fireEvent.change(autoTrigger, { target: { value: "100" } }); + await settle(); + + expect(cache.value).toBe("5"); + expect(autoTrigger.value).toBe("100"); + expect(server.stored).toMatchObject({ cacheMinutes: 5, autoTriggerTokens: 100 }); + }); +}); From 7ab9264e2f66694eca3388f5d0ccd0f2d975fc04 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:24:20 -0500 Subject: [PATCH 2/6] fix(compression): save only changed fields from the settings tab CompressionSettingsTab loaded the compression config once on mount and every save sent that whole snapshot. updateCompressionSettings overwrites each key it receives, so a setting changed elsewhere after the tab loaded went back to the tab's stale value. On the caveman context page, turning the Auto-Clarity checkbox off and then editing any field in the embedded tab turned autoClarity back on while the checkbox still showed it off. outputStyles saved from another browser tab reset the same way. save() now sends only the fields it is called with, as CompressionPanel does. Saves go out one at a time, and the form shows the last saved config plus the saves still queued, so a failed save rolls back only its own fields and leaves a newer save alone. A key the tab changes still goes out as its whole nested object (cavemanOutputMode, cavemanConfig, aggressive, ultra) because the server stores each key as one row. --- .../components/CompressionSettingsTab.tsx | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx index 5c92027b0503..2a5baa43ae2e 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Card, Button } from "@/shared/components"; import { useTranslations } from "next-intl"; import CompressionTokenSaverCard, { @@ -215,12 +215,22 @@ export default function CompressionSettingsTab() { const [loading, setLoading] = useState(true); const [status, setStatus] = useState<"" | "saved" | "error">(""); const [ruleMetadata, setRuleMetadata] = useState([]); + // A save sends only the fields it changes, so it never writes back a stale copy of settings + // another page or tab changed after this one loaded. Saves go out one at a time and the form + // shows the last saved config plus the saves still queued, so a failed save rolls back only + // its own fields. A PUT that never settles holds up the saves queued behind it. + const savedRef = useRef(config); + const queuedRef = useRef[]>([]); + const saveQueueRef = useRef(Promise.resolve()); useEffect(() => { fetch("/api/settings/compression") .then((r) => (r.ok ? r.json() : null)) .then((data) => { - if (data) setConfig(data); + if (data) { + savedRef.current = data; + setConfig(data); + } }) .catch(() => {}) .finally(() => setLoading(false)); @@ -232,28 +242,34 @@ export default function CompressionSettingsTab() { .catch(() => {}); }, []); - const save = async (updates: Partial) => { - const newConfig = { ...config, ...updates }; - setConfig(newConfig); + const save = (updates: Partial) => { + const showQueued = () => + setConfig( + queuedRef.current.reduce( + (shown, queued) => ({ ...shown, ...queued }), + savedRef.current + ) + ); + queuedRef.current.push(updates); + showQueued(); setSaving(true); setStatus(""); - try { - const res = await fetch("/api/settings/compression", { + saveQueueRef.current = saveQueueRef.current.then(async () => { + const ok = await fetch("/api/settings/compression", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newConfig), - }); - if (res.ok) { - setStatus("saved"); - setTimeout(() => setStatus(""), 2000); - } else { - setStatus("error"); - } - } catch { - setStatus("error"); - } finally { - setSaving(false); - } + body: JSON.stringify(updates), + }).then( + (res) => res.ok, + () => false + ); + queuedRef.current.shift(); + if (ok) savedRef.current = { ...savedRef.current, ...updates }; + showQueued(); + setSaving(queuedRef.current.length > 0); + setStatus(ok ? "saved" : "error"); + if (ok) setTimeout(() => setStatus(""), 2000); + }); }; const toggleCavemanRole = (role: "user" | "assistant" | "system") => { From fab0a7792125b1bc16a454e14c055cbad4728551 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:34:09 -0500 Subject: [PATCH 3/6] docs(changelog): add the #14723 fragment --- changelog.d/fixes/14723-compression-settings-tab-partial-save.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fixes/14723-compression-settings-tab-partial-save.md diff --git a/changelog.d/fixes/14723-compression-settings-tab-partial-save.md b/changelog.d/fixes/14723-compression-settings-tab-partial-save.md new file mode 100644 index 000000000000..8aa6ca02af1e --- /dev/null +++ b/changelog.d/fixes/14723-compression-settings-tab-partial-save.md @@ -0,0 +1 @@ +- **fix(compression):** the compression settings tab saves only the fields you change, so settings changed on another page or tab (such as Auto-Clarity on the caveman page) keep their values, and a failed save rolls the field back ([#14723](https://github.com/diegosouzapw/OmniRoute/pull/14723)) — thanks @woodsonl From 34f2a187989bb65e6c6e72a46c2931537392dc86 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:26:57 -0500 Subject: [PATCH 4/6] fix(compression): keep a failed save's error visible behind later saves A queued save that succeeded after a failed one replaced the error with "saved", and an earlier save's 2-second timeout cleared a later error, so a field that had just rolled back showed no failure. The error now stays on screen until the next edit starts a new save. The rollback test now checks the status after a failed save and a queued success, and a new test covers an earlier success's timeout firing after a later failure. --- .../components/CompressionSettingsTab.tsx | 6 ++-- ...ression-settings-tab-partial-save.test.tsx | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx index 2a5baa43ae2e..e5c0b60f4333 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -267,8 +267,10 @@ export default function CompressionSettingsTab() { if (ok) savedRef.current = { ...savedRef.current, ...updates }; showQueued(); setSaving(queuedRef.current.length > 0); - setStatus(ok ? "saved" : "error"); - if (ok) setTimeout(() => setStatus(""), 2000); + // A failure stays on screen until the next edit, so a queued success or an earlier + // save's timeout cannot hide a field that just rolled back. + setStatus((shown) => (ok ? (shown === "error" ? shown : "saved") : "error")); + if (ok) setTimeout(() => setStatus((shown) => (shown === "saved" ? "" : shown)), 2000); }); }; diff --git a/tests/unit/ui/compression-settings-tab-partial-save.test.tsx b/tests/unit/ui/compression-settings-tab-partial-save.test.tsx index 8a0b65e6d196..d872184aaba3 100644 --- a/tests/unit/ui/compression-settings-tab-partial-save.test.tsx +++ b/tests/unit/ui/compression-settings-tab-partial-save.test.tsx @@ -118,6 +118,7 @@ async function renderTab() { } afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -180,5 +181,32 @@ describe("CompressionSettingsTab saves only what changed", () => { expect(cache.value).toBe("5"); expect(autoTrigger.value).toBe("100"); expect(server.stored).toMatchObject({ cacheMinutes: 5, autoTriggerTokens: 100 }); + // The queued success does not cover up the failure that rolled the cache field back. + expect(screen.getByText("saveFailed")).toBeTruthy(); + expect(screen.queryByText("saved")).toBeNull(); + + // The next edit starts a new save, which clears the error. + fireEvent.change(autoTrigger, { target: { value: "200" } }); + await settle(); + expect(screen.getByText("saved")).toBeTruthy(); + expect(cache.value).toBe("5"); + }); + + it("keeps a later save's error when an earlier save's success message times out", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + startServer((body) => "autoTriggerTokens" in body); + await renderTab(); + + fireEvent.change(inputFor("compressionCacheTTL"), { target: { value: "10" } }); + await settle(); + expect(screen.getByText("saved")).toBeTruthy(); + + fireEvent.change(inputFor("compressionAutoTrigger"), { target: { value: "100" } }); + await settle(); + expect(screen.getByText("saveFailed")).toBeTruthy(); + + // The first save's 2-second "saved" timeout fires after the second save failed. + await act(() => vi.advanceTimersByTimeAsync(2000)); + expect(screen.getByText("saveFailed")).toBeTruthy(); }); }); From ba74e42ff2b7a34f5c0cfc53b1fbed082f6f61dc Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:08:00 -0500 Subject: [PATCH 5/6] test(compression): cover stale nested fields in settings tab saves The settings tab sends each nested object (cavemanOutputMode, aggressive, and the rest) whole, built from the copy it loaded, so a save writes back any sibling field another tab changed since then. Two new cases fail on the current code: toggling Auto-Clarity writes back a stale output-mode intensity, and editing an aggressive threshold writes back a stale sibling threshold and maxTokensPerMessage. The caveman-page case gets a 30-second timeout, because rendering the whole page takes over 5 seconds on a cold run. --- ...ression-settings-tab-partial-save.test.tsx | 98 +++++++++++++++---- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/tests/unit/ui/compression-settings-tab-partial-save.test.tsx b/tests/unit/ui/compression-settings-tab-partial-save.test.tsx index d872184aaba3..724fdf56eb1b 100644 --- a/tests/unit/ui/compression-settings-tab-partial-save.test.tsx +++ b/tests/unit/ui/compression-settings-tab-partial-save.test.tsx @@ -61,6 +61,20 @@ const STORED: Settings = { rtkConfig: { enabled: true, intensity: "standard" }, }; +const AGGRESSIVE = { + thresholds: { fullSummary: 5, moderate: 3, light: 2, verbatim: 2 }, + toolStrategies: { + fileContent: true, + grepSearch: true, + shellOutput: true, + json: true, + errorMessage: true, + }, + summarizerEnabled: true, + maxTokensPerMessage: 2048, + minSavingsThreshold: 0.05, +}; + // Stands in for the compression settings route. Like updateCompressionSettings, a PUT // overwrites every key in its body. /api/context/caveman/config re-exports the same handler. function startServer(failPut: (body: Settings) => boolean = () => false) { @@ -112,6 +126,12 @@ function inputFor(labelKey: string): HTMLInputElement { return input; } +function buttonFor(labelKey: string): HTMLButtonElement { + const button = screen.getByText(labelKey).closest("label")?.querySelector("button"); + if (!button) throw new Error(`no button next to ${labelKey}`); + return button; +} + async function renderTab() { render(); await settle(); @@ -123,26 +143,31 @@ afterEach(() => { }); describe("CompressionSettingsTab saves only what changed", () => { - it("keeps Auto-Clarity off on the caveman page when the embedded tab saves", async () => { - const server = startServer(); - render(); - await settle(); - fireEvent.click(screen.getByText("advancedMode")); - await settle(); + // Rendering the whole caveman page takes over 5 seconds on a cold run. + it( + "keeps Auto-Clarity off on the caveman page when the embedded tab saves", + { timeout: 30_000 }, + async () => { + const server = startServer(); + render(); + await settle(); + fireEvent.click(screen.getByText("advancedMode")); + await settle(); - const autoClarity = screen.getByLabelText("autoClarity") as HTMLInputElement; - expect(autoClarity.checked).toBe(true); - fireEvent.click(autoClarity); - await settle(); - expect(server.stored.cavemanOutputMode).toMatchObject({ autoClarity: false }); + const autoClarity = screen.getByLabelText("autoClarity") as HTMLInputElement; + expect(autoClarity.checked).toBe(true); + fireEvent.click(autoClarity); + await settle(); + expect(server.stored.cavemanOutputMode).toMatchObject({ autoClarity: false }); - fireEvent.change(inputFor("compressionCacheTTL"), { target: { value: "10" } }); - await settle(); + fireEvent.change(inputFor("compressionCacheTTL"), { target: { value: "10" } }); + await settle(); - expect(server.stored.cacheMinutes).toBe(10); - expect(server.stored.cavemanOutputMode).toMatchObject({ autoClarity: false }); - expect(autoClarity.checked).toBe(false); - }); + expect(server.stored.cacheMinutes).toBe(10); + expect(server.stored.cavemanOutputMode).toMatchObject({ autoClarity: false }); + expect(autoClarity.checked).toBe(false); + } + ); it("leaves outputStyles saved from another tab in place", async () => { const server = startServer(); @@ -156,6 +181,45 @@ describe("CompressionSettingsTab saves only what changed", () => { expect(server.puts.at(-1)).toEqual({ cacheMinutes: 10 }); }); + it("keeps an output-mode field changed elsewhere when the tab toggles Auto-Clarity", async () => { + const server = startServer(); + await renderTab(); + // The panel changes the output-mode level after this tab loaded. + server.write({ cavemanOutputMode: { enabled: true, intensity: "ultra", autoClarity: true } }); + + fireEvent.click(buttonFor("compressionSettingsAutoClarityBypass")); + await settle(); + + expect(server.puts.at(-1)).toEqual({ + cavemanOutputMode: { enabled: true, intensity: "ultra", autoClarity: false }, + }); + }); + + it("fills the rest of a nested save from the stored row, two levels down", async () => { + const server = startServer(); + server.write({ defaultMode: "aggressive", aggressive: AGGRESSIVE }); + await renderTab(); + // Another tab changes a sibling threshold and another aggressive field. + server.write({ + aggressive: { + ...AGGRESSIVE, + maxTokensPerMessage: 4096, + thresholds: { ...AGGRESSIVE.thresholds, moderate: 9 }, + }, + }); + + fireEvent.change(inputFor("full Summary"), { target: { value: "7" } }); + await settle(); + + expect(server.stored.aggressive).toEqual({ + ...AGGRESSIVE, + maxTokensPerMessage: 4096, + thresholds: { ...AGGRESSIVE.thresholds, fullSummary: 7, moderate: 9 }, + }); + // The tab now shows the stored row it saved into. + expect(inputFor("moderate").value).toBe("9"); + }); + it("rolls the field back when its save fails", async () => { startServer(() => true); await renderTab(); From f8a63cd3da67715dd21504c7c41d936010dca363 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:39:34 -0500 Subject: [PATCH 6/6] fix(compression): fill nested settings saves from the stored row The settings tab sent each nested object (cavemanConfig, cavemanOutputMode, aggressive, ultra) whole, built from the copy it loaded, so saving one field wrote back every sibling field another tab or the panel had changed since. Toggling Auto-Clarity, for example, wrote back a stale output-mode level. Each save now names only the fields it changes. Before the PUT, save() reads the server's current row for any nested object it touches and fills in the other fields from that row. The PUT still carries whole objects, because the server stores each one as a single row, but no stale siblings. The form still shows the queued edits on top of the last saved state, and a failed save still rolls back only its own fields. --- .../components/CompressionSettingsTab.tsx | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx index e5c0b60f4333..12ed04f9a89a 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -97,6 +97,21 @@ interface RuleMetadata { description: string; } +// A save names only the fields it changes, including fields inside nested objects. +type SettingsPatch = { + [K in keyof T]?: T[K] extends unknown[] ? T[K] : T[K] extends object ? SettingsPatch : T[K]; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +function withPatch(base: T, patch: unknown): T { + if (!isRecord(base) || !isRecord(patch)) return patch as T; + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) merged[key] = withPatch(base[key], value); + return merged as T; +} + const MODES: { value: CompressionMode; labelKey: string; descKey: string; icon: string }[] = [ { value: "off", @@ -220,7 +235,7 @@ export default function CompressionSettingsTab() { // shows the last saved config plus the saves still queued, so a failed save rolls back only // its own fields. A PUT that never settles holds up the saves queued behind it. const savedRef = useRef(config); - const queuedRef = useRef[]>([]); + const queuedRef = useRef[]>([]); const saveQueueRef = useRef(Promise.resolve()); useEffect(() => { @@ -242,11 +257,11 @@ export default function CompressionSettingsTab() { .catch(() => {}); }, []); - const save = (updates: Partial) => { + const save = (updates: SettingsPatch) => { const showQueued = () => setConfig( queuedRef.current.reduce( - (shown, queued) => ({ ...shown, ...queued }), + (shown, queued) => withPatch(shown, queued), savedRef.current ) ); @@ -255,16 +270,32 @@ export default function CompressionSettingsTab() { setSaving(true); setStatus(""); saveQueueRef.current = saveQueueRef.current.then(async () => { - const ok = await fetch("/api/settings/compression", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updates), - }).then( - (res) => res.ok, - () => false - ); + // The server stores each nested object as one row, so a PUT carries the whole object. + // Its other fields come from the server's current row, not this tab's copy, so a sibling + // field saved elsewhere since the tab loaded is not written back. + const current: CompressionConfig | null = Object.values(updates).some(isRecord) + ? await fetch("/api/settings/compression") + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null) + : savedRef.current; + const merged = current && withPatch(current, updates); + const body = + merged && + Object.fromEntries( + Object.keys(updates).map((key) => [key, merged[key as keyof CompressionConfig]]) + ); + const ok = + !!body && + (await fetch("/api/settings/compression", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then( + (res) => res.ok, + () => false + )); queuedRef.current.shift(); - if (ok) savedRef.current = { ...savedRef.current, ...updates }; + if (ok) savedRef.current = { ...savedRef.current, ...body }; showQueued(); setSaving(queuedRef.current.length > 0); // A failure stays on screen until the next edit, so a queued success or an earlier @@ -280,7 +311,7 @@ export default function CompressionSettingsTab() { ? currentRoles.filter((r) => r !== role) : [...currentRoles, role]; save({ - cavemanConfig: { ...config.cavemanConfig!, compressRoles: newRoles }, + cavemanConfig: { compressRoles: newRoles }, }); }; @@ -290,7 +321,7 @@ export default function CompressionSettingsTab() { ? currentSkip.filter((r) => r !== rule) : [...currentSkip, rule]; save({ - cavemanConfig: { ...config.cavemanConfig!, skipRules: newSkip }, + cavemanConfig: { skipRules: newSkip }, }); }; @@ -512,7 +543,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ cavemanConfig: { - ...config.cavemanConfig!, minMessageLength: parseInt(e.target.value) || 50, }, }) @@ -558,7 +588,6 @@ export default function CompressionSettingsTab() { .filter(Boolean); save({ cavemanConfig: { - ...config.cavemanConfig!, preservePatterns: patterns, }, }); @@ -591,7 +620,6 @@ export default function CompressionSettingsTab() { onClick={() => save({ cavemanOutputMode: { - ...config.cavemanOutputMode!, autoClarity: !config.cavemanOutputMode!.autoClarity, }, }) @@ -629,7 +657,6 @@ export default function CompressionSettingsTab() { onClick={() => save({ aggressive: { - ...config.aggressive!, summarizerEnabled: !config.aggressive!.summarizerEnabled, }, }) @@ -657,7 +684,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ aggressive: { - ...config.aggressive!, maxTokensPerMessage: parseInt(e.target.value) || 2048, }, }) @@ -680,7 +706,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ aggressive: { - ...config.aggressive!, minSavingsThreshold: parseFloat(e.target.value) || 0.05, }, }) @@ -713,9 +738,7 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ aggressive: { - ...config.aggressive!, thresholds: { - ...config.aggressive!.thresholds, [tier]: parseInt(e.target.value) || 2, }, }, @@ -740,9 +763,7 @@ export default function CompressionSettingsTab() { onClick={() => save({ aggressive: { - ...config.aggressive!, toolStrategies: { - ...config.aggressive!.toolStrategies, [strategy]: !config.aggressive!.toolStrategies[strategy], }, }, @@ -775,7 +796,6 @@ export default function CompressionSettingsTab() { onClick={() => save({ ultra: { - ...config.ultra!, enabled: !config.ultra!.enabled, }, }) @@ -803,7 +823,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ ultra: { - ...config.ultra!, compressionRate: parseFloat(e.target.value) || 0, }, }) @@ -823,7 +842,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ ultra: { - ...config.ultra!, minScoreThreshold: parseFloat(e.target.value) || 0, }, }) @@ -843,7 +861,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ ultra: { - ...config.ultra!, maxTokensPerMessage: parseInt(e.target.value) || 0, }, }) @@ -860,7 +877,6 @@ export default function CompressionSettingsTab() { onClick={() => save({ ultra: { - ...config.ultra!, slmFallbackToAggressive: !config.ultra!.slmFallbackToAggressive, }, }) @@ -885,7 +901,6 @@ export default function CompressionSettingsTab() { onChange={(e) => save({ ultra: { - ...config.ultra!, modelPath: e.target.value.trim() || undefined, }, })