Compare 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
GlucksbergandClaude Opus 4.5 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
15 changed files with 78 additions and 115 deletions

No files matched your search

+2 -2
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)
@@ -28,13 +29,12 @@ Docs: https://docs.clawd.bot
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. - Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery. - Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted. - Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) - Gateway: honor trusted proxy client IPs for local pairing + HTTP checks. (#1654) Thanks @ndbroadbent.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. - Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix. - Google Chat: normalize space targets without double `spaces/` prefix.
- CLI: register lazy subcommands before parse so non-help invocations work. (#1683) Thanks @grrowl.
## 2026.1.23-1 ## 2026.1.23-1
-15
View File
@@ -66,7 +66,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How does memory work?](#how-does-memory-work) - [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key) - [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk) - [Where things live on disk](#where-things-live-on-disk)
- [Is all data used with Clawdbot saved locally?](#is-all-data-used-with-clawdbot-saved-locally)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data) - [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live) - [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy) - [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
@@ -919,20 +918,6 @@ models — see [Memory](/concepts/memory) for the setup details.
## Where things live on disk ## Where things live on disk
### Is all data used with Clawdbot saved locally?
No — **Clawdbot’s state is local**, but **external services still see what you send them**.
- **Local by default:** sessions, memory files, config, and workspace live on the Gateway host
(`~/.clawdbot` + your workspace directory).
- **Remote by necessity:** messages you send to model providers (Anthropic/OpenAI/etc.) go to
their APIs, and chat platforms (WhatsApp/Telegram/Slack/etc.) store message data on their
servers.
- **You control the footprint:** using local models keeps prompts on your machine, but channel
traffic still goes through the channel’s servers.
Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory).
### Where does Clawdbot store its data? ### Where does Clawdbot store its data?
Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`): Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`):
+1 -2
View File
@@ -7,8 +7,7 @@ read_when:
# Windows (WSL2) # Windows (WSL2)
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
Windows installs are untested and more problematic. Windows installs are untested and more problematic.
Native Windows companion apps are planned. Native Windows companion apps are planned.
+1 -1
View File
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
See [Web tools](/tools/web). See [Web tools](/tools/web).
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough. macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows). Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
## 1) Install the CLI (recommended) ## 1) Install the CLI (recommended)
@@ -48,19 +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 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 previousVenice = process.env.VENICE_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.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.VENICE_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();
@@ -83,16 +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; if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
else process.env.KIMICODE_API_KEY = previousKimiCode; 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 (previousVenice === undefined) delete process.env.VENICE_API_KEY; if (previousAwsAccessKey === undefined) delete process.env.AWS_ACCESS_KEY_ID;
else process.env.VENICE_API_KEY = previousVenice; 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;
} }
}); });
}); });
+29 -28
View File
@@ -1,36 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
const registerSubCliByName = vi.fn(async () => true); import { rewriteUpdateFlagArgv } from "./run-main.js";
const parseAsync = vi.fn(async () => undefined);
const buildProgram = vi.fn(() => ({ parseAsync }));
vi.mock("../infra/dotenv.js", () => ({ loadDotEnv: vi.fn() })); describe("rewriteUpdateFlagArgv", () => {
vi.mock("../infra/env.js", () => ({ normalizeEnv: vi.fn() })); it("leaves argv unchanged when --update is absent", () => {
vi.mock("../infra/path-env.js", () => ({ ensureClawdbotCliOnPath: vi.fn() })); const argv = ["node", "entry.js", "status"];
vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: vi.fn() })); expect(rewriteUpdateFlagArgv(argv)).toBe(argv);
vi.mock("../infra/errors.js", () => ({ formatUncaughtError: vi.fn(() => "error") }));
vi.mock("../infra/unhandled-rejections.js", () => ({ installUnhandledRejectionHandler: vi.fn() }));
vi.mock("../logging.js", () => ({ enableConsoleCapture: vi.fn() }));
vi.mock("./program.js", () => ({ buildProgram }));
vi.mock("./program/register.subclis.js", () => ({ registerSubCliByName }));
vi.mock("./route.js", () => ({ tryRouteCli: vi.fn(async () => false) }));
const { runCli } = await import("./run-main.js");
describe("runCli", () => {
afterEach(() => {
registerSubCliByName.mockClear();
parseAsync.mockClear();
buildProgram.mockClear();
}); });
it("registers the primary subcommand before parsing", async () => { it("rewrites --update into the update command", () => {
const argv = ["/usr/bin/node-22", "/opt/clawdbot/entry.js", "gateway", "--port", "18789"]; expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update"])).toEqual([
"node",
"entry.js",
"update",
]);
});
await runCli(argv); it("preserves global flags that appear before --update", () => {
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--profile", "p", "--update"])).toEqual([
"node",
"entry.js",
"--profile",
"p",
"update",
]);
});
expect(registerSubCliByName).toHaveBeenCalledTimes(1); it("keeps update options after the rewritten command", () => {
expect(registerSubCliByName).toHaveBeenCalledWith(expect.any(Object), "gateway"); expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update", "--json"])).toEqual([
expect(parseAsync).toHaveBeenCalledWith(argv); "node",
"entry.js",
"update",
"--json",
]);
}); });
}); });
+7 -6
View File
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js"; import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js"; import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand } from "./argv.js"; import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js"; import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] { export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -50,11 +50,12 @@ export async function runCli(argv: string[] = process.argv) {
}); });
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
// Register the primary subcommand if one exists (for lazy-loading) if (hasHelpOrVersion(parseArgv)) {
const primary = getPrimaryCommand(parseArgv); const primary = getPrimaryCommand(parseArgv);
if (primary) { if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js"); const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary); await registerSubCliByName(program, primary);
}
} }
await program.parseAsync(parseArgv); await program.parseAsync(parseArgv);
} }
+3 -1
View File
@@ -353,7 +353,9 @@ export function applyVeniceProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition); const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
const mergedModels = [ const mergedModels = [
...existingModels, ...existingModels,
...veniceModels.filter((model) => !existingModels.some((existing) => existing.id === model.id)), ...veniceModels.filter(
(model) => !existingModels.some((existing) => existing.id === model.id),
),
]; ];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string, string,
+4 -1
View File
@@ -2,7 +2,10 @@ export {
SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF,
} from "../agents/synthetic-models.js"; } from "../agents/synthetic-models.js";
export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; export {
VENICE_DEFAULT_MODEL_ID,
VENICE_DEFAULT_MODEL_REF,
} from "../agents/venice-models.js";
export { export {
applyAuthProfileConfig, applyAuthProfileConfig,
applyKimiCodeConfig, applyKimiCodeConfig,
+1 -5
View File
@@ -13,7 +13,6 @@ export async function startGatewayDiscovery(params: {
gatewayTls?: { enabled: boolean; fingerprintSha256?: string }; gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number; canvasPort?: number;
wideAreaDiscoveryEnabled: boolean; wideAreaDiscoveryEnabled: boolean;
tailscaleMode: "off" | "serve" | "funnel";
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void }; logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}) { }) {
let bonjourStop: (() => Promise<void>) | null = null; let bonjourStop: (() => Promise<void>) | null = null;
@@ -21,11 +20,8 @@ export async function startGatewayDiscovery(params: {
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" && process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
process.env.NODE_ENV !== "test" && process.env.NODE_ENV !== "test" &&
!process.env.VITEST; !process.env.VITEST;
const tailscaleEnabled = params.tailscaleMode !== "off";
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled; const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
const tailnetDns = needsTailnetDns const tailnetDns = needsTailnetDns ? await resolveTailnetDnsHint() : undefined;
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
: undefined;
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim(); const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN; const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined; const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
-45
View File
@@ -1,45 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const getTailnetHostname = vi.hoisted(() => vi.fn());
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
import { resolveTailnetDnsHint } from "./server-discovery.js";
describe("resolveTailnetDnsHint", () => {
const prevTailnetDns = { value: undefined as string | undefined };
beforeEach(() => {
prevTailnetDns.value = process.env.CLAWDBOT_TAILNET_DNS;
delete process.env.CLAWDBOT_TAILNET_DNS;
getTailnetHostname.mockReset();
});
afterEach(() => {
if (prevTailnetDns.value === undefined) {
delete process.env.CLAWDBOT_TAILNET_DNS;
} else {
process.env.CLAWDBOT_TAILNET_DNS = prevTailnetDns.value;
}
});
test("returns env hint when disabled", async () => {
process.env.CLAWDBOT_TAILNET_DNS = "studio.tailnet.ts.net.";
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBe("studio.tailnet.ts.net");
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("skips tailscale lookup when disabled", async () => {
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBeUndefined();
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("uses tailscale lookup when enabled", async () => {
getTailnetHostname.mockResolvedValue("host.tailnet.ts.net");
const value = await resolveTailnetDnsHint({ enabled: true });
expect(value).toBe("host.tailnet.ts.net");
expect(getTailnetHostname).toHaveBeenCalledTimes(1);
});
});
-2
View File
@@ -55,13 +55,11 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
export async function resolveTailnetDnsHint(opts?: { export async function resolveTailnetDnsHint(opts?: {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
exec?: typeof runExec; exec?: typeof runExec;
enabled?: boolean;
}): Promise<string | undefined> { }): Promise<string | undefined> {
const env = opts?.env ?? process.env; const env = opts?.env ?? process.env;
const envRaw = env.CLAWDBOT_TAILNET_DNS?.trim(); const envRaw = env.CLAWDBOT_TAILNET_DNS?.trim();
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : ""; const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
if (envValue) return envValue; if (envValue) return envValue;
if (opts?.enabled === false) return undefined;
const exec = const exec =
opts?.exec ?? opts?.exec ??
-1
View File
@@ -339,7 +339,6 @@ export async function startGatewayServer(
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 } ? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
: undefined, : undefined,
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true, wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
tailscaleMode,
logDiscovery, logDiscovery,
}); });
bonjourStop = discovery.bonjourStop; bonjourStop = discovery.bonjourStop;
+4 -1
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 }) => {
+2 -1
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 &&