- No ACP runtimes found. Make sure an agent runtime (e.g. Goose)
- is installed.
+ {runtimes.length === 0
+ ? "No ACP runtimes found. Make sure an agent runtime (e.g. Goose) is installed."
+ : "No enabled fallback runtime is available. Turn on a harness to deploy runtime-less team members."}
) : null}
@@ -300,7 +316,7 @@ export function AddTeamToChannelDialog({
disabled={
!team ||
!selectedChannel ||
- !defaultProvider ||
+ !canResolveTeamRuntimes ||
resolved.length === 0 ||
missingPersonaCount > 0 ||
channelsQuery.isLoading ||
diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx
index 1bd8af8976..834c034f31 100644
--- a/desktop/src/features/agents/ui/AgentConfigFields.tsx
+++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx
@@ -31,8 +31,8 @@ import {
} from "@/features/agents/ui/bakedEnvHelpers";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
- BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
+ getPersonaHiddenProviderIds,
getPersonaProviderOptions,
getProviderApiKeyEnvVar,
runtimeSupportsLlmProviderSelection,
@@ -585,21 +585,16 @@ export function AgentConfigFields({
onConfigChange({ ...config, env_vars: merged });
}
- // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
- // migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
- // for new selections; OSS builds show it.
- const hideProviderIds = React.useMemo(() => {
- const hidden = new Set();
- if (bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER")) {
- for (const providerId of BLOCK_BUILD_HIDDEN_PROVIDER_IDS) {
- hidden.add(providerId);
- }
- }
- if (selectedRuntimeId !== "buzz-agent") {
- hidden.add("relay-mesh");
- }
- return hidden;
- }, [bakedEnvKeys, selectedRuntimeId]);
+ const hideProviderIds = React.useMemo(
+ () =>
+ getPersonaHiddenProviderIds({
+ bakedEnvKeys,
+ selectableRuntimes: selectedRuntime ? [selectedRuntime] : [],
+ currentRuntime: selectedRuntime,
+ preserveCurrentRuntime: false,
+ }),
+ [bakedEnvKeys, selectedRuntime],
+ );
const providerOptions = getPersonaProviderOptions(
providerValue,
credentialRuntimeId,
diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx
index 69e8b3a5b4..e5325d6faf 100644
--- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx
+++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx
@@ -27,6 +27,7 @@ import {
import {
formatRuntimeOptionLabel,
getDefaultPersonaRuntime,
+ reconcilePreferredRuntimeFallback,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
resetConfigForHarnessChange,
@@ -39,6 +40,7 @@ import {
} from "@/features/agents/ui/AgentConfigFields";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
+import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference";
type SaveState = "idle" | "saving" | "saved" | "error";
@@ -135,9 +137,10 @@ export function AgentDefaultsEditor({
}, []);
const runtimesQuery = useAcpRuntimesQuery();
+ const selectableRuntimes = useSelectableAcpRuntimes(runtimesQuery.data ?? []);
const sortedRuntimes = React.useMemo(
- () => sortPersonaRuntimes(runtimesQuery.data ?? []),
- [runtimesQuery.data],
+ () => sortPersonaRuntimes(selectableRuntimes),
+ [selectableRuntimes],
);
// An unset preferred runtime uses the same Buzz Agent-first fallback as
// deployment. The rendered draft below carries that fallback forward so the
@@ -152,16 +155,14 @@ export function AgentDefaultsEditor({
sortedRuntimes[0]
);
}, [config.preferred_runtime, sortedRuntimes]);
- const renderedConfig = React.useMemo(
- () =>
- config.preferred_runtime || !selectedRuntime
- ? config
- : { ...config, preferred_runtime: selectedRuntime.id },
- [config, selectedRuntime],
- );
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(
selectedRuntime?.id ?? "",
);
+ const effectiveConfig = React.useMemo(
+ () =>
+ reconcilePreferredRuntimeFallback(config, selectedRuntime?.id ?? null),
+ [config, selectedRuntime],
+ );
const harnessOptions = React.useMemo(
() =>
sortedRuntimes.map((runtime) => ({
@@ -185,7 +186,7 @@ export function AgentDefaultsEditor({
}
function handleHarnessChange(runtimeId: string) {
- handleConfigChange(resetConfigForHarnessChange(config, runtimeId));
+ handleConfigChange(resetConfigForHarnessChange(effectiveConfig, runtimeId));
setConfigIsValid(false);
setIsCustomModelEditing(false);
setIsCustomProvider(false);
@@ -194,7 +195,11 @@ export function AgentDefaultsEditor({
async function handleSave() {
// Snapshot the config being submitted so we can detect edits that arrive
// during the IPC round-trip and avoid clobbering the user's newer input.
- const submittedConfig = config;
+ const draftAtSubmit = configRef.current;
+ const submittedConfig = reconcilePreferredRuntimeFallback(
+ draftAtSubmit,
+ selectedRuntime?.id ?? null,
+ );
onSavingChange?.(true);
setSaveState("saving");
setSaveError(null);
@@ -204,7 +209,7 @@ export function AgentDefaultsEditor({
// IPC window. If the user edited, keep their newer value and leave dirty=true
// so they can save again. setDirty(false) runs inside the updater so both
// state updates batch into the same render (React 18 automatic batching).
- const savedCurrentDraft = configRef.current === submittedConfig;
+ const savedCurrentDraft = configRef.current === draftAtSubmit;
setConfig((current) => {
if (!savedCurrentDraft) {
// Mid-flight edit detected — do not overwrite newer user input.
@@ -240,7 +245,7 @@ export function AgentDefaultsEditor({
void;
- onSubmit: (
- input: CreatePersonaInput | UpdatePersonaInput,
- ) => Promise;
- /** Rendered below the form fields in create mode only ("Where to run"). */
- createRunSection?: React.ReactNode;
- /** Extra create-mode submit gate (e.g. incomplete provider config). */
- createSubmitBlocked?: boolean;
-};
-
-const ADVANCED_FIELDS_MOTION_TRANSITION = {
- duration: 0.18,
- ease: [0.23, 1, 0.32, 1],
-} as const;
+import {
+ useSelectableAcpRuntimes,
+ visibleAcpRuntimeSeedForCreate,
+} from "../lib/runtimeVisibilityPreference";
+import type { AgentDefinitionDialogProps } from "./AgentDefinitionDialog.types";
+import { ADVANCED_FIELDS_MOTION_TRANSITION } from "./agentAdvancedFieldsMotion";
export function AgentDefinitionDialog({
open,
@@ -141,18 +119,11 @@ export function AgentDefinitionDialog({
const [behaviorDraft, setBehaviorDraft] = React.useState(
emptyPersonaBehaviorDraft,
);
- // The seed the draft is diffed against at submit: an untouched quad
- // submits no behavior group, keeping unrelated edits hash-quiet.
+ // Untouched behavior fields submit no group, keeping edits hash-quiet.
const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft);
- // Tracks when the runtime was auto-seeded by the default-runtime effect in
- // edit mode (i.e. the user never explicitly chose a runtime). Used to omit
- // the seeded runtime from the submit payload for builtin definitions whose
- // canonical runtime is null — the sync would revert it anyway.
+ // Lets edit-mode builtin definitions omit an untouched auto-seeded runtime.
const isRuntimeAutoSeededRef = React.useRef(false);
- // Guards the seeding effect so it fires at most once per dialog-open.
- // Without this, clearing runtime back to "" via "No preference" would re-
- // trigger the effect (the `runtime` dep would pass the length guard) and
- // snap the dropdown back to the default — an edit-mode regression.
+ // Prevent "No preference" from snapping back to the default.
const hasSeededForOpenRef = React.useRef(false);
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
@@ -164,10 +135,11 @@ export function AgentDefinitionDialog({
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
- } = useAgentDialogDefaults({ open });
- const defaultRuntime = React.useMemo(
- () => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
- [globalConfig.preferred_runtime, runtimes],
+ } = useDefinitionAgentDialogDefaults(initialValues, open);
+ const selectableRuntimes = useSelectableAcpRuntimes(runtimes);
+ const defaultRuntime = getDefaultPersonaRuntime(
+ selectableRuntimes,
+ globalConfig.preferred_runtime,
);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const shouldReduceMotion = useReducedMotion();
@@ -215,6 +187,38 @@ export function AgentDefinitionDialog({
hasSeededForOpenRef.current = false;
}, [initialValues, open]);
+ React.useEffect(() => {
+ if (!open || !initialValues || "id" in initialValues || runtimesLoading) {
+ return;
+ }
+ const seededRuntime = initialValues.runtime?.trim() ?? "";
+ const nextRuntime = visibleAcpRuntimeSeedForCreate(
+ seededRuntime,
+ selectableRuntimes,
+ defaultRuntime?.id,
+ );
+ if (
+ !seededRuntime ||
+ runtime.trim() !== seededRuntime ||
+ nextRuntime === seededRuntime
+ ) {
+ return;
+ }
+ setRuntime(nextRuntime);
+ setModel("");
+ setProvider("");
+ setAiConfigurationMode("defaults");
+ setIsCustomModelEditing(false);
+ setIsCustomProviderEditing(false);
+ }, [
+ defaultRuntime?.id,
+ initialValues,
+ open,
+ runtime,
+ runtimesLoading,
+ selectableRuntimes,
+ ]);
+
React.useEffect(() => {
if (
!open ||
@@ -231,10 +235,7 @@ export function AgentDefinitionDialog({
setRuntime(defaultRuntime.id);
hasSeededForOpenRef.current = true;
if ("id" in initialValues) {
- // Edit mode: record that this runtime was auto-seeded so the submit path
- // can omit it from the payload for builtin definitions (canonical runtime
- // null; sync would revert the value anyway). Explicit user changes via
- // the dropdown clear this flag.
+ // Builtin definitions omit this untouched inferred runtime on submit.
isRuntimeAutoSeededRef.current = true;
}
}, [defaultRuntime, initialValues, open, runtime, runtimesLoading]);
@@ -296,16 +297,14 @@ export function AgentDefinitionDialog({
behaviorSeedRef.current = emptyPersonaBehaviorDraft;
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
- // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
- // [initialValues, open] effect resets both when the dialog re-opens.
+ // The open-seeding effect resets both refs on the next open.
}
onOpenChange(next);
}
async function handleSubmit() {
- // D1: the same localModeSatisfied gate as canSubmit prevents form-submit
- // (Enter) from bypassing a missing credential.
+ // Keep Enter submission on the same credential gate as the button.
if (!initialValues || !localModeSatisfied || !canSubmit) return;
const {
@@ -372,11 +371,7 @@ export function AgentDefinitionDialog({
(runtime.trim().length > 0 && runtimeCanChooseLlmProvider) ||
blankRuntimeModelProviderEditable;
const trimmedProvider = provider.trim();
- // Required credential env keys for this runtime + provider combination.
- // Used to show required markers on the LLM provider label and amber
- // locked rows in the env vars editor.
- // File-layer config for the selected runtime (e.g. goose config.yaml).
- // Used to silence requirements already satisfied there.
+ // File config satisfies credentials before the readiness gate renders them.
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, {
enabled: open,
});
@@ -457,12 +452,6 @@ export function AgentDefinitionDialog({
const modelFieldVisible =
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
const isExplicitModelRequired = aiConfigurationMode === "custom";
- // Gate the provider requirement on the field's actual visibility, not the raw
- // runtime capability. Codex/Claude hide the provider picker (they drive their
- // own provider), so Customize must not require a provider there. But a
- // runtime-less legacy/builtin definition still exposes the picker via
- // blankRuntimeModelProviderEditable, so it must keep requiring a provider —
- // otherwise Save could persist `provider: undefined` despite the visible field.
const customAiPairSatisfied = agentAiConfigurationModeSatisfied(
aiConfigurationMode,
{ provider, model },
@@ -471,8 +460,7 @@ export function AgentDefinitionDialog({
const selectedRuntimeIsAvailable =
runtime.trim().length === 0 ||
selectedRuntime?.availability === "available";
- // Gate model/provider validity through missingNormalizedFields — single
- // source of truth with the readiness gate so display and Save can't drift.
+ // Keep model/provider validity aligned with the readiness gate.
const canSubmit =
canSubmitPersonaDialog({ displayName, isPending }) &&
(!isCreateMode || runtime.trim().length > 0) &&
@@ -527,17 +515,12 @@ export function AgentDefinitionDialog({
modelFieldVisible,
provider: effectiveProvider,
});
- // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
- // migration rewrites any persisted Databricks v1 values → v2. Hide the v1
- // option there so it is not offered for new selections. OSS builds have no
- // baked provider, so v1 remains visible.
- const hideProviderIds = React.useMemo(
- () =>
- (bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER")
- ? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
- : new Set(),
- [bakedEnvKeys],
- );
+ const hideProviderIds = getPersonaHiddenProviderIds({
+ bakedEnvKeys: bakedEnvKeys ?? [],
+ selectableRuntimes,
+ currentRuntime: selectedRuntime,
+ preserveCurrentRuntime: !isCreateMode,
+ });
const providerOptions = getPersonaProviderOptions(
trimmedProvider,
runtime,
@@ -553,8 +536,8 @@ export function AgentDefinitionDialog({
llmProviderFieldVisible && isCustomProviderEditing;
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
const sortedRuntimes = React.useMemo(
- () => sortPersonaRuntimes(runtimes),
- [runtimes],
+ () => sortPersonaRuntimes(selectableRuntimes),
+ [selectableRuntimes],
);
const blankRuntimeOptionLabel = runtimesLoading
? "Loading harnesses..."
@@ -582,11 +565,12 @@ export function AgentDefinitionDialog({
})),
];
if (
+ !isCreateMode &&
runtime.trim().length > 0 &&
!runtimeDropdownOptions.some((option) => option.value === runtime)
) {
runtimeDropdownOptions.push({
- label: `${runtime.trim()} (current)`,
+ label: formatCurrentRuntimeOptionLabel(runtimes, runtime),
value: runtime.trim(),
});
}
@@ -699,11 +683,16 @@ export function AgentDefinitionDialog({
function handleProviderDropdownChange(nextValue: string) {
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
- if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") {
- handleRuntimeDropdownChange("buzz-agent");
+ const relayMeshRuntime =
+ nextProvider === "relay-mesh"
+ ? getRelayMeshRuntime(selectableRuntimes, selectedRuntime)
+ : null;
+ const nextRuntime = relayMeshRuntime?.id ?? runtime;
+ if (nextRuntime !== runtime) {
+ handleRuntimeDropdownChange(nextRuntime);
}
const nextSelection = selectionOnProviderDropdownChange(selection, {
- runtime: nextProvider === "relay-mesh" ? "buzz-agent" : runtime,
+ runtime: nextRuntime,
nextValue,
clearModelWhenApiKeyMissing: true,
});
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.types.ts b/desktop/src/features/agents/ui/AgentDefinitionDialog.types.ts
new file mode 100644
index 0000000000..99a211d315
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.types.ts
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+
+import type {
+ AcpRuntimeCatalogEntry,
+ CreatePersonaInput,
+ UpdatePersonaInput,
+} from "@/shared/api/types";
+
+export type AgentDefinitionDialogProps = {
+ open: boolean;
+ title: string;
+ description: string;
+ submitLabel: string;
+ initialValues: CreatePersonaInput | UpdatePersonaInput | null;
+ error: Error | null;
+ isPending: boolean;
+ runtimes: AcpRuntimeCatalogEntry[];
+ runtimesLoading?: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (
+ input: CreatePersonaInput | UpdatePersonaInput,
+ ) => Promise;
+ /** Rendered below the form fields in create mode only ("Where to run"). */
+ createRunSection?: ReactNode;
+ /** Extra create-mode submit gate (e.g. incomplete provider config). */
+ createSubmitBlocked?: boolean;
+};
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
index 5cd5c7a016..6d35a6c800 100644
--- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
@@ -14,11 +14,9 @@ import {
} from "@/features/agents/hooks";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import type {
- ManagedAgent,
RespondToMode,
UpdateManagedAgentInput,
} from "@/shared/api/types";
-import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
@@ -28,12 +26,15 @@ import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
- BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
+ formatCurrentRuntimeOptionLabel,
formatRuntimeOptionLabel,
getDefaultLlmModelLabel,
getDefaultPersonaRuntime,
+ getPersonaHiddenProviderIds,
getPersonaProviderOptions,
+ getRelayMeshRuntime,
+ getProviderApiKeyEnvVar,
isMissingRequiredDropdownField,
NO_RUNTIME_DROPDOWN_VALUE,
PERSONA_FIELD_CONTROL_CLASS,
@@ -76,18 +77,15 @@ import {
getBakedModelInheritLabel,
getBakedProviderInheritLabel,
} from "./bakedEnvHelpers";
-import { getProviderApiKeyEnvVar } from "./agentConfigOptions";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
+import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference";
import { resolveModelFieldStatusMessage } from "./agentConfigControls";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
-
-const ADVANCED_FIELDS_MOTION_TRANSITION = {
- duration: 0.18,
- ease: [0.23, 1, 0.32, 1],
-} as const;
+import { ADVANCED_FIELDS_MOTION_TRANSITION } from "./agentAdvancedFieldsMotion";
+import type { AgentInstanceEditDialogProps } from "./AgentInstanceEditDialog.types";
export function AgentInstanceEditDialog({
agent,
@@ -96,22 +94,13 @@ export function AgentInstanceEditDialog({
onEditLinkedPersona,
onOpenChange,
onUpdated,
-}: {
- agent: ManagedAgent;
- /** Optional field to scroll/focus when the dialog opens from a card deep-link. */
- initialFocus?: EditAgentFocusTarget;
- open: boolean;
- /** Present only when the linked definition is editable (non-built-in,
- * resolved). Caller closes this dialog and enters definition-edit. */
- onEditLinkedPersona?: () => void;
- onOpenChange: (open: boolean) => void;
- onUpdated?: (agent: ManagedAgent) => void;
-}) {
+}: AgentInstanceEditDialogProps) {
const updateMutation = useUpdateManagedAgentMutation();
const startMutation = useStartManagedAgentMutation();
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null);
const runtimes = runtimesQuery.data ?? [];
+ const selectableRuntimes = useSelectableAcpRuntimes(runtimes);
const [name, setName] = React.useState(agent.name);
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
@@ -216,8 +205,8 @@ export function AgentInstanceEditDialog({
// Build the sorted runtime catalog for the dropdown.
const sortedRuntimes = React.useMemo(
- () => sortPersonaRuntimes(runtimes),
- [runtimes],
+ () => sortPersonaRuntimes(selectableRuntimes),
+ [selectableRuntimes],
);
const selectedRuntime = React.useMemo(
@@ -241,12 +230,12 @@ export function AgentInstanceEditDialog({
!options.some((o) => o.value === selectedRuntimeId)
) {
options.push({
- label: `${selectedRuntimeId} (current)`,
+ label: formatCurrentRuntimeOptionLabel(runtimes, selectedRuntimeId),
value: selectedRuntimeId,
});
}
return options;
- }, [sortedRuntimes, selectedRuntimeId]);
+ }, [runtimes, sortedRuntimes, selectedRuntimeId]);
// Resolve the dialog-opening command as the catalog loads. Edit-state runtime
// ids mutate during selection changes and cannot identify the original state.
@@ -366,7 +355,11 @@ export function AgentInstanceEditDialog({
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
- } = useAgentDialogDefaults({ inheritedEnvVars, open });
+ } = useAgentDialogDefaults({
+ configScope: "existing",
+ inheritedEnvVars,
+ open,
+ });
// Runtime/provider-required credential state, derived from the PROSPECTIVE
// post-submit runtime — see the hook for the inherit-transition rationale.
@@ -536,14 +529,17 @@ export function AgentInstanceEditDialog({
function handleProviderDropdownChange(nextValue: string) {
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
- if (nextProvider === "relay-mesh" && selectedRuntimeId !== "buzz-agent") {
- handleRuntimeDropdownChange("buzz-agent");
+ const relayMeshRuntime =
+ nextProvider === "relay-mesh"
+ ? getRelayMeshRuntime(selectableRuntimes, selectedRuntime)
+ : null;
+ const nextRuntimeId =
+ relayMeshRuntime?.id ?? selectedRuntime?.id ?? selectedRuntimeId;
+ if (nextRuntimeId !== selectedRuntimeId) {
+ handleRuntimeDropdownChange(nextRuntimeId);
}
const nextSelection = selectionOnProviderDropdownChange(selection, {
- runtime:
- nextProvider === "relay-mesh"
- ? "buzz-agent"
- : (selectedRuntime?.id ?? selectedRuntimeId),
+ runtime: nextRuntimeId,
nextValue,
clearModelWhenApiKeyMissing: false,
});
@@ -778,13 +774,12 @@ export function AgentInstanceEditDialog({
// Provider field derived state
const trimmedProvider = provider.trim();
- const hideProviderIds = React.useMemo(
- () =>
- (bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER")
- ? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
- : new Set(),
- [bakedEnvKeys],
- );
+ const hideProviderIds = getPersonaHiddenProviderIds({
+ bakedEnvKeys: bakedEnvKeys ?? [],
+ selectableRuntimes,
+ currentRuntime: selectedRuntime,
+ preserveCurrentRuntime: true,
+ });
const providerOptions = getPersonaProviderOptions(
trimmedProvider,
selectedRuntime?.id ?? "",
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.types.ts b/desktop/src/features/agents/ui/AgentInstanceEditDialog.types.ts
new file mode 100644
index 0000000000..c7c16331cb
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.types.ts
@@ -0,0 +1,14 @@
+import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
+import type { ManagedAgent } from "@/shared/api/types";
+
+export type AgentInstanceEditDialogProps = {
+ agent: ManagedAgent;
+ /** Optional field to scroll/focus when the dialog opens from a card deep-link. */
+ initialFocus?: EditAgentFocusTarget;
+ open: boolean;
+ /** Present only when the linked definition is editable (non-built-in,
+ * resolved). Caller closes this dialog and enters definition-edit. */
+ onEditLinkedPersona?: () => void;
+ onOpenChange: (open: boolean) => void;
+ onUpdated?: (agent: ManagedAgent) => void;
+};
diff --git a/desktop/src/features/agents/ui/agentAdvancedFieldsMotion.ts b/desktop/src/features/agents/ui/agentAdvancedFieldsMotion.ts
new file mode 100644
index 0000000000..0a39b11fdd
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentAdvancedFieldsMotion.ts
@@ -0,0 +1,4 @@
+export const ADVANCED_FIELDS_MOTION_TRANSITION = {
+ duration: 0.18,
+ ease: [0.23, 1, 0.32, 1],
+} as const;
diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs
index d0690cefc3..a93e862f96 100644
--- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs
+++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs
@@ -2,9 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
+ formatCurrentRuntimeOptionLabel,
getDefaultPersonaRuntime,
+ getPersonaHiddenProviderIds,
getPersonaModelOptions,
getPersonaProviderOptions,
+ reconcilePreferredRuntimeFallback,
resetConfigForHarnessChange,
runtimeSupportsLlmProviderSelection,
} from "./agentConfigOptions.tsx";
@@ -12,17 +15,45 @@ import { formatModelDiscoveryErrorStatus } from "./personaModelDiscoveryStatus.t
// ── helpers ──────────────────────────────────────────────────────────────────
-function makeRuntime(id, availability = "available") {
+function makeRuntime(
+ id,
+ availability = "available",
+ providerEnvVar = id === "buzz-agent"
+ ? "BUZZ_AGENT_PROVIDER"
+ : id === "goose"
+ ? "GOOSE_PROVIDER"
+ : null,
+) {
return {
id,
label: id,
command: id,
defaultArgs: [],
mcpCommand: null,
+ providerEnvVar,
availability,
};
}
+test("formatCurrentRuntimeOptionLabel uses the catalog display label", () => {
+ const runtime = {
+ ...makeRuntime("goose"),
+ label: "Goose",
+ };
+
+ assert.equal(
+ formatCurrentRuntimeOptionLabel([runtime], "goose"),
+ "Goose (current)",
+ );
+});
+
+test("formatCurrentRuntimeOptionLabel falls back to an unknown runtime id", () => {
+ assert.equal(
+ formatCurrentRuntimeOptionLabel([], " custom-runtime "),
+ "custom-runtime (current)",
+ );
+});
+
// ── getPersonaProviderOptions — hideProviderIds ───────────────────────────────
test("getPersonaProviderOptions returns databricks v1 and v2 when hideProviderIds is empty", () => {
@@ -74,6 +105,58 @@ test("getPersonaProviderOptions appends (current) tail for an unknown saved prov
assert.equal(tail?.label, "my-custom-llm (current)");
});
+test("hidden Buzz Agent suppresses shared compute for new selections", () => {
+ const hidden = getPersonaHiddenProviderIds({
+ bakedEnvKeys: [],
+ selectableRuntimes: [makeRuntime("goose")],
+ currentRuntime: makeRuntime("goose"),
+ preserveCurrentRuntime: false,
+ });
+ const ids = getPersonaProviderOptions("", "goose", "", hidden).map(
+ (option) => option.id,
+ );
+ assert.ok(!ids.includes("relay-mesh"));
+});
+
+test("an existing hidden Buzz Agent keeps its shared compute provider", () => {
+ const hidden = getPersonaHiddenProviderIds({
+ bakedEnvKeys: [],
+ selectableRuntimes: [makeRuntime("goose")],
+ currentRuntime: makeRuntime("buzz-agent"),
+ preserveCurrentRuntime: true,
+ });
+ const ids = getPersonaProviderOptions("", "buzz-agent", "", hidden).map(
+ (option) => option.id,
+ );
+ assert.ok(ids.includes("relay-mesh"));
+});
+
+test("shared compute visibility follows runtime catalog metadata instead of runtime ids", () => {
+ const hidden = getPersonaHiddenProviderIds({
+ bakedEnvKeys: [],
+ selectableRuntimes: [
+ makeRuntime("future-shared-compute", "available", "BUZZ_AGENT_PROVIDER"),
+ ],
+ preserveCurrentRuntime: false,
+ });
+ const ids = getPersonaProviderOptions(
+ "",
+ "future-shared-compute",
+ "",
+ hidden,
+ ).map((option) => option.id);
+ assert.ok(ids.includes("relay-mesh"));
+
+ const renamedCapability = getPersonaHiddenProviderIds({
+ bakedEnvKeys: [],
+ selectableRuntimes: [
+ makeRuntime("buzz-agent", "available", "GOOSE_PROVIDER"),
+ ],
+ preserveCurrentRuntime: false,
+ });
+ assert.ok(renamedCapability.has("relay-mesh"));
+});
+
// ── getDefaultPersonaRuntime — buzz-agent first ───────────────────────────────
test("getDefaultPersonaRuntime honors an available global preference", () => {
@@ -189,6 +272,37 @@ test("resetConfigForHarnessChange does not carry relay mesh to Goose", () => {
assert.equal(resetConfigForHarnessChange(config, "goose").provider, null);
});
+test("reconcilePreferredRuntimeFallback updates a hidden saved default", () => {
+ const config = {
+ env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high", SHARED: "kept" },
+ provider: "anthropic",
+ model: "claude-opus",
+ preferred_runtime: "goose",
+ };
+
+ assert.deepEqual(reconcilePreferredRuntimeFallback(config, "buzz-agent"), {
+ env_vars: { SHARED: "kept" },
+ provider: "anthropic",
+ model: null,
+ preferred_runtime: "buzz-agent",
+ });
+ assert.equal(reconcilePreferredRuntimeFallback(config, "goose"), config);
+});
+
+test("reconcilePreferredRuntimeFallback persists an unsaved displayed fallback", () => {
+ const config = {
+ env_vars: { SHARED: "kept" },
+ provider: "relay-mesh",
+ model: "auto",
+ preferred_runtime: null,
+ };
+
+ assert.deepEqual(reconcilePreferredRuntimeFallback(config, "buzz-agent"), {
+ ...config,
+ preferred_runtime: "buzz-agent",
+ });
+});
+
// ── getPersonaModelOptions — codex/claude do not use global provider ──────────
//
// The discovery call in AgentDefinitionDialog passes
diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx
index 6ae81ff6cb..f31a20441d 100644
--- a/desktop/src/features/agents/ui/agentConfigOptions.tsx
+++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx
@@ -14,15 +14,55 @@ export { getDefaultPersonaRuntime } from "../lib/resolvePersonaRuntime";
* offering it for new selections would create a regression path.
* OSS builds pass an empty `Set` so v1 remains visible.
*
- * All three dialog sites that show a provider picker import this constant —
- * `AgentDefinitionDialog`, `AgentInstanceEditDialog`, and
- * `AgentDefaultsSettingsCard` — making it the single source of truth for
- * which provider ids to suppress on Block builds.
+ * Provider pickers consume this directly or through
+ * `getPersonaHiddenProviderIds`, keeping one source of truth for Block builds.
*/
export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet = new Set([
"databricks",
]);
+type RuntimeProviderCapability = Pick<
+ AcpRuntimeCatalogEntry,
+ "id" | "providerEnvVar"
+>;
+
+export function runtimeSupportsRelayMesh(
+ runtime: RuntimeProviderCapability | null | undefined,
+): boolean {
+ return runtime?.providerEnvVar === "BUZZ_AGENT_PROVIDER";
+}
+
+export function getRelayMeshRuntime(
+ selectableRuntimes: readonly T[],
+ currentRuntime?: T | null,
+): T | null {
+ if (runtimeSupportsRelayMesh(currentRuntime)) {
+ return currentRuntime ?? null;
+ }
+ return selectableRuntimes.find(runtimeSupportsRelayMesh) ?? null;
+}
+
+export function getPersonaHiddenProviderIds({
+ bakedEnvKeys,
+ selectableRuntimes,
+ currentRuntime,
+ preserveCurrentRuntime,
+}: {
+ bakedEnvKeys: readonly string[];
+ selectableRuntimes: readonly RuntimeProviderCapability[];
+ currentRuntime?: RuntimeProviderCapability | null;
+ preserveCurrentRuntime: boolean;
+}): ReadonlySet {
+ const hidden = bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER")
+ ? new Set(BLOCK_BUILD_HIDDEN_PROVIDER_IDS)
+ : new Set();
+ const relayMeshSelectable =
+ selectableRuntimes.some(runtimeSupportsRelayMesh) ||
+ (preserveCurrentRuntime && runtimeSupportsRelayMesh(currentRuntime));
+ if (!relayMeshSelectable) hidden.add("relay-mesh");
+ return hidden;
+}
+
export const PERSONA_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
export const PERSONA_FIELD_CONTROL_CLASS =
@@ -198,6 +238,29 @@ export function resetConfigForHarnessChange(
};
}
+/**
+ * Align a missing, stale, or hidden saved preference with the shown fallback.
+ * A missing preference adopts the current context without clearing its draft;
+ * switching away from a saved harness clears values that are not portable.
+ */
+export function reconcilePreferredRuntimeFallback(
+ config: GlobalAgentConfig,
+ fallbackRuntimeId: string | null,
+): GlobalAgentConfig {
+ if (!fallbackRuntimeId || config.preferred_runtime === fallbackRuntimeId) {
+ return config;
+ }
+
+ if (!config.preferred_runtime) {
+ return {
+ ...config,
+ preferred_runtime: fallbackRuntimeId,
+ };
+ }
+
+ return resetConfigForHarnessChange(config, fallbackRuntimeId);
+}
+
function effectiveModelProviderForOptions(
runtimeId: string,
providerId: string | null | undefined,
@@ -426,6 +489,18 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) {
return `${runtime.label}${suffix}`;
}
+export function formatCurrentRuntimeOptionLabel(
+ runtimes: readonly AcpRuntimeCatalogEntry[],
+ runtimeId: string,
+) {
+ const trimmedRuntimeId = runtimeId.trim();
+ const runtime = runtimes.find(
+ (candidate) => candidate.id === trimmedRuntimeId,
+ );
+ const label = runtime ? formatRuntimeOptionLabel(runtime) : trimmedRuntimeId;
+ return `${label} (current)`;
+}
+
function runtimeAvailabilitySortRank(
availability: AcpRuntimeCatalogEntry["availability"],
) {
diff --git a/desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs b/desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs
new file mode 100644
index 0000000000..12e0a02b2d
--- /dev/null
+++ b/desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ agentDefinitionConfigScope,
+ resolveAgentDialogGlobalConfig,
+} from "./useAgentDialogDefaults.ts";
+
+const persistedConfig = {
+ env_vars: { SHARED: "kept" },
+ provider: "relay-mesh",
+ model: "auto",
+ preferred_runtime: "buzz-agent",
+};
+
+test("create-mode defaults mask values owned by a hidden harness", () => {
+ assert.deepEqual(
+ resolveAgentDialogGlobalConfig(persistedConfig, "implicit", ["buzz-agent"]),
+ {
+ env_vars: { SHARED: "kept" },
+ provider: null,
+ model: null,
+ preferred_runtime: null,
+ },
+ );
+});
+
+test("existing edit defaults preserve values owned by a hidden harness", () => {
+ assert.equal(
+ resolveAgentDialogGlobalConfig(persistedConfig, "existing", ["buzz-agent"]),
+ persistedConfig,
+ );
+});
+
+test("definition dialogs select config scope from create versus edit values", () => {
+ assert.equal(agentDefinitionConfigScope(null), "implicit");
+ assert.equal(agentDefinitionConfigScope({ displayName: "New" }), "implicit");
+ assert.equal(
+ agentDefinitionConfigScope({ id: "existing", displayName: "Existing" }),
+ "existing",
+ );
+});
diff --git a/desktop/src/features/agents/ui/useAgentDialogDefaults.ts b/desktop/src/features/agents/ui/useAgentDialogDefaults.ts
index 5ede9558fe..f9fbbd31c7 100644
--- a/desktop/src/features/agents/ui/useAgentDialogDefaults.ts
+++ b/desktop/src/features/agents/ui/useAgentDialogDefaults.ts
@@ -1,18 +1,57 @@
import * as React from "react";
+import type {
+ CreatePersonaInput,
+ GlobalAgentConfig,
+ UpdatePersonaInput,
+} from "@/shared/api/types";
import { useBakedBuildEnvQuery } from "../hooks";
+import {
+ maskDisabledAcpRuntimePreference,
+ useDisabledAcpRuntimeIds,
+} from "../lib/runtimeVisibilityPreference";
import { useGlobalAgentConfig } from "../useGlobalAgentConfig";
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
import { getInheritedAgentDefaults } from "./bakedEnvHelpers";
+export type AgentDialogConfigScope = "existing" | "implicit";
+
+export function agentDefinitionConfigScope(
+ initialValues: CreatePersonaInput | UpdatePersonaInput | null,
+): AgentDialogConfigScope {
+ return initialValues && "id" in initialValues ? "existing" : "implicit";
+}
+
+export function resolveAgentDialogGlobalConfig(
+ persistedConfig: GlobalAgentConfig,
+ configScope: AgentDialogConfigScope,
+ disabledRuntimeIds: readonly string[],
+): GlobalAgentConfig {
+ return configScope === "implicit"
+ ? maskDisabledAcpRuntimePreference(persistedConfig, disabledRuntimeIds)
+ : persistedConfig;
+}
+
export function useAgentDialogDefaults({
+ configScope,
inheritedEnvVars = {},
open,
}: {
+ configScope: AgentDialogConfigScope;
inheritedEnvVars?: Record;
open: boolean;
}) {
- const { globalConfig } = useGlobalAgentConfig();
+ const { globalConfig: persistedConfig } = useGlobalAgentConfig();
+ const disabledRuntimeIds = useDisabledAcpRuntimeIds();
+ const globalConfig = React.useMemo(
+ () =>
+ resolveAgentDialogGlobalConfig(
+ persistedConfig,
+ configScope,
+ disabledRuntimeIds,
+ ),
+ [configScope, disabledRuntimeIds, persistedConfig],
+ );
const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: open });
const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv);
const effectiveInheritedEnvVars = React.useMemo(
@@ -31,3 +70,13 @@ export function useAgentDialogDefaults({
inheritedEnvVars: effectiveInheritedEnvVars,
};
}
+
+export function useDefinitionAgentDialogDefaults(
+ initialValues: CreatePersonaInput | UpdatePersonaInput | null,
+ open: boolean,
+) {
+ return useAgentDialogDefaults({
+ configScope: agentDefinitionConfigScope(initialValues),
+ open,
+ });
+}
diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts
index 4eb9b6ed03..31fff91cee 100644
--- a/desktop/src/features/agents/ui/useManagedAgentActions.ts
+++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts
@@ -12,7 +12,7 @@ import {
useStopManagedAgentMutation,
useDeleteManagedAgentMutation,
} from "@/features/agents/hooks";
-import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
+import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import type {
@@ -36,7 +36,7 @@ import {
} from "../lib/instanceInputForDefinition";
export function useManagedAgentActions() {
- const { globalConfig } = useGlobalAgentConfig();
+ const { globalConfig } = useImplicitGlobalAgentConfig();
const relayAgentsQuery = useRelayAgentsQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false);
diff --git a/desktop/src/features/agents/useGlobalAgentConfig.ts b/desktop/src/features/agents/useGlobalAgentConfig.ts
index 4b90beb43d..7b5e09191c 100644
--- a/desktop/src/features/agents/useGlobalAgentConfig.ts
+++ b/desktop/src/features/agents/useGlobalAgentConfig.ts
@@ -9,8 +9,14 @@
* On fetch error the query falls back to EMPTY_CONFIG (safe — the absence of
* a global config is never an error state for callers).
*/
+import * as React from "react";
+
import { useQuery } from "@tanstack/react-query";
+import {
+ maskDisabledAcpRuntimePreference,
+ useDisabledAcpRuntimeIds,
+} from "@/features/agents/lib/runtimeVisibilityPreference";
import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig";
import type { GlobalAgentConfig } from "@/shared/api/types";
@@ -42,3 +48,27 @@ export function useGlobalAgentConfig(): {
isLoading: isPending,
};
}
+
+/**
+ * Load global defaults for a new implicit runtime choice.
+ *
+ * Existing agent edit surfaces must use useGlobalAgentConfig so a hidden
+ * harness can still inherit its persisted provider and model. Start paths use
+ * this hook to ignore a hidden preferred harness and its dependent defaults.
+ */
+export function useImplicitGlobalAgentConfig(): {
+ globalConfig: GlobalAgentConfig;
+ isLoading: boolean;
+} {
+ const { globalConfig, isLoading } = useGlobalAgentConfig();
+ const disabledRuntimeIds = useDisabledAcpRuntimeIds();
+ const implicitGlobalConfig = React.useMemo(
+ () => maskDisabledAcpRuntimePreference(globalConfig, disabledRuntimeIds),
+ [disabledRuntimeIds, globalConfig],
+ );
+
+ return {
+ globalConfig: implicitGlobalConfig,
+ isLoading,
+ };
+}
diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts
index 1f7d066fad..537ca66f7b 100644
--- a/desktop/src/features/channel-templates/useApplyTemplate.ts
+++ b/desktop/src/features/channel-templates/useApplyTemplate.ts
@@ -9,7 +9,7 @@ import {
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
-import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
+import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import { useLastRuntime } from "@/features/agents/lib/useLastRuntime";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
@@ -72,11 +72,6 @@ export function useApplyTemplate() {
const runtimes = acpRuntimesQuery.data ?? [];
if (runtimes.length === 0) return; // No runtimes — skip silently
- // Resolve default provider: user's last-used preference, or first available
- const defaultProvider =
- runtimes.find((p) => p.id === lastRuntimeId) ?? runtimes[0] ?? null;
- if (!defaultProvider) return;
-
const seenPersonaIds = new Set();
const inputs: CreateChannelManagedAgentInput[] = [];
@@ -86,15 +81,18 @@ export function useApplyTemplate() {
if (!persona) continue;
if (seenPersonaIds.has(persona.id)) continue;
seenPersonaIds.add(persona.id);
- const resolved = resolvePersonaRuntime(
- entry.runtime ?? persona.runtime,
+ const requestedRuntimeId = entry.runtime ?? persona.runtime;
+ const resolved = resolveProvisioningRuntimeForDefinition(
+ requestedRuntimeId,
runtimes,
- defaultProvider,
+ lastRuntimeId,
);
+ if (!resolved.runtime) continue;
inputs.push({
- runtime: resolved.runtime ?? defaultProvider,
+ runtime: resolved.runtime,
name: persona.displayName,
personaId: persona.id,
+ harnessOverride: resolved.harnessOverride,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: entry.model ?? persona.model ?? undefined,
@@ -111,15 +109,18 @@ export function useApplyTemplate() {
for (const persona of resolvedPersonas) {
if (seenPersonaIds.has(persona.id)) continue;
seenPersonaIds.add(persona.id);
- const resolved = resolvePersonaRuntime(
- teamEntry.runtime ?? persona.runtime,
+ const requestedRuntimeId = teamEntry.runtime ?? persona.runtime;
+ const resolved = resolveProvisioningRuntimeForDefinition(
+ requestedRuntimeId,
runtimes,
- defaultProvider,
+ lastRuntimeId,
);
+ if (!resolved.runtime) continue;
inputs.push({
- runtime: resolved.runtime ?? defaultProvider,
+ runtime: resolved.runtime,
name: persona.displayName,
personaId: persona.id,
+ harnessOverride: resolved.harnessOverride,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: teamEntry.model ?? persona.model ?? undefined,
diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
index fb27c659d5..cd8e8beb80 100644
--- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
+++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
@@ -8,7 +8,8 @@ import {
type CreateChannelManagedAgentResult,
} from "@/features/agents/hooks";
import { getActivePersonas } from "@/features/agents/lib/catalog";
-import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
+import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition";
+import { canResolveAllPersonaRuntimes } from "@/features/agents/lib/resolvePersonaRuntime";
import { getUsableTeams } from "@/features/agents/lib/teamPersonas";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection";
@@ -135,26 +136,33 @@ export function AddChannelBotDialog({
}
async function handleSubmit() {
- if (providers.length === 0 || selectedPersonas.length === 0) return;
+ if (
+ providers.length === 0 ||
+ selectedPersonas.length === 0 ||
+ !selectedPersonasResolvable
+ ) {
+ return;
+ }
- const inputs = selectedPersonas.map((persona) => {
- const resolved = resolvePersonaRuntime(
+ const inputs = selectedPersonas.flatMap((persona) => {
+ const resolved = resolveProvisioningRuntimeForDefinition(
persona.runtime,
providers,
- providers[0] ?? null,
- false,
);
- return {
- runtime: resolved.runtime ?? providers[0],
- name: persona.displayName,
- personaId: persona.id,
- harnessOverride: false,
- systemPrompt: persona.systemPrompt,
- avatarUrl: persona.avatarUrl ?? undefined,
- model: persona.model ?? undefined,
- role: "bot" as const,
- backend: { type: "local" as const },
- };
+ if (!resolved.runtime) return [];
+ return [
+ {
+ runtime: resolved.runtime,
+ name: persona.displayName,
+ personaId: persona.id,
+ harnessOverride: resolved.harnessOverride,
+ systemPrompt: persona.systemPrompt,
+ avatarUrl: persona.avatarUrl ?? undefined,
+ model: persona.model ?? undefined,
+ role: "bot" as const,
+ backend: { type: "local" as const },
+ },
+ ];
});
setSubmissionNotice(null);
@@ -189,9 +197,15 @@ export function AddChannelBotDialog({
}
}
+ const selectedPersonasResolvable = canResolveAllPersonaRuntimes(
+ selectedPersonas,
+ providers,
+ providers[0] ?? null,
+ );
const canSubmit =
providers.length > 0 &&
selectedPersonas.length > 0 &&
+ selectedPersonasResolvable &&
!providersLoading &&
!createBotsMutation.isPending;
const addButtonLabel = createBotsMutation.isPending
diff --git a/desktop/src/features/channels/ui/useQuickBotDrop.ts b/desktop/src/features/channels/ui/useQuickBotDrop.ts
index f141ca4381..5283a402fb 100644
--- a/desktop/src/features/channels/ui/useQuickBotDrop.ts
+++ b/desktop/src/features/channels/ui/useQuickBotDrop.ts
@@ -4,7 +4,7 @@ import {
useAvailableAcpRuntimes,
useCreateChannelManagedAgentMutation,
} from "@/features/agents/hooks";
-import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
+import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition";
import type { AgentPersona } from "@/shared/api/types";
type QuickBotDropState = {
@@ -24,7 +24,6 @@ export function useQuickBotDrop(channelId: string | null) {
});
const providers = providersQuery.data ?? [];
- const defaultProvider = providers[0] ?? null;
const addBot = React.useCallback(
async (persona: AgentPersona, instanceName: string) => {
@@ -33,11 +32,8 @@ export function useQuickBotDrop(channelId: string | null) {
setState({ pending: true, error: null });
try {
- const { runtime } = resolvePersonaRuntime(
- persona.runtime,
- providers,
- defaultProvider,
- );
+ const { harnessOverride, runtime } =
+ resolveProvisioningRuntimeForDefinition(persona.runtime, providers);
if (!runtime) {
setState({
@@ -54,6 +50,7 @@ export function useQuickBotDrop(channelId: string | null) {
avatarUrl: persona.avatarUrl ?? undefined,
personaId: persona.id,
model: persona.model ?? undefined,
+ harnessOverride,
});
setState({ pending: false, error: null });
@@ -64,7 +61,7 @@ export function useQuickBotDrop(channelId: string | null) {
});
}
},
- [channelId, createMutation, defaultProvider, providers, state.pending],
+ [channelId, createMutation, providers, state.pending],
);
return { ...state, addBot };
diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts
index 6d4e007cd4..1a95ae8755 100644
--- a/desktop/src/features/messages/ui/useMentionSendFlow.ts
+++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts
@@ -10,7 +10,7 @@ import {
useProvisionChannelManagedAgentMutation,
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
-import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
+import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition";
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys";
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
@@ -313,7 +313,6 @@ export function useMentionSendFlow({
}
const runtimes = await getAvailableRuntimes();
- const defaultRuntime = runtimes[0] ?? null;
const errors: string[] = [];
const agents: ManagedAgent[] = [];
const pubkeys: string[] = [];
@@ -327,11 +326,8 @@ export function useMentionSendFlow({
}
seenPersonaIds.add(persona.id);
- const { runtime } = resolvePersonaRuntime(
- persona.runtime,
- runtimes,
- defaultRuntime,
- );
+ const { harnessOverride, runtime } =
+ resolveProvisioningRuntimeForDefinition(persona.runtime, runtimes);
if (!runtime) {
errors.push(`${displayName}: No agent runtime available.`);
continue;
@@ -345,6 +341,7 @@ export function useMentionSendFlow({
runtime,
name: persona.displayName,
personaId: persona.id,
+ harnessOverride,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
index 90f8b22d62..0eb613e437 100644
--- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
+++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
@@ -10,6 +10,7 @@ import {
} from "@/features/agents/ui/AgentConfigFields";
import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions";
import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls";
+import { useDisabledAcpRuntimeIds } from "@/features/agents/lib/runtimeVisibilityPreference";
import { createSaveCoalescer } from "./saveCoalescer";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import {
@@ -126,14 +127,16 @@ function AgentDefaultsSection({
() => new Set(effectiveReadyRuntimeIds),
[effectiveReadyRuntimeIds],
);
+ const disabledRuntimeIds = useDisabledAcpRuntimeIds();
// Setup already confirmed readiness. Re-filter only for onboarding
// visibility here; a transient auth recheck must not invalidate that handoff.
const readyRuntimes = React.useMemo(
() =>
- getVisibleOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) =>
- readyRuntimeIdSet.has(runtime.id),
- ),
- [readyRuntimeIdSet, runtimesQuery.data],
+ getVisibleOnboardingRuntimes(
+ runtimesQuery.data ?? [],
+ disabledRuntimeIds,
+ ).filter((runtime) => readyRuntimeIdSet.has(runtime.id)),
+ [disabledRuntimeIds, readyRuntimeIdSet, runtimesQuery.data],
);
const selectedRuntime = React.useMemo(
() =>
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index 288ca30259..05f2962bdb 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -8,6 +8,7 @@ import {
useConnectAcpRuntimeMutation,
useInstallAcpRuntimeMutation,
} from "@/features/agents/hooks";
+import { useDisabledAcpRuntimeIds } from "@/features/agents/lib/runtimeVisibilityPreference";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
@@ -656,12 +657,14 @@ function SetupStepContent({
const { runtimeProviders } = state;
const [installResults, setInstallResults] =
React.useState({});
+ const disabledRuntimeIds = useDisabledAcpRuntimeIds();
const readyRuntimeIds = React.useMemo(
() =>
- getReadyOnboardingRuntimes(runtimeProviders.items).map(
- (runtime) => runtime.id,
- ),
- [runtimeProviders.items],
+ getReadyOnboardingRuntimes(
+ runtimeProviders.items,
+ disabledRuntimeIds,
+ ).map((runtime) => runtime.id),
+ [disabledRuntimeIds, runtimeProviders.items],
);
const readyRuntimeIdsKey = readyRuntimeIds.join("\0");
// The key prevents catalog object refreshes from creating an effect loop
diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs
index b10aa19154..ea836f655f 100644
--- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs
+++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs
@@ -34,6 +34,18 @@ test("visible onboarding runtimes use the product order", () => {
);
});
+test("onboarding defaults exclude device-disabled harnesses", () => {
+ const runtimes = [
+ runtime("codex", "available", "logged_in"),
+ runtime("claude", "available", "logged_in"),
+ ];
+
+ assert.deepEqual(
+ getVisibleOnboardingRuntimes(runtimes, ["claude"]).map(({ id }) => id),
+ ["codex"],
+ );
+});
+
test("readiness requires an available and authenticated runtime", () => {
assert.equal(
runtimeIsReadyForOnboarding(runtime("claude", "available", "logged_in")),
@@ -68,3 +80,17 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => {
["claude"],
);
});
+
+test("ready onboarding runtimes exclude device-disabled harnesses", () => {
+ const runtimes = [
+ runtime("codex", "available", "logged_in"),
+ runtime("claude", "available", "logged_in"),
+ ];
+
+ assert.deepEqual(
+ getReadyOnboardingRuntimes(runtimes, ["claude", "codex"]).map(
+ ({ id }) => id,
+ ),
+ [],
+ );
+});
diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
index 51339e2afe..4b2d97a712 100644
--- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
+++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
@@ -1,4 +1,5 @@
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
+import { runtimesForAcpConfigurationPicker } from "@/features/agents/lib/runtimeVisibilityPreference";
export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"];
@@ -20,8 +21,9 @@ export function runtimeIsReadyForOnboarding(runtime: AcpRuntimeCatalogEntry) {
export function getVisibleOnboardingRuntimes(
runtimes: readonly AcpRuntimeCatalogEntry[],
+ disabledRuntimeIds: readonly string[] = [],
) {
- return runtimes
+ return runtimesForAcpConfigurationPicker(runtimes, disabledRuntimeIds)
.filter((runtime) => runtimeIsVisibleInOnboarding(runtime.id))
.sort(
(left, right) =>
@@ -32,8 +34,9 @@ export function getVisibleOnboardingRuntimes(
export function getReadyOnboardingRuntimes(
runtimes: readonly AcpRuntimeCatalogEntry[],
+ disabledRuntimeIds: readonly string[] = [],
) {
- return getVisibleOnboardingRuntimes(runtimes).filter(
+ return getVisibleOnboardingRuntimes(runtimes, disabledRuntimeIds).filter(
runtimeIsReadyForOnboarding,
);
}
diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs
index b3def930f1..bd009bd71e 100644
--- a/desktop/src/features/onboarding/welcomeGuide.test.mjs
+++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs
@@ -289,6 +289,68 @@ test("existing Welcome starter needs no update when runtime already matches", ()
);
});
+test("existing Welcome starter keeps its runtime when that harness is hidden", () => {
+ const existing = makeAgent({
+ personaId: WELCOME_GUIDE_PERSONA_ID,
+ agentCommand: "buzz-agent",
+ agentArgs: [],
+ model: "auto",
+ provider: "relay-mesh",
+ });
+
+ assert.equal(
+ welcomeStarterRuntimeUpdate(
+ existing,
+ {
+ name: "Fizz",
+ agentCommand: "goose",
+ agentArgs: [],
+ mcpCommand: "",
+ model: "gpt-5",
+ provider: "openai",
+ },
+ {
+ runtimes: [
+ { id: "buzz-agent", command: "buzz-agent" },
+ { id: "goose", command: "goose" },
+ ],
+ disabledRuntimeIds: ["buzz-agent"],
+ },
+ ),
+ null,
+ );
+});
+
+test("existing Welcome starter keeps an aliased runtime when that harness is hidden", () => {
+ const existing = makeAgent({
+ personaId: WELCOME_GUIDE_PERSONA_ID,
+ agentCommand: "claude-code-acp",
+ agentArgs: [],
+ });
+
+ assert.equal(
+ welcomeStarterRuntimeUpdate(
+ existing,
+ {
+ name: "Fizz",
+ agentCommand: "goose",
+ agentArgs: [],
+ mcpCommand: "",
+ model: null,
+ provider: null,
+ },
+ {
+ runtimes: [
+ { id: "claude", command: "claude-agent-acp" },
+ { id: "goose", command: "goose" },
+ ],
+ disabledRuntimeIds: ["claude"],
+ },
+ ),
+ null,
+ );
+});
+
test("welcome team starter definitions and role identities are stable", () => {
assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome");
assert.deepEqual(WELCOME_TEAM_STARTERS, [
diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts
index aa8deb7c19..91de5d8d79 100644
--- a/desktop/src/features/onboarding/welcomeGuide.ts
+++ b/desktop/src/features/onboarding/welcomeGuide.ts
@@ -1,7 +1,12 @@
+import { commandsMatch } from "@/features/agents/agentReuse";
import {
buildInstanceInputForDefinition,
resolveStartRuntimeForDefinition,
} from "@/features/agents/lib/instanceInputForDefinition";
+import {
+ filterEnabledAcpRuntimes,
+ getDisabledAcpRuntimeIdsSnapshot,
+} from "@/features/agents/lib/runtimeVisibilityPreference";
import {
addChannelMembers,
createManagedAgent,
@@ -14,6 +19,7 @@ import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig";
import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
import type {
AcpRuntime,
+ AcpRuntimeCatalogEntry,
AgentPersona,
CreateManagedAgentInput,
ManagedAgent,
@@ -232,9 +238,29 @@ export async function buildWelcomeStarterCreateInput(
export function welcomeStarterRuntimeUpdate(
existing: ManagedAgent,
desired: CreateManagedAgentInput,
+ visibility?: {
+ runtimes: readonly AcpRuntimeCatalogEntry[];
+ disabledRuntimeIds: readonly string[];
+ },
) {
if (!desired.agentCommand) return null;
+ const existingRuntime = visibility?.runtimes.find(
+ (runtime) =>
+ (runtime.command
+ ? commandsMatch(existing.agentCommand, runtime.command)
+ : false) || commandsMatch(existing.agentCommand, runtime.id),
+ );
+ if (
+ existingRuntime &&
+ filterEnabledAcpRuntimes(
+ [existingRuntime],
+ visibility?.disabledRuntimeIds ?? [],
+ ).length === 0
+ ) {
+ return null;
+ }
+
const desiredArgs = desired.agentArgs ?? [];
const desiredModel = desired.model ?? null;
const desiredProvider = desired.provider ?? null;
@@ -282,6 +308,7 @@ async function provisionWelcomeTeam(
const runtimes = runtimeCatalog.filter(
(runtime): runtime is AcpRuntime => runtime.availability === "available",
);
+ const disabledRuntimeIds = getDisabledAcpRuntimeIdsSnapshot();
const agents: ManagedAgent[] = [];
for (const starter of WELCOME_TEAM_STARTERS) {
@@ -302,7 +329,10 @@ async function provisionWelcomeTeam(
relayUrl,
);
if (existing) {
- const runtimeUpdate = welcomeStarterRuntimeUpdate(existing, desired);
+ const runtimeUpdate = welcomeStarterRuntimeUpdate(existing, desired, {
+ runtimes: runtimeCatalog,
+ disabledRuntimeIds,
+ });
agents.push(
runtimeUpdate
? (await updateManagedAgent(runtimeUpdate)).agent
diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
index 6f472b8068..c21641b12c 100644
--- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs
+++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
@@ -8,7 +8,9 @@ import {
buildWelcomeKickoffOpenerSendInput,
classifyWelcomeKickoffResolution,
createWelcomeKickoffCoordinator,
+ hasEnabledWelcomeRuntime,
mergeKickoffEvents,
+ resolveWelcomeAgentReadiness,
resolveWelcomeAgentSet,
selectWelcomeKickoffIntroTeammates,
waitForWelcomeKickoffBeat,
@@ -33,6 +35,82 @@ const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64));
const honey = agent("Honey", "builtin:honey", "h".repeat(64));
const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64));
+test("welcome readiness ignores a hidden logged-in runtime", () => {
+ const runtimes = [
+ {
+ id: "claude",
+ label: "Claude",
+ availability: "available",
+ authStatus: { status: "logged_in" },
+ },
+ ];
+ const globalConfig = {
+ env_vars: {},
+ provider: null,
+ model: null,
+ preferred_runtime: null,
+ };
+
+ assert.deepEqual(
+ resolveWelcomeAgentReadiness(runtimes, globalConfig, ["claude"]),
+ { ready: false },
+ );
+});
+
+test("welcome readiness evaluates the runtime selected by the visible fallback", () => {
+ const runtimes = [
+ {
+ id: "buzz-agent",
+ label: "Buzz Agent",
+ availability: "available",
+ authStatus: { status: "not_applicable" },
+ },
+ {
+ id: "goose",
+ label: "Goose",
+ availability: "available",
+ authStatus: { status: "not_applicable" },
+ },
+ {
+ id: "claude",
+ label: "Claude",
+ availability: "available",
+ authStatus: { status: "logged_in" },
+ },
+ ];
+ const globalConfig = {
+ env_vars: {},
+ provider: null,
+ model: null,
+ preferred_runtime: "buzz-agent",
+ };
+
+ assert.deepEqual(
+ resolveWelcomeAgentReadiness(runtimes, globalConfig, ["buzz-agent"]),
+ { ready: false },
+ );
+});
+
+test("welcome provisioning requires an enabled available runtime", () => {
+ const runtimes = [
+ {
+ id: "goose",
+ label: "Goose",
+ availability: "available",
+ authStatus: { status: "not_applicable" },
+ },
+ {
+ id: "claude",
+ label: "Claude",
+ availability: "adapter_missing",
+ authStatus: { status: "unknown" },
+ },
+ ];
+
+ assert.equal(hasEnabledWelcomeRuntime(runtimes, ["goose"]), false);
+ assert.equal(hasEnabledWelcomeRuntime(runtimes, []), true);
+});
+
test("resolveWelcomeAgentSet orders agents by stable persona identity", () => {
assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), {
lead: fizz,
diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts
index fd5dac3947..b9a22e7963 100644
--- a/desktop/src/features/onboarding/welcomeKickoff.ts
+++ b/desktop/src/features/onboarding/welcomeKickoff.ts
@@ -5,7 +5,12 @@ import {
useAcpRuntimesQuery,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
-import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
+import {
+ filterEnabledAcpRuntimes,
+ useDisabledAcpRuntimeIds,
+} from "@/features/agents/lib/runtimeVisibilityPreference";
+import { getDefaultPersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
+import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { useCommunities } from "@/features/communities/useCommunities";
import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding";
import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness";
@@ -26,7 +31,13 @@ import { hasManagedAgentChannelMessageMarker } from "@/shared/api/tauriManagedAg
import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
import { getPresence, listManagedAgents } from "@/shared/api/tauri";
import { getProfile } from "@/shared/api/tauriProfiles";
-import type { Channel, ManagedAgent, RelayEvent } from "@/shared/api/types";
+import type {
+ AcpRuntimeCatalogEntry,
+ Channel,
+ GlobalAgentConfig,
+ ManagedAgent,
+ RelayEvent,
+} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useQueryClient } from "@tanstack/react-query";
@@ -45,6 +56,40 @@ export const WELCOME_KICKOFF_PROVIDER_MESSAGE =
const WELCOME_KICKOFF_CTA =
"What can we help you build? Bring us something you're working on, or give us a quick challenge to see how we work together.";
+export function resolveWelcomeAgentReadiness(
+ runtimes: readonly AcpRuntimeCatalogEntry[],
+ globalConfig: GlobalAgentConfig,
+ disabledRuntimeIds: readonly string[],
+) {
+ const visibleRuntimes = filterEnabledAcpRuntimes(
+ runtimes,
+ disabledRuntimeIds,
+ );
+ const selectedRuntime = getDefaultPersonaRuntime(
+ visibleRuntimes,
+ globalConfig.preferred_runtime,
+ );
+ if (!selectedRuntime) return { ready: false } as const;
+
+ return resolveAgentReadiness(
+ visibleRuntimes,
+ {
+ ...globalConfig,
+ preferred_runtime: selectedRuntime.id,
+ },
+ "preferred",
+ );
+}
+
+export function hasEnabledWelcomeRuntime(
+ runtimes: readonly AcpRuntimeCatalogEntry[],
+ disabledRuntimeIds: readonly string[],
+) {
+ return filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds).some(
+ (runtime) => runtime.availability === "available",
+ );
+}
+
function formatAgentNames(agents: readonly ManagedAgent[]) {
if (agents.length === 0) return "";
if (agents.length === 1) return agents[0]?.name ?? "";
@@ -493,7 +538,9 @@ export function useWelcomeKickoff(
const { activeCommunity } = useCommunities();
const runtimesQuery = useAcpRuntimesQuery();
const managedAgentsQuery = useManagedAgentsQuery();
- const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig();
+ const disabledRuntimeIds = useDisabledAcpRuntimeIds();
+ const { globalConfig, isLoading: configLoading } =
+ useImplicitGlobalAgentConfig();
const channelId = activeChannel?.id ?? null;
const isActiveWelcome = isWelcomeChannel(activeChannel);
const focusedWelcomeChannelRef = React.useRef(null);
@@ -545,14 +592,25 @@ export function useWelcomeKickoff(
[activeCommunity?.relayUrl, managedAgentsQuery.data],
);
const readiness = React.useMemo(
- () => resolveAgentReadiness(runtimesQuery.data ?? [], globalConfig),
- [globalConfig, runtimesQuery.data],
+ () =>
+ resolveWelcomeAgentReadiness(
+ runtimesQuery.data ?? [],
+ globalConfig,
+ disabledRuntimeIds,
+ ),
+ [disabledRuntimeIds, globalConfig, runtimesQuery.data],
+ );
+ const canProvisionWelcomeTeam = React.useMemo(
+ () =>
+ hasEnabledWelcomeRuntime(runtimesQuery.data ?? [], disabledRuntimeIds),
+ [disabledRuntimeIds, runtimesQuery.data],
);
React.useEffect(() => {
if (
!channelId ||
!isActiveWelcome ||
configLoading ||
+ managedAgentsQuery.isPending ||
runtimesQuery.isPending
) {
return;
@@ -565,6 +623,21 @@ export function useWelcomeKickoff(
focusedWelcomeChannelRef.current !== channelId;
void (async () => {
try {
+ if (await markerExists(channelId, closerMarker)) {
+ return;
+ }
+ if (!canProvisionWelcomeTeam) {
+ if (!agentSet) return;
+ await sendManagedAgentChannelMessage({
+ agentPubkey: agentSet.lead.pubkey,
+ channelId,
+ content: WELCOME_KICKOFF_PROVIDER_MESSAGE,
+ marker: providerMarker,
+ markerScope: "channel",
+ });
+ return;
+ }
+
const welcomeTeam = await ensureWelcomeTeam(
channelId,
activeCommunity?.relayUrl,
@@ -682,9 +755,12 @@ export function useWelcomeKickoff(
})();
}, [
activeCommunity?.relayUrl,
+ agentSet,
+ canProvisionWelcomeTeam,
channelId,
configLoading,
isActiveWelcome,
+ managedAgentsQuery.isPending,
onKickoffOpenerPosted,
queryClient,
readiness,
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx
index c1f713d230..4dfdaaafc9 100644
--- a/desktop/src/features/profile/ui/UserProfilePanel.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx
@@ -25,7 +25,7 @@ import {
useUpdateManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
-import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
+import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog";
import {
availableRuntimesForStart,
@@ -122,7 +122,7 @@ export function UserProfilePanel({
widthPx,
transparentChrome = false,
}: UserProfilePanelProps) {
- const { globalConfig } = useGlobalAgentConfig();
+ const { globalConfig } = useImplicitGlobalAgentConfig();
const isOverlay = useIsThreadPanelOverlay();
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
index 82680ae5a8..6e619a26e7 100644
--- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
@@ -16,6 +16,10 @@ import {
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
+import {
+ runtimesForAcpConfigurationPicker,
+ useDisabledAcpRuntimeIds,
+} from "@/features/agents/lib/runtimeVisibilityPreference";
import {
useChannelTemplatesQuery,
useCreateChannelTemplateMutation,
@@ -292,6 +296,7 @@ function TemplateFormDialog({
const teamsQuery = useTeamsQuery();
const providersQuery = useAvailableAcpRuntimes();
const runtimes = providersQuery.data ?? [];
+ const disabledRuntimeIds = useDisabledAcpRuntimeIds();
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
@@ -559,6 +564,7 @@ function TemplateFormDialog({
personaRuntimes={personaRuntimes}
providers={runtimes}
providersLoading={providersQuery.isLoading}
+ disabledRuntimeIds={disabledRuntimeIds}
selectedPersonaIds={selectedPersonaIds}
selectedTeamIds={selectedTeamIds}
teamRuntimes={teamRuntimes}
@@ -638,6 +644,7 @@ function TemplateTeamSelector({
}
function RuntimeAssignments({
+ disabledRuntimeIds,
isPending,
onPersonaRuntimeChange,
onTeamRuntimeChange,
@@ -650,6 +657,7 @@ function RuntimeAssignments({
teamRuntimes,
teams,
}: {
+ disabledRuntimeIds: readonly string[];
isPending: boolean;
onPersonaRuntimeChange: (personaId: string, runtimeId: string) => void;
onTeamRuntimeChange: (teamId: string, runtimeId: string) => void;
@@ -697,6 +705,7 @@ function RuntimeAssignments({
onChange={(runtimeId) =>
onPersonaRuntimeChange(persona.id, runtimeId)
}
+ disabledRuntimeIds={disabledRuntimeIds}
providers={providers}
value={personaRuntimes[persona.id] ?? ""}
/>
@@ -708,6 +717,7 @@ function RuntimeAssignments({
icon="team"
label={team.name}
onChange={(runtimeId) => onTeamRuntimeChange(team.id, runtimeId)}
+ disabledRuntimeIds={disabledRuntimeIds}
providers={providers}
value={teamRuntimes[team.id] ?? ""}
/>
@@ -721,6 +731,7 @@ function RuntimeAssignments({
function RuntimeRow({
avatarUrl,
disabled,
+ disabledRuntimeIds,
icon,
label,
onChange,
@@ -729,12 +740,24 @@ function RuntimeRow({
}: {
avatarUrl?: string | null | undefined;
disabled: boolean;
+ disabledRuntimeIds: readonly string[];
icon?: "team";
label: string;
onChange: (runtimeId: string) => void;
providers: AcpRuntime[];
value: string;
}) {
+ const runtimeOptions = runtimesForAcpConfigurationPicker(
+ providers,
+ disabledRuntimeIds,
+ value,
+ );
+ const selectableRuntimeIds = new Set(
+ runtimesForAcpConfigurationPicker(providers, disabledRuntimeIds).map(
+ (runtime) => runtime.id,
+ ),
+ );
+
return (