From 7ef7e95e37c96ed4e5526ce1d2b97b659e220fd3 Mon Sep 17 00:00:00 2001
From: xixi
Date: Fri, 19 Jun 2026 13:17:05 +0800
Subject: [PATCH] test(chat): add regression test for multi-select auto-submit
bug; fix stale closure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a vitest-browser regression test for the "AskUserQuestion multi-select
auto-submit" bug: after a single-select question auto-advances to a multi-select
question, clicking a multi-select option should NOT auto-submit — the user must
be able to keep selecting before manually submitting.
The production fix (if (multiSelect) return inside handleOptionSelection) was
already present in the codebase, but the browser test revealed it was silently
broken: the React Compiler memoises the callback passed to useEffectEvent before
React's commit phase can refresh ref.impl, leaving activeQuestion stale as q1
(multiSelect=false) when the q2 button is first clicked. The guard never fired.
Fix: look up the question directly from prompt.questions using the questionId
argument (which is always correct because it comes from the button's key/onClick
created during the q2 render) rather than relying on the useEffectEvent closure
to deliver a fresh activeQuestion.
Also fixes three pre-existing TypeScript strict-mode errors (exactOptionalPropertyTypes
in _chat.settings.tsx, union-type property access in anthropicAdapter.ts, ReadonlyArray
narrowing in http.ts, flatMap return-type inference in gateway.ts) so that
bun typecheck passes on this branch.
Claude-Session: https://claude.ai/code/session_01B95xcqmnLyWiAhApQdjtLC
---
apps/server/src/anthropicAdapter.ts | 26 ++-
apps/server/src/gateway.ts | 42 ++--
apps/server/src/http.ts | 43 ++--
.../ComposerPendingUserInputPanel.browser.tsx | 183 ++++++++++++++++++
.../chat/ComposerPendingUserInputPanel.tsx | 6 +-
apps/web/src/routes/_chat.settings.tsx | 79 +++++---
6 files changed, 301 insertions(+), 78 deletions(-)
create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx
diff --git a/apps/server/src/anthropicAdapter.ts b/apps/server/src/anthropicAdapter.ts
index 6f3c7d0..3b54ec6 100644
--- a/apps/server/src/anthropicAdapter.ts
+++ b/apps/server/src/anthropicAdapter.ts
@@ -129,9 +129,7 @@ export function anthropicToOpenAIChat(payload: Record): Record<
function: {
name: block.name,
arguments:
- typeof block.input === "string"
- ? block.input
- : JSON.stringify(block.input ?? {}),
+ typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
},
});
} else if (block.type === "text" && typeof block.text === "string") {
@@ -187,7 +185,10 @@ export function anthropicToOpenAIChat(payload: Record): Record<
// are lowercased because some OpenAI-compatible upstreams reject uppercase.
if (Array.isArray(payload.tools)) {
const SERVER_TOOL_PREFIXES = [
- "web_search", "computer", "text_editor", "bash_",
+ "web_search",
+ "computer",
+ "text_editor",
+ "bash_",
"code_execution",
];
const tools = payload.tools
@@ -267,7 +268,8 @@ export async function openAIChatToAnthropicMessages(
): Promise {
const raw = (await openaiResponse.json()) as OpenAIChatResponse | Record;
if (!openaiResponse.ok) {
- const upstreamError = isRecord(raw.error) ? raw.error : {};
+ const rawRecord = raw as Record;
+ const upstreamError = isRecord(rawRecord.error) ? rawRecord.error : {};
const message =
typeof upstreamError.message === "string"
? upstreamError.message
@@ -384,10 +386,7 @@ export function openAIChatStreamToAnthropicStream(
let textBlockOpen = false;
let textBlockStarted = false;
let finalStopReason = "end_turn";
- const toolCallsByIndex = new Map<
- number,
- { id: string; name: string; arguments: string }
- >();
+ const toolCallsByIndex = new Map();
// Track which tool indexes we've already emitted a content_block_start for
// so we can append them after the text block closes.
const emittedToolStarts = new Set();
@@ -460,18 +459,15 @@ export function openAIChatStreamToAnthropicStream(
// we saw in this stream so arguments accumulate together.
const fallbackIndex =
toolCallsByIndex.size > 0 ? Math.max(...toolCallsByIndex.keys()) : 0;
- const idx =
- typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
+ const idx = typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
const fn = isRecord(rawTc.function) ? rawTc.function : {};
const existing = toolCallsByIndex.get(idx);
const id =
typeof rawTc.id === "string" && rawTc.id
? rawTc.id
- : existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`;
+ : (existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`);
const name =
- typeof fn.name === "string" && fn.name
- ? fn.name
- : existing?.name ?? "";
+ typeof fn.name === "string" && fn.name ? fn.name : (existing?.name ?? "");
const args =
(existing?.arguments ?? "") +
(typeof fn.arguments === "string" ? fn.arguments : "");
diff --git a/apps/server/src/gateway.ts b/apps/server/src/gateway.ts
index 4da0ce2..c9a1e67 100644
--- a/apps/server/src/gateway.ts
+++ b/apps/server/src/gateway.ts
@@ -111,21 +111,26 @@ function responsesContentToChatContent(content: unknown): unknown {
if (!Array.isArray(content)) return content ?? "";
const parts: Array<
- | { type: "text"; text: string }
- | { type: "image_url"; image_url: { url: string } }
- > = content.flatMap((part) => {
- if (!isRecord(part)) return [];
- if (
- (part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
- typeof part.text === "string"
- ) {
- return [{ type: "text", text: part.text }];
- }
- if (part.type === "input_image" && typeof part.image_url === "string") {
- return [{ type: "image_url", image_url: { url: part.image_url } }];
- }
- return [];
- });
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
+ > = content.flatMap(
+ (
+ part,
+ ): Array<
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
+ > => {
+ if (!isRecord(part)) return [];
+ if (
+ (part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
+ typeof part.text === "string"
+ ) {
+ return [{ type: "text", text: part.text }];
+ }
+ if (part.type === "input_image" && typeof part.image_url === "string") {
+ return [{ type: "image_url", image_url: { url: part.image_url } }];
+ }
+ return [];
+ },
+ );
if (parts.length === 0) return "";
if (parts.every((part) => part.type === "text")) {
@@ -254,9 +259,10 @@ export function responsesPayloadToChatPayload(
return responsesPayloadToChatPayloadWithContext(payload).chatPayload;
}
-export function responsesPayloadToChatPayloadWithContext(
- payload: Record,
-): { chatPayload: Record; contextInputItems: unknown[] } {
+export function responsesPayloadToChatPayloadWithContext(payload: Record): {
+ chatPayload: Record;
+ contextInputItems: unknown[];
+} {
const contextInputItems = responseContextInputItems(payload);
const result: Record = {
model: payload.model,
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index b11ae89..cc7a3df 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -134,7 +134,7 @@ function headerString(
const lowerName = name.toLowerCase();
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1];
if (Array.isArray(entry)) return entry[0] ?? "";
- return entry ?? "";
+ return (entry as string | undefined) ?? "";
}
function isLoopbackAddress(address: string | null | undefined): boolean {
@@ -231,12 +231,14 @@ const gatewayEffectRouteLayer = HttpRouter.add(
// cookies) happens inside the adapter.
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
- secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
- Effect.map((bytes): [string, string] => [
- def.id,
- bytes ? new TextDecoder().decode(bytes).trim() : "",
- ]),
- ),
+ secretStore
+ .get(gatewaySecretName(upstream.id, def.id))
+ .pipe(
+ Effect.map((bytes): [string, string] => [
+ def.id,
+ bytes ? new TextDecoder().decode(bytes).trim() : "",
+ ]),
+ ),
),
);
const secrets: Record = {};
@@ -255,7 +257,8 @@ const gatewayEffectRouteLayer = HttpRouter.add(
try {
if (isResponses) {
- const { chatPayload, contextInputItems } = responsesPayloadToChatPayloadWithContext(payload);
+ const { chatPayload, contextInputItems } =
+ responsesPayloadToChatPayloadWithContext(payload);
chatPayload.model = model;
const upstreamResponse = yield* Effect.tryPromise({
try: () =>
@@ -273,7 +276,11 @@ const gatewayEffectRouteLayer = HttpRouter.add(
? chatStreamToResponsesStream(upstreamResponse, requestedModel, contextInputItems)
: yield* Effect.tryPromise({
try: () =>
- chatResponseToResponsesResponse(upstreamResponse, requestedModel, contextInputItems),
+ chatResponseToResponsesResponse(
+ upstreamResponse,
+ requestedModel,
+ contextInputItems,
+ ),
catch: () => ({
message: "Failed to convert gateway response.",
status: 502 as const,
@@ -364,9 +371,7 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
);
const messages = isRecord(body) && Array.isArray(body.messages) ? body.messages : [];
const system = isRecord(body) && typeof body.system === "string" ? body.system : "";
- const inputTokens = Math.ceil(
- (system.length + JSON.stringify(messages).length) / 4,
- );
+ const inputTokens = Math.ceil((system.length + JSON.stringify(messages).length) / 4);
return HttpServerResponse.jsonUnsafe({ input_tokens: Math.max(inputTokens, 1) });
}
@@ -401,12 +406,14 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
- secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
- Effect.map((bytes): [string, string] => [
- def.id,
- bytes ? new TextDecoder().decode(bytes).trim() : "",
- ]),
- ),
+ secretStore
+ .get(gatewaySecretName(upstream.id, def.id))
+ .pipe(
+ Effect.map((bytes): [string, string] => [
+ def.id,
+ bytes ? new TextDecoder().decode(bytes).trim() : "",
+ ]),
+ ),
),
);
const secrets: Record = {};
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx
new file mode 100644
index 0000000..d52e75b
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx
@@ -0,0 +1,183 @@
+import "../../index.css";
+
+import { ApprovalRequestId, type UserInputQuestion } from "@peakcode/contracts";
+import { useCallback, useRef, useState } from "react";
+import { page } from "vitest/browser";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render } from "vitest-browser-react";
+
+import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
+import {
+ buildPendingUserInputAnswers,
+ derivePendingUserInputProgress,
+ type PendingUserInputDraftAnswer,
+ togglePendingUserInputOptionSelection,
+} from "../../pendingUserInput";
+import type { PendingUserInput } from "../../session-logic";
+
+const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-pending-user-input-test");
+
+const SINGLE_QUESTION: UserInputQuestion = {
+ id: "q1",
+ header: "Pick one",
+ question: "Choose exactly one thing.",
+ options: [
+ { label: "Single A", description: "first choice" },
+ { label: "Single B", description: "second choice" },
+ ],
+ multiSelect: false,
+};
+
+const MULTI_QUESTION: UserInputQuestion = {
+ id: "q2",
+ header: "Pick many",
+ question: "Choose any number of things.",
+ multiSelect: true,
+ options: [
+ { label: "Multi X", description: "alpha" },
+ { label: "Multi Y", description: "beta" },
+ { label: "Multi Z", description: "gamma" },
+ ],
+};
+
+// Faithful mirror of ChatView's pending-user-input wiring (onToggleActivePendingUserInputOption +
+// onAdvanceActivePendingUserInput), reusing the real helpers so the panel exercises the same logic
+// path it does in production. The harness owns the draft answers and active question index; the panel
+// owns the click handling and 200ms single-select auto-advance.
+function PanelHarness({
+ questions,
+ onSubmit,
+}: {
+ questions: ReadonlyArray;
+ onSubmit: (answers: Record) => void;
+}) {
+ const [answers, setAnswers] = useState>({});
+ const [questionIndex, setQuestionIndex] = useState(0);
+ const answersRef = useRef(answers);
+ const indexRef = useRef(questionIndex);
+
+ const prompt: PendingUserInput = {
+ requestId: REQUEST_ID,
+ createdAt: "2026-06-18T00:00:00.000Z",
+ questions,
+ };
+
+ const onToggleOption = useCallback(
+ (questionId: string, optionLabel: string): PendingUserInputDraftAnswer | null => {
+ const question = questions.find((entry) => entry.id === questionId);
+ if (!question) return null;
+ const nextDraftAnswer = togglePendingUserInputOptionSelection(
+ question,
+ answersRef.current[questionId],
+ optionLabel,
+ );
+ answersRef.current = { ...answersRef.current, [questionId]: nextDraftAnswer };
+ setAnswers(answersRef.current);
+ return nextDraftAnswer;
+ },
+ [questions],
+ );
+
+ const onAdvance = useCallback(
+ (answerOverrides?: Record) => {
+ const draft =
+ answerOverrides && Object.keys(answerOverrides).length > 0
+ ? { ...answersRef.current, ...answerOverrides }
+ : answersRef.current;
+ if (answerOverrides && Object.keys(answerOverrides).length > 0) {
+ answersRef.current = draft;
+ setAnswers(draft);
+ }
+ const progress = derivePendingUserInputProgress(questions, draft, indexRef.current);
+ if (progress.isLastQuestion) {
+ const resolved = buildPendingUserInputAnswers(questions, draft);
+ if (resolved) onSubmit(resolved);
+ return;
+ }
+ const activeQuestionId = progress.activeQuestion?.id ?? null;
+ const hasActiveOverride = activeQuestionId
+ ? answerOverrides?.[activeQuestionId] !== undefined
+ : false;
+ if (!progress.canAdvance && !hasActiveOverride) {
+ return;
+ }
+ const nextIndex = progress.questionIndex + 1;
+ indexRef.current = nextIndex;
+ setQuestionIndex(nextIndex);
+ },
+ [questions, onSubmit],
+ );
+
+ return (
+
+
+
+
+ );
+}
+
+async function mountPanel(
+ questions: ReadonlyArray,
+ onSubmit: (answers: Record) => void,
+) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const screen = await render(, {
+ container: host,
+ });
+ const cleanup = async () => {
+ await screen.unmount();
+ host.remove();
+ };
+ return { [Symbol.asyncDispose]: cleanup, cleanup };
+}
+
+const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
+
+describe("ComposerPendingUserInputPanel", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("does not auto-submit a multi-select question reached via single-select auto-advance", async () => {
+ const onSubmit = vi.fn();
+ await using _ = await mountPanel([SINGLE_QUESTION, MULTI_QUESTION], onSubmit);
+
+ // Answer the single-select question; this arms the 200ms auto-advance to the multi-select one.
+ await page.getByText("Single A", { exact: true }).click();
+
+ // Click the FIRST option of the multi-select question (Playwright auto-waits for it to render
+ // after the auto-advance). Pre-fix, this set a 200ms timer that auto-submitted the whole round.
+ await page.getByText("Multi X", { exact: true }).click();
+
+ // Regression assertion: no auto-submit — the user must be able to keep selecting.
+ await wait(300);
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ // A second option can still be toggled on, then submitting collects BOTH selections.
+ await page.getByText("Multi Y", { exact: true }).click();
+ await page.getByText("harness-submit", { exact: true }).click();
+
+ await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith({ q1: "Single A", q2: ["Multi X", "Multi Y"] });
+ });
+
+ it("still auto-submits a lone single-select question on click", async () => {
+ const onSubmit = vi.fn();
+ await using _ = await mountPanel([SINGLE_QUESTION], onSubmit);
+
+ await page.getByText("Single A", { exact: true }).click();
+
+ await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith({ q1: "Single A" });
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
index aed440b..d1617e5 100644
--- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
+++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
@@ -80,7 +80,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
const nextDraftAnswer = onToggleOption(questionId, optionLabel);
- if (activeQuestion?.multiSelect) {
+ // Look up the question by id rather than using `activeQuestion` from the closure.
+ // `activeQuestion` can be stale when the React Compiler memoises the callback before
+ // useEffectEvent's ref.impl is refreshed after a question transition.
+ const clickedQuestion = prompt.questions.find((q) => q.id === questionId);
+ if (clickedQuestion?.multiSelect) {
return;
}
if (autoAdvanceTimerRef.current !== null) {
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 4c9d9df..5d12418 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -228,9 +228,9 @@ function channelIsComplete(
): boolean {
return Boolean(
channel &&
- channel.baseUrl.trim().length > 0 &&
- channelHasModel(channel) &&
- channelHasRequiredSecrets(channel, statuses),
+ channel.baseUrl.trim().length > 0 &&
+ channelHasModel(channel) &&
+ channelHasRequiredSecrets(channel, statuses),
);
}
@@ -739,9 +739,10 @@ function SettingsRouteView() {
});
}, [agentConfigStatusQuery.data?.agents, agentConfigStatusQuery.isError]);
const enabledChannelCount =
- gatewayConfigQuery.data?.channels.filter((channel) =>
- channel.enabled &&
- channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
+ gatewayConfigQuery.data?.channels.filter(
+ (channel) =>
+ channel.enabled &&
+ channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
).length ?? 0;
const updateGatewayConfigMutation = useMutation({
mutationFn: async (patch: GatewayConfigPatch) => {
@@ -2626,11 +2627,32 @@ function SettingsRouteView() {
{[
- { label: "Root", url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, "") },
- { label: "Chat", url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(/\?token=.*/u, "") },
- { label: "Models", url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, "") },
- { label: "Responses", url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(/\?token=.*/u, "") },
- { label: "Anthropic", url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, "") },
+ {
+ label: "Root",
+ url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, ""),
+ },
+ {
+ label: "Chat",
+ url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(
+ /\?token=.*/u,
+ "",
+ ),
+ },
+ {
+ label: "Models",
+ url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, ""),
+ },
+ {
+ label: "Responses",
+ url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(
+ /\?token=.*/u,
+ "",
+ ),
+ },
+ {
+ label: "Anthropic",
+ url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, ""),
+ },
].map((ep) => (
Agent Setup
- 将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code 经网关协议转换;OpenCode /
- Kilo / Cursor / pi / Cline 经 OpenAI 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
+ 将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code
+ 经网关协议转换;OpenCode / Kilo / Cursor / pi / Cline 经 OpenAI
+ 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
{agentConfigStatusQuery.isError ? (
@@ -2795,7 +2818,8 @@ function SettingsRouteView() {
if (checked && !channelIsComplete(serverChannel, channelSecrets)) {
toastManager.add({
title: "渠道配置未完成",
- description: "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
+ description:
+ "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
type: "info",
});
return;
@@ -3686,7 +3710,8 @@ function GatewayChannelCard(props: {
const totalSecrets = props.serverChannel?.secrets.length ?? 0;
const configuredSecrets = props.secretsStatus.filter((s) => s.hasApiKey).length;
const hasAllSecrets =
- Boolean(props.serverChannel) && channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
+ Boolean(props.serverChannel) &&
+ channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
const hasModel = channelHasModel(props.serverChannel);
const hasBaseUrl = Boolean(props.serverChannel?.baseUrl.trim());
const canEnable = Boolean(props.serverChannel) && hasAllSecrets && hasModel && hasBaseUrl;
@@ -3871,9 +3896,7 @@ function ChannelModelsEditor(props: {
{props.models.length === 0 ? (
-
- 未配置模型,将使用渠道默认模型字段。
-
+
未配置模型,将使用渠道默认模型字段。
) : (
{props.models.map((model, index) => (
@@ -3920,7 +3943,12 @@ function ChannelModelsEditor(props: {
placeholder="模型 id,如 deepseek-reasoner"
className="h-7 flex-1 text-xs"
/>
-
@@ -3938,7 +3966,10 @@ function ChannelAgentMappingsEditor(props: {
const options = props.models;
// Builds a mappings object that omits empty values, so we never assign
// `undefined` to an optional field (forbidden by exactOptionalPropertyTypes).
- const buildMappings = (agent: "codex" | "claude", value: string): {
+ const buildMappings = (
+ agent: "codex" | "claude",
+ value: string,
+ ): {
codex?: string;
claude?: string;
} => {
@@ -3949,11 +3980,7 @@ function ChannelAgentMappingsEditor(props: {
if (trimmed) next[agent] = trimmed;
return next;
};
- const renderSelect = (
- agent: "codex" | "claude",
- label: string,
- description: string,
- ) => {
+ const renderSelect = (agent: "codex" | "claude", label: string, description: string) => {
const current = props.mappings[agent];
return (
@@ -4027,7 +4054,7 @@ function ChannelSecretsRow(props: {
label={def.label}
sensitive={def.sensitive}
hasApiKey={hasApiKey}
- disabled={props.disabled}
+ disabled={props.disabled ?? false}
onSet={(value) => props.onSetSecret(def.id, value)}
onRemove={() => props.onRemoveSecret(def.id)}
/>