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.
- 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: 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.
- 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)

View File

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

View File

@ -48,15 +48,29 @@ describe("models-config", () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_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 previousMoonshot = process.env.MOONSHOT_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.GH_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.MOONSHOT_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 {
vi.resetModules();
@ -79,12 +93,26 @@ describe("models-config", () => {
else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
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;
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
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) {
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);
}
@ -351,7 +353,9 @@ export async function discoverVeniceModels(): Promise<ModelDefinitionConfig[]> {
}
// 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[] = [];
for (const apiModel of data.data) {

View File

@ -134,7 +134,10 @@ export function connectGateway(host: GatewayHost) {
},
onClose: ({ code, reason }) => {
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),
onGap: ({ expected, received }) => {

View File

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

View File

@ -96,6 +96,34 @@ describe("config view", () => {
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", () => {
const container = document.createElement("div");
const onFormModeChange = vi.fn();

View File

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