Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
6e9ecd257e fix: keep raw config edits scoped to config view (#1673) (thanks @Glucksberg) 2026-01-25 02:42:31 +00:00
Glucksberg
595b874798 fix(ui): improve config save UX
Follow-up to #1609 fix:
- Remove formUnsafe check from canSave (was blocking save even with valid changes)
- Suppress disconnect message for code 1012 (service restart is expected during config save)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:24:17 +00:00
8 changed files with 70 additions and 11 deletions

View File

@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: keep raw config edits from toggling channel save state; enable save/apply on raw changes only. (#1673) Thanks @Glucksberg.
- Heartbeat: normalize target identifiers for consistent routing. - Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663) - TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)

View File

@ -12,12 +12,7 @@ import {
SYNTHETIC_BASE_URL, SYNTHETIC_BASE_URL,
SYNTHETIC_MODEL_CATALOG, SYNTHETIC_MODEL_CATALOG,
} from "./synthetic-models.js"; } from "./synthetic-models.js";
import { import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
buildVeniceModelDefinition,
discoverVeniceModels,
VENICE_BASE_URL,
VENICE_MODEL_CATALOG,
} from "./venice-models.js";
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>; type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string]; export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];

View File

@ -48,15 +48,29 @@ describe("models-config", () => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN; const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN; const previousGithub = process.env.GITHUB_TOKEN;
const previousVenice = process.env.VENICE_API_KEY;
const previousKimiCode = process.env.KIMICODE_API_KEY;
const previousKimiCodeAlt = process.env.KIMI_CODE_API_KEY;
const previousMinimax = process.env.MINIMAX_API_KEY; const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY; const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY; const previousSynthetic = process.env.SYNTHETIC_API_KEY;
const previousAwsAccessKey = process.env.AWS_ACCESS_KEY_ID;
const previousAwsSecretKey = process.env.AWS_SECRET_ACCESS_KEY;
const previousAwsProfile = process.env.AWS_PROFILE;
const previousAwsBearer = process.env.AWS_BEARER_TOKEN;
delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN; delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN; delete process.env.GITHUB_TOKEN;
delete process.env.VENICE_API_KEY;
delete process.env.KIMICODE_API_KEY;
delete process.env.KIMI_CODE_API_KEY;
delete process.env.MINIMAX_API_KEY; delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY; delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY; delete process.env.SYNTHETIC_API_KEY;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_PROFILE;
delete process.env.AWS_BEARER_TOKEN;
try { try {
vi.resetModules(); vi.resetModules();
@ -79,12 +93,26 @@ describe("models-config", () => {
else process.env.GH_TOKEN = previousGh; else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN; if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub; else process.env.GITHUB_TOKEN = previousGithub;
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = previousVenice;
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
else process.env.KIMICODE_API_KEY = previousKimiCode;
if (previousKimiCodeAlt === undefined) delete process.env.KIMI_CODE_API_KEY;
else process.env.KIMI_CODE_API_KEY = previousKimiCodeAlt;
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY; if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
else process.env.MINIMAX_API_KEY = previousMinimax; else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY; if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot; else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY; if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic; else process.env.SYNTHETIC_API_KEY = previousSynthetic;
if (previousAwsAccessKey === undefined) delete process.env.AWS_ACCESS_KEY_ID;
else process.env.AWS_ACCESS_KEY_ID = previousAwsAccessKey;
if (previousAwsSecretKey === undefined) delete process.env.AWS_SECRET_ACCESS_KEY;
else process.env.AWS_SECRET_ACCESS_KEY = previousAwsSecretKey;
if (previousAwsProfile === undefined) delete process.env.AWS_PROFILE;
else process.env.AWS_PROFILE = previousAwsProfile;
if (previousAwsBearer === undefined) delete process.env.AWS_BEARER_TOKEN;
else process.env.AWS_BEARER_TOKEN = previousAwsBearer;
} }
}); });
}); });

View File

@ -340,7 +340,9 @@ export async function discoverVeniceModels(): Promise<ModelDefinitionConfig[]> {
}); });
if (!response.ok) { if (!response.ok) {
console.warn(`[venice-models] Failed to discover models: HTTP ${response.status}, using static catalog`); console.warn(
`[venice-models] Failed to discover models: HTTP ${response.status}, using static catalog`,
);
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition); return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
} }
@ -351,7 +353,9 @@ export async function discoverVeniceModels(): Promise<ModelDefinitionConfig[]> {
} }
// Merge discovered models with catalog metadata // Merge discovered models with catalog metadata
const catalogById = new Map(VENICE_MODEL_CATALOG.map((m) => [m.id, m])); const catalogById = new Map<string, VeniceCatalogEntry>(
VENICE_MODEL_CATALOG.map((m) => [m.id, m]),
);
const models: ModelDefinitionConfig[] = []; const models: ModelDefinitionConfig[] = [];
for (const apiModel of data.data) { for (const apiModel of data.data) {

View File

@ -134,7 +134,10 @@ export function connectGateway(host: GatewayHost) {
}, },
onClose: ({ code, reason }) => { onClose: ({ code, reason }) => {
host.connected = false; host.connected = false;
host.lastError = `disconnected (${code}): ${reason || "no reason"}`; // Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
}
}, },
onEvent: (evt) => handleGatewayEvent(host, evt), onEvent: (evt) => handleGatewayEvent(host, evt),
onGap: ({ expected, received }) => { onGap: ({ expected, received }) => {

View File

@ -512,7 +512,6 @@ export function renderApp(state: AppViewState) {
activeSubsection: state.configActiveSubsection, activeSubsection: state.configActiveSubsection,
onRawChange: (next) => { onRawChange: (next) => {
state.configRaw = next; state.configRaw = next;
state.configFormDirty = true;
}, },
onFormModeChange: (mode) => (state.configFormMode = mode), onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),

View File

@ -96,6 +96,34 @@ describe("config view", () => {
expect(applyButton?.disabled).toBe(true); expect(applyButton?.disabled).toBe(true);
}); });
it("enables save and apply when raw changes", () => {
const container = document.createElement("div");
render(
renderConfig({
...baseProps(),
formMode: "raw",
raw: "{\n gateway: { mode: \"local\" }\n}\n",
originalRaw: "{\n}\n",
}),
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const applyButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Apply") as
| HTMLButtonElement
| undefined;
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);
expect(applyButton?.disabled).toBe(false);
});
it("switches mode via the sidebar toggle", () => { it("switches mode via the sidebar toggle", () => {
const container = document.createElement("div"); const container = document.createElement("div");
const onFormModeChange = vi.fn(); const onFormModeChange = vi.fn();

View File

@ -234,8 +234,9 @@ export function renderConfig(props: ConfigProps) {
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges; const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled // Save/apply buttons require actual changes to be enabled
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving
const canSaveForm = const canSaveForm =
Boolean(props.formValue) && !props.loading && !formUnsafe; Boolean(props.formValue) && !props.loading;
const canSave = const canSave =
props.connected && props.connected &&
!props.saving && !props.saving &&