mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing
This commit is contained in:
@@ -714,6 +714,7 @@ export function AppShell() {
|
||||
}
|
||||
onAddCommunity={addCommunityDialog.openDialog}
|
||||
onRemoveCommunity={communitiesHook.removeCommunity}
|
||||
onReorderCommunities={communitiesHook.reorderCommunities}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
communities={communitiesHook.communities}
|
||||
|
||||
@@ -68,20 +68,34 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
sole onboarding surface that chooses and persists `preferred_runtime`.
|
||||
`onboarding-agent-defaults.spec.ts` is the acceptance gate for anything
|
||||
touching this flow or the shared renderer.
|
||||
8. **Omit the Model control only after a confirmed successful empty
|
||||
discovery on an optional-model harness.** When the field model marks model
|
||||
as `acpNative` (Claude Code / Codex), `shouldRenderModelControl` hides the
|
||||
picker while discovery is in flight and after IPC resolves with no usable
|
||||
options (`modelDiscoverySuccessfulEmpty` / `isSuccessfulEmptyDiscovery`).
|
||||
A thrown or unavailable discovery keeps the control so #2246 failure UI can
|
||||
render, and must not heal/clear persisted model or effort. Full disclosure
|
||||
still shows the control when Custom model is available. Required-model
|
||||
harnesses always keep the field. Gate: `defaults hides model when optional
|
||||
harness has empty discovery` (and the failed-discovery counterpart) in
|
||||
`onboarding-agent-defaults.spec.ts`.
|
||||
|
||||
## The tests that enforce this
|
||||
|
||||
- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing
|
||||
policy. Update when the capability model changes.
|
||||
- `ui/agentConfigFieldsContract.test.mjs` — canonical behaviors + disclosure
|
||||
presets + `shouldShowModelStatusMessage` status-bypass rule. If this fails,
|
||||
you probably reintroduced a per-surface flag or broke the status-bypass.
|
||||
presets + `shouldShowModelStatusMessage` status-bypass +
|
||||
`shouldRenderModelControl` (successful-empty omit vs failure keep). If this
|
||||
fails, you probably reintroduced a per-surface flag or conflated empty with
|
||||
failed discovery.
|
||||
- `ui/usePersonaModelDiscovery.test.mjs` — `synthesizeEmptyDiscoveryStatus`,
|
||||
`isCacheableDiscoveryResponse`, `deriveModelDiscoveryPending`. If the
|
||||
"reopen to retry" copy becomes inert again, these tests will catch it.
|
||||
`isCacheableDiscoveryResponse`, `deriveModelDiscoveryPending`,
|
||||
`isSuccessfulEmptyDiscovery`. If the "reopen to retry" copy becomes inert
|
||||
again, these tests will catch it.
|
||||
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
|
||||
acceptance coverage for readiness, failure states, defaults, navigation, and
|
||||
persistence races.
|
||||
acceptance coverage for readiness, failure states, defaults, navigation,
|
||||
successful-empty vs failed optional-model discovery, and persistence races.
|
||||
- Rust: `runtime_metadata_env_vars` tests pin spawn-time key application.
|
||||
|
||||
## Keep this file true
|
||||
|
||||
@@ -125,6 +125,41 @@ export function shouldShowModelStatusMessage(
|
||||
return showDescriptions || status !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Model control should render given discovery state.
|
||||
*
|
||||
* Optional-model harnesses (Claude Code / Codex, `acpNative`) omit the control
|
||||
* while discovery is in flight and after a **confirmed successful empty**
|
||||
* catalog (IPC resolved, no usable options) — there is nothing useful to pick.
|
||||
* Discovery failures / unavailable runtimes keep the control so #2246 failure
|
||||
* UI can render. Full disclosure still shows the control when Custom model is
|
||||
* available. Required-model harnesses always render the control.
|
||||
*/
|
||||
export function shouldRenderModelControl({
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryLoading,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
modelIsOptional,
|
||||
showCustomModelOption,
|
||||
}: {
|
||||
discoveredModelOptions: readonly { id: string }[] | null;
|
||||
modelDiscoveryLoading: boolean;
|
||||
/** True only when discovery IPC resolved with a response that yielded no options. */
|
||||
modelDiscoverySuccessfulEmpty: boolean;
|
||||
modelIsOptional: boolean;
|
||||
showCustomModelOption: boolean;
|
||||
}): boolean {
|
||||
if (!modelIsOptional) return true;
|
||||
if (modelDiscoveryLoading) return false;
|
||||
const hasExplicitModel = (discoveredModelOptions ?? []).some(
|
||||
(option) => option.id.trim().length > 0,
|
||||
);
|
||||
if (hasExplicitModel) return true;
|
||||
if (showCustomModelOption) return true;
|
||||
// Omit only on confirmed successful empty — not on failure/unavailable.
|
||||
return !modelDiscoverySuccessfulEmpty;
|
||||
}
|
||||
|
||||
export type AgentConfigFieldsProps = {
|
||||
bakedEnv: BakedEnvEntry[];
|
||||
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
|
||||
@@ -277,6 +312,7 @@ export function AgentConfigFields({
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryLoading,
|
||||
modelDiscoveryStatus,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
} = usePersonaModelDiscovery({
|
||||
envVars: config.env_vars,
|
||||
isCustomProviderEditing: isCustomProvider,
|
||||
@@ -285,6 +321,18 @@ export function AgentConfigFields({
|
||||
provider: providerForDiscovery,
|
||||
selectedRuntime,
|
||||
});
|
||||
const modelControlVisible = shouldRenderModelControl({
|
||||
discoveredModelOptions: dependentFieldsDisabled
|
||||
? null
|
||||
: discoveredModelOptions,
|
||||
modelDiscoveryLoading: dependentFieldsDisabled
|
||||
? false
|
||||
: modelDiscoveryLoading,
|
||||
modelDiscoverySuccessfulEmpty:
|
||||
!dependentFieldsDisabled && modelDiscoverySuccessfulEmpty,
|
||||
modelIsOptional,
|
||||
showCustomModelOption,
|
||||
});
|
||||
|
||||
// Mount-time healing policy: onboarding page 4 edits the root config during
|
||||
// first-run (no higher layers to inherit from), so acting on open is safe
|
||||
@@ -353,16 +401,23 @@ export function AgentConfigFields({
|
||||
// the old harness. In onboarding, heal that stale value as soon as the new
|
||||
// harness catalog proves it is unsupported; otherwise a Codex id like
|
||||
// `gpt-5.5[low]` appears as a Claude Code custom model.
|
||||
// Also clear when the Model control is omitted after a confirmed successful
|
||||
// empty catalog — never while discovery failed/unavailable (transient
|
||||
// failures must not erase saved model/effort).
|
||||
React.useEffect(() => {
|
||||
if (!healOnMount) return;
|
||||
const currentModel = (config.model ?? "").trim();
|
||||
if (currentModel.length === 0) return;
|
||||
if (modelDiscoveryLoading || discoveredModelOptions === null) return;
|
||||
if (
|
||||
discoveredModelOptions.some((option) => option.id.trim() === currentModel)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (modelDiscoveryLoading) return;
|
||||
|
||||
const catalogMiss =
|
||||
discoveredModelOptions !== null &&
|
||||
!discoveredModelOptions.some(
|
||||
(option) => option.id.trim() === currentModel,
|
||||
);
|
||||
const omittedAfterSuccessfulEmpty =
|
||||
modelIsOptional && !modelControlVisible && modelDiscoverySuccessfulEmpty;
|
||||
if (!catalogMiss && !omittedAfterSuccessfulEmpty) return;
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
@@ -371,7 +426,10 @@ export function AgentConfigFields({
|
||||
}, [
|
||||
config,
|
||||
discoveredModelOptions,
|
||||
modelControlVisible,
|
||||
modelDiscoveryLoading,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
modelIsOptional,
|
||||
onConfigChange,
|
||||
onCustomModelEditingChange,
|
||||
healOnMount,
|
||||
@@ -659,53 +717,55 @@ export function AgentConfigFields({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Model field */}
|
||||
<div className={showDescriptions ? fieldClassName : undefined}>
|
||||
<AgentModelField
|
||||
allowDefaultModel={fallbackModel !== null}
|
||||
defaultModelLabel={
|
||||
fallbackModel ? `Default model (${fallbackModel})` : undefined
|
||||
}
|
||||
disableSelectDuringDiscovery={disableModelSelectDuringDiscovery}
|
||||
disabled={dependentFieldsDisabled}
|
||||
discoveredModelOptions={
|
||||
dependentFieldsDisabled ? null : discoveredModelOptions
|
||||
}
|
||||
globalModel={fallbackModel ?? undefined}
|
||||
id="global-agent-model"
|
||||
isCustomModelEditing={isCustomModelEditing}
|
||||
isRequired={
|
||||
showRequiredIndicators &&
|
||||
!modelIsOptional &&
|
||||
fallbackModel === null &&
|
||||
!dependentFieldsDisabled
|
||||
}
|
||||
keepSelectedModelValueLabel
|
||||
model={dependentFieldsDisabled ? "" : (config.model ?? "")}
|
||||
modelDiscoveryLoading={
|
||||
dependentFieldsDisabled ? false : modelDiscoveryLoading
|
||||
}
|
||||
modelDiscoveryStatus={
|
||||
dependentFieldsDisabled ? null : modelDiscoveryStatus
|
||||
}
|
||||
onIsCustomModelEditingChange={onCustomModelEditingChange}
|
||||
onModelChange={handleModelChange}
|
||||
placeholderClassName={placeholderClassName}
|
||||
placeholder="Select a model"
|
||||
provider={providerForDiscovery}
|
||||
fieldClassName={unstyled ? fieldClassName : undefined}
|
||||
labelClassName={fieldLabelClassName}
|
||||
selectClassName={selectClassName}
|
||||
showCustomModelOption={showCustomModelOption}
|
||||
showStatusMessage={shouldShowModelStatusMessage(
|
||||
showDescriptions,
|
||||
modelDiscoveryStatus,
|
||||
)}
|
||||
testId="global-agent-model"
|
||||
useCustomSelect={useCustomSelect}
|
||||
useChevronIcon={useChevronSelectIcon}
|
||||
/>
|
||||
</div>
|
||||
{/* Model field — omitted only after confirmed successful empty discovery */}
|
||||
{modelControlVisible ? (
|
||||
<div className={showDescriptions ? fieldClassName : undefined}>
|
||||
<AgentModelField
|
||||
allowDefaultModel={fallbackModel !== null}
|
||||
defaultModelLabel={
|
||||
fallbackModel ? `Default model (${fallbackModel})` : undefined
|
||||
}
|
||||
disableSelectDuringDiscovery={disableModelSelectDuringDiscovery}
|
||||
disabled={dependentFieldsDisabled}
|
||||
discoveredModelOptions={
|
||||
dependentFieldsDisabled ? null : discoveredModelOptions
|
||||
}
|
||||
globalModel={fallbackModel ?? undefined}
|
||||
id="global-agent-model"
|
||||
isCustomModelEditing={isCustomModelEditing}
|
||||
isRequired={
|
||||
showRequiredIndicators &&
|
||||
!modelIsOptional &&
|
||||
fallbackModel === null &&
|
||||
!dependentFieldsDisabled
|
||||
}
|
||||
keepSelectedModelValueLabel
|
||||
model={dependentFieldsDisabled ? "" : (config.model ?? "")}
|
||||
modelDiscoveryLoading={
|
||||
dependentFieldsDisabled ? false : modelDiscoveryLoading
|
||||
}
|
||||
modelDiscoveryStatus={
|
||||
dependentFieldsDisabled ? null : modelDiscoveryStatus
|
||||
}
|
||||
onIsCustomModelEditingChange={onCustomModelEditingChange}
|
||||
onModelChange={handleModelChange}
|
||||
placeholderClassName={placeholderClassName}
|
||||
placeholder="Select a model"
|
||||
provider={providerForDiscovery}
|
||||
fieldClassName={unstyled ? fieldClassName : undefined}
|
||||
labelClassName={fieldLabelClassName}
|
||||
selectClassName={selectClassName}
|
||||
showCustomModelOption={showCustomModelOption}
|
||||
showStatusMessage={shouldShowModelStatusMessage(
|
||||
showDescriptions,
|
||||
dependentFieldsDisabled ? null : modelDiscoveryStatus,
|
||||
)}
|
||||
testId="global-agent-model"
|
||||
useCustomSelect={useCustomSelect}
|
||||
useChevronIcon={useChevronSelectIcon}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Thinking / Effort */}
|
||||
{effortFieldVisible ? (
|
||||
|
||||
@@ -63,6 +63,7 @@ export function AgentDropdownSelect({
|
||||
ariaRequired,
|
||||
className,
|
||||
disabled = false,
|
||||
emptyOptionsLabel = "No options available",
|
||||
id,
|
||||
onValueChange,
|
||||
options,
|
||||
@@ -77,6 +78,8 @@ export function AgentDropdownSelect({
|
||||
ariaRequired?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
/** Shown when the option list is empty (not a search filter miss). */
|
||||
emptyOptionsLabel?: string;
|
||||
id: string;
|
||||
onValueChange: (value: string) => void;
|
||||
options: readonly AgentDropdownOption[];
|
||||
@@ -184,8 +187,15 @@ export function AgentDropdownSelect({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showSearch && filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-foreground/55">No matches</p>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p
|
||||
className="px-3 py-2 text-sm text-foreground/55"
|
||||
data-testid={testId ? `${testId}-empty` : undefined}
|
||||
>
|
||||
{showSearch && query.trim().length > 0
|
||||
? "No matches"
|
||||
: emptyOptionsLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{filteredOptions.map((option) => {
|
||||
const selected = option.value === value;
|
||||
@@ -470,6 +480,7 @@ export function AgentModelField({
|
||||
ariaRequired={isRequired}
|
||||
className={selectClassName}
|
||||
disabled={selectDisabled}
|
||||
emptyOptionsLabel="Couldn't load models"
|
||||
id={id}
|
||||
onValueChange={handleModelSelectChange}
|
||||
options={modelOptions}
|
||||
|
||||
@@ -21,6 +21,7 @@ import test from "node:test";
|
||||
import {
|
||||
CANONICAL_CONFIG_BEHAVIORS,
|
||||
resolveDisclosure,
|
||||
shouldRenderModelControl,
|
||||
shouldShowModelStatusMessage,
|
||||
} from "./AgentConfigFields.tsx";
|
||||
|
||||
@@ -83,3 +84,94 @@ test("shouldShowModelStatusMessage_onboardingPreset_warningStatus_showsMessage",
|
||||
};
|
||||
assert.equal(shouldShowModelStatusMessage(showDescriptions, warning), true);
|
||||
});
|
||||
|
||||
// ── shouldRenderModelControl ──────────────────────────────────────────────────
|
||||
// Optional-model harnesses omit the control only after a confirmed successful
|
||||
// empty catalog. Failures keep the control so #2246 status UI can render.
|
||||
|
||||
test("optional model control hides while loading", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: true,
|
||||
modelDiscoverySuccessfulEmpty: false,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
false,
|
||||
"optional + loading must hide the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("optional model control hides after successful empty discovery", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: true,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
false,
|
||||
"optional + successful empty + no custom must hide the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("optional model control stays visible on discovery failure", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: false,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
true,
|
||||
"optional + failed/unavailable discovery must keep the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("optional model control shows when explicit models are available", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: [
|
||||
{ id: "", label: "Default model" },
|
||||
{ id: "claude-sonnet-4", label: "Claude Sonnet 4" },
|
||||
],
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: false,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
true,
|
||||
"explicit models must show the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("full disclosure keeps Custom escape hatch when discovery is empty", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: true,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: true,
|
||||
}),
|
||||
true,
|
||||
"full disclosure keeps Custom escape hatch when discovery is empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("required-model harnesses always keep the control", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: true,
|
||||
modelIsOptional: false,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
true,
|
||||
"required-model harnesses always keep the control",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deriveModelDiscoveryPending,
|
||||
getDiscoveredPersonaModelOptions,
|
||||
isCacheableDiscoveryResponse,
|
||||
isSuccessfulEmptyDiscovery,
|
||||
synthesizeEmptyDiscoveryStatus,
|
||||
} from "./usePersonaModelDiscovery.ts";
|
||||
|
||||
@@ -258,3 +259,56 @@ test("deriveModelDiscoveryPending_noKey_isNotPending", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ── isSuccessfulEmptyDiscovery ────────────────────────────────────────────────
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_resolvedEmptyResponse_isTrue", () => {
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: response({ models: [] }),
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryPending: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_thrownFailure_isFalse", () => {
|
||||
// Failure path leaves data null — must not be treated as successful empty.
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: null,
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryPending: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_withUsableModels_isFalse", () => {
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: response({
|
||||
models: [
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", description: null },
|
||||
],
|
||||
}),
|
||||
discoveredModelOptions: [
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
],
|
||||
modelDiscoveryPending: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_stillPending_isFalse", () => {
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: null,
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryPending: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,6 +139,28 @@ export function deriveModelDiscoveryPending({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when discovery IPC resolved with a response that yielded no usable
|
||||
* model options. Distinct from a thrown/unavailable failure (data stays null).
|
||||
* Callers that omit the Model control or heal persisted values must gate on
|
||||
* this — not on `discoveredModelOptions === null` alone.
|
||||
*/
|
||||
export function isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData,
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryPending,
|
||||
}: {
|
||||
activeModelDiscoveryData: AgentModelsResponse | null;
|
||||
discoveredModelOptions: readonly PersonaModelOption[] | null;
|
||||
modelDiscoveryPending: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
!modelDiscoveryPending &&
|
||||
activeModelDiscoveryData !== null &&
|
||||
discoveredModelOptions === null
|
||||
);
|
||||
}
|
||||
|
||||
export function usePersonaModelDiscovery({
|
||||
envVars,
|
||||
isCustomProviderEditing,
|
||||
@@ -358,6 +380,11 @@ export function usePersonaModelDiscovery({
|
||||
activeModelDiscoveryData,
|
||||
activeModelDiscoveryStatus,
|
||||
});
|
||||
const modelDiscoverySuccessfulEmpty = isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData,
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryPending,
|
||||
});
|
||||
|
||||
return {
|
||||
discoveredModelOptions,
|
||||
@@ -366,5 +393,6 @@ export function usePersonaModelDiscovery({
|
||||
modelDiscoveryPending || discoveredModelOptions !== null
|
||||
? null
|
||||
: activeModelDiscoveryStatus,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ export function useTeamActions(
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
description: team.description ?? "",
|
||||
instructions: team.instructions ?? undefined,
|
||||
personaIds: [...team.personaIds],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
canonicalChannelName,
|
||||
channelNamesMatch,
|
||||
} from "./canonicalChannelName.ts";
|
||||
|
||||
test("canonicalChannelName strips interleaved leading hashes and whitespace", () => {
|
||||
assert.equal(canonicalChannelName("channel"), "channel");
|
||||
assert.equal(canonicalChannelName("#channel"), "channel");
|
||||
assert.equal(canonicalChannelName(" ### channel "), "channel");
|
||||
assert.equal(canonicalChannelName("# #"), "");
|
||||
assert.equal(canonicalChannelName("### ###"), "");
|
||||
assert.equal(canonicalChannelName("channel#topic"), "channel#topic");
|
||||
});
|
||||
|
||||
test("channelNamesMatch canonicalizes both legacy names and search input", () => {
|
||||
assert.equal(channelNamesMatch("#general", "general"), true);
|
||||
assert.equal(channelNamesMatch("general", " #GENERAL "), true);
|
||||
assert.equal(channelNamesMatch("#random", "general"), false);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Returns the stored form of a channel name after removing the display prefix.
|
||||
* Keep this aligned with `buzz_core::channel::canonical_channel_name`.
|
||||
*/
|
||||
export function canonicalChannelName(name: string): string {
|
||||
return name.replace(/^[#\s]+/u, "").trimEnd();
|
||||
}
|
||||
|
||||
export function channelNamesMatch(left: string, right: string): boolean {
|
||||
return (
|
||||
canonicalChannelName(left).toLowerCase() ===
|
||||
canonicalChannelName(right).toLowerCase()
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
canonicalChannelName,
|
||||
channelNamesMatch,
|
||||
} from "@/features/channels/lib/canonicalChannelName";
|
||||
import { scoreChannelMatch } from "@/features/channels/lib/channelSearchScore";
|
||||
import {
|
||||
type ChannelSortMode,
|
||||
@@ -127,8 +131,9 @@ export function ChannelBrowserDialog({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
||||
const trimmedQuery = query.trim();
|
||||
const canonicalQuery = canonicalChannelName(query);
|
||||
const deferredQuery = React.useDeferredValue(canonicalQuery.toLowerCase());
|
||||
const trimmedQuery = canonicalQuery;
|
||||
// Immediate (non-deferred) lowercased query. The create row's visibility
|
||||
// (via hasExactMatch) and its label both read from the live query so they
|
||||
// can never disagree for a frame while the fuzzy filter catches up.
|
||||
@@ -244,7 +249,7 @@ export function ChannelBrowserDialog({
|
||||
channels.some(
|
||||
(channel) =>
|
||||
channel.channelType !== "dm" &&
|
||||
channel.name.toLowerCase() === normalizedQuery &&
|
||||
channelNamesMatch(channel.name, normalizedQuery) &&
|
||||
(channelTypeFilter
|
||||
? channel.channelType === channelTypeFilter
|
||||
: true),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Unit tests for applyCommunitiesOrder — the pure permutation helper that
|
||||
* drives community-rail drag-to-reorder.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { applyCommunitiesOrder } from "./useCommunities.tsx";
|
||||
|
||||
const A = {
|
||||
id: "ws-a",
|
||||
name: "Alpha",
|
||||
relayUrl: "wss://a.example.com",
|
||||
addedAt: "2024-01-01",
|
||||
};
|
||||
const B = {
|
||||
id: "ws-b",
|
||||
name: "Bravo",
|
||||
relayUrl: "wss://b.example.com",
|
||||
addedAt: "2024-01-02",
|
||||
};
|
||||
const C = {
|
||||
id: "ws-c",
|
||||
name: "Charlie",
|
||||
relayUrl: "wss://c.example.com",
|
||||
addedAt: "2024-01-03",
|
||||
};
|
||||
|
||||
describe("applyCommunitiesOrder", () => {
|
||||
it("reorders communities to match orderedIds", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-a", "ws-b"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-c", "ws-a", "ws-b"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns same order when orderedIds matches current order", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-a", "ws-b", "ws-c"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("appends communities not mentioned in orderedIds at the end in original relative order", () => {
|
||||
// C was added after drag — not in orderedIds — should tail-append
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles orderedIds that contain stale IDs not present in communities", () => {
|
||||
// "ws-gone" is a stale id — silent skip, no crash
|
||||
const result = applyCommunitiesOrder([A, B], ["ws-b", "ws-gone", "ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the full list when orderedIds is empty — original order preserved", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], []);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a single-element list (no-op reorder)", () => {
|
||||
const result = applyCommunitiesOrder([A], ["ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles an empty communities list", () => {
|
||||
const result = applyCommunitiesOrder([], ["ws-a", "ws-b"]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const original = [A, B, C];
|
||||
applyCommunitiesOrder(original, ["ws-c", "ws-a", "ws-b"]);
|
||||
assert.deepEqual(
|
||||
original.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves object identity of each community (no clone)", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-b", "ws-a"]);
|
||||
assert.equal(result[0], C);
|
||||
assert.equal(result[1], B);
|
||||
assert.equal(result[2], A);
|
||||
});
|
||||
|
||||
it("handles duplicate IDs in orderedIds — first occurrence wins", () => {
|
||||
// Defensive: dnd-kit should never produce duplicates, but guard anyway.
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-b", "ws-a"]);
|
||||
// ws-b appears once, ws-a once, ws-c appended
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a", "ws-c"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,42 @@ export function resolveCommunityUpdateResult(
|
||||
return { kind: "updated", requiresReinit: backendFieldsChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permute `communities` so that its order matches `orderedIds`.
|
||||
*
|
||||
* - Communities whose id appears in `orderedIds` are placed first, in the
|
||||
* order given by `orderedIds`.
|
||||
* - Communities not mentioned in `orderedIds` (e.g. added after the drag
|
||||
* completed) are appended at the end in their original relative order.
|
||||
*
|
||||
* Pure and side-effect-free — extracted so it can be unit-tested without
|
||||
* a DOM or React.
|
||||
*/
|
||||
export function applyCommunitiesOrder(
|
||||
communities: Community[],
|
||||
orderedIds: string[],
|
||||
): Community[] {
|
||||
const byId = new Map(communities.map((c) => [c.id, c]));
|
||||
const seen = new Set<string>();
|
||||
const reordered: Community[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c && !seen.has(id)) {
|
||||
reordered.push(c);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of communities) {
|
||||
if (!seen.has(c.id)) {
|
||||
reordered.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
export type UseCommunitiesReturn = {
|
||||
communities: Community[];
|
||||
activeCommunity: Community | null;
|
||||
@@ -90,6 +126,8 @@ export type UseCommunitiesReturn = {
|
||||
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
|
||||
>,
|
||||
) => UpdateCommunityResult;
|
||||
/** Persist a new display order for the rail. IDs not in orderedIds keep their relative position at the end. */
|
||||
reorderCommunities: (orderedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
|
||||
@@ -242,6 +280,14 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const reorderCommunities = useCallback((orderedIds: string[]) => {
|
||||
setCommunitiesState((prev) => {
|
||||
const next = applyCommunitiesOrder(prev, orderedIds);
|
||||
saveCommunities(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
communities,
|
||||
activeCommunity,
|
||||
@@ -252,5 +298,6 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
switchCommunity,
|
||||
reconnectCommunity,
|
||||
updateCommunity,
|
||||
reorderCommunities,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +215,14 @@ export function MachineOnboardingFlow({
|
||||
back: () =>
|
||||
setPage(identityWasImported ? "key-import" : "backup"),
|
||||
next: (runtimeIds) => {
|
||||
setReadyRuntimeIds(Array.from(runtimeIds));
|
||||
const ids = Array.from(runtimeIds);
|
||||
setReadyRuntimeIds(ids);
|
||||
// Harness install can fail (Windows/PATH/network). Don't soft-lock
|
||||
// onboarding — users can finish setup later in Settings → Agents.
|
||||
if (ids.length === 0) {
|
||||
complete(selectedPubkey ?? undefined);
|
||||
return;
|
||||
}
|
||||
setPage("config");
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -687,6 +687,16 @@ function SetupStepContent({
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
data-testid="onboarding-setup-skip"
|
||||
onClick={() => actions.next([])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -33,6 +50,7 @@ type CommunityRailProps = {
|
||||
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemoveCommunity: (id: string) => void;
|
||||
onReorderCommunities: (orderedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const MAX_BADGE = 99;
|
||||
@@ -76,6 +94,9 @@ function CommunityButton({
|
||||
iconUrl,
|
||||
onSwitch,
|
||||
menu,
|
||||
dragListeners,
|
||||
dragAttributes,
|
||||
isDragging,
|
||||
}: {
|
||||
community: Community;
|
||||
isActive: boolean;
|
||||
@@ -83,6 +104,9 @@ function CommunityButton({
|
||||
iconUrl: string | null;
|
||||
onSwitch: () => void;
|
||||
menu: React.ReactNode;
|
||||
dragListeners?: React.HTMLAttributes<HTMLElement>;
|
||||
dragAttributes?: React.HTMLAttributes<HTMLElement>;
|
||||
isDragging?: boolean;
|
||||
}) {
|
||||
const { mentionCount, showBadge, showDot, pending, badgeLabel } =
|
||||
communityRailIndicators(unread);
|
||||
@@ -101,10 +125,15 @@ function CommunityButton({
|
||||
<button
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
aria-label={tooltipLabel}
|
||||
className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center touch-none outline-hidden focus:outline-none focus-visible:outline-none",
|
||||
isDragging && "opacity-30",
|
||||
)}
|
||||
data-testid={`community-rail-button-${community.id}`}
|
||||
onClick={onSwitch}
|
||||
type="button"
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -154,6 +183,105 @@ function CommunityButton({
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityDragOverlay({
|
||||
community,
|
||||
iconUrl,
|
||||
}: {
|
||||
community: Community;
|
||||
iconUrl: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-9 w-9 cursor-grabbing items-center justify-center overflow-hidden rounded-xl bg-primary text-xs font-semibold text-primary-foreground opacity-90 shadow-lg ring-1 ring-sidebar-border"
|
||||
data-buzz-flat
|
||||
>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={iconUrl}
|
||||
/>
|
||||
) : (
|
||||
getInitials(community.name) || "🐝"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCommunityButton({
|
||||
community,
|
||||
activeCommunityId,
|
||||
iconsByCommunity,
|
||||
unreadByCommunity,
|
||||
onSwitchCommunity,
|
||||
onMarkAllRead,
|
||||
onSetEditingCommunity,
|
||||
}: {
|
||||
community: Community;
|
||||
activeCommunityId: string | null;
|
||||
iconsByCommunity: Record<string, string | null | undefined>;
|
||||
unreadByCommunity: Record<string, CommunityUnreadState>;
|
||||
onSwitchCommunity: (id: string) => void;
|
||||
onMarkAllRead: (community: Community) => void;
|
||||
onSetEditingCommunity: (community: Community) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: community.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<CommunityButton
|
||||
community={community}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
isActive={community.id === activeCommunityId}
|
||||
isDragging={isDragging}
|
||||
menu={
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onMarkAllRead(community)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Copy relay URL
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSetEditingCommunity(community)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Community settings
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
}
|
||||
onSwitch={() => onSwitchCommunity(community.id)}
|
||||
unread={
|
||||
unreadByCommunity[community.id] ?? {
|
||||
hasUnread: false,
|
||||
state: "unknown",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord/Slack-style vertical rail of communities on the far left of the app.
|
||||
* Shows a mention-count badge for inactive communities (observed via
|
||||
@@ -169,6 +297,7 @@ export function CommunityRail({
|
||||
onAddCommunity,
|
||||
onUpdateCommunity,
|
||||
onRemoveCommunity,
|
||||
onReorderCommunities,
|
||||
}: CommunityRailProps) {
|
||||
const { unreadByCommunity, markCommunityRead } = useCommunityUnread(
|
||||
communities,
|
||||
@@ -179,10 +308,39 @@ export function CommunityRail({
|
||||
const { markAllChannelsRead } = useAppShell();
|
||||
const [editingCommunity, setEditingCommunity] =
|
||||
React.useState<Community | null>(null);
|
||||
const [draggingId, setDraggingId] = React.useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
if (communities.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const communityIds = communities.map((c) => c.id);
|
||||
const draggingCommunity = draggingId
|
||||
? (communities.find((c) => c.id === draggingId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setDraggingId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setDraggingId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = communityIds.indexOf(active.id as string);
|
||||
const newIdx = communityIds.indexOf(over.id as string);
|
||||
if (oldIdx !== -1 && newIdx !== -1) {
|
||||
onReorderCommunities(arrayMove(communityIds, oldIdx, newIdx));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = (community: Community) => {
|
||||
if (community.id === activeCommunityId) {
|
||||
markAllChannelsRead();
|
||||
@@ -211,42 +369,37 @@ export function CommunityRail({
|
||||
)}
|
||||
data-testid="community-rail"
|
||||
>
|
||||
{communities.map((community) => (
|
||||
<CommunityButton
|
||||
key={community.id}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
isActive={community.id === activeCommunityId}
|
||||
menu={
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleMarkAllRead(community)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Copy relay URL
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => setEditingCommunity(community)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Community settings
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
}
|
||||
onSwitch={() => onSwitchCommunity(community.id)}
|
||||
unread={
|
||||
unreadByCommunity[community.id] ?? {
|
||||
hasUnread: false,
|
||||
state: "unknown",
|
||||
}
|
||||
}
|
||||
community={community}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext
|
||||
items={communityIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{communities.map((community) => (
|
||||
<SortableCommunityButton
|
||||
key={community.id}
|
||||
activeCommunityId={activeCommunityId}
|
||||
community={community}
|
||||
iconsByCommunity={iconsByCommunity}
|
||||
unreadByCommunity={unreadByCommunity}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onSetEditingCommunity={setEditingCommunity}
|
||||
onSwitchCommunity={onSwitchCommunity}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{draggingCommunity ? (
|
||||
<CommunityDragOverlay
|
||||
community={draggingCommunity}
|
||||
iconUrl={iconsByCommunity[draggingCommunity.id] ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -393,6 +393,25 @@ type E2eConfig = {
|
||||
* spec can interleave edits and exercise the mid-save race handling.
|
||||
*/
|
||||
globalConfigSaveDelayMs?: number;
|
||||
/**
|
||||
* Override the `discover_agent_models` mock response. When set, returns
|
||||
* this catalog instead of the default per-harness model list.
|
||||
*/
|
||||
discoverAgentModels?: {
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
description?: string | null;
|
||||
}>;
|
||||
supportsSwitching: boolean;
|
||||
agentDefaultModel?: string | null;
|
||||
selectedModel?: string | null;
|
||||
};
|
||||
/**
|
||||
* When set, `discover_agent_models` throws with this message instead of
|
||||
* returning a catalog.
|
||||
*/
|
||||
discoverAgentModelsError?: string;
|
||||
};
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
@@ -10136,6 +10155,25 @@ export function maybeInstallE2eTauriMocks() {
|
||||
supportsSwitching: false,
|
||||
};
|
||||
case "discover_agent_models": {
|
||||
const discoverError = activeConfig?.mock?.discoverAgentModelsError;
|
||||
if (discoverError) {
|
||||
throw new Error(discoverError);
|
||||
}
|
||||
const discoverOverride = activeConfig?.mock?.discoverAgentModels;
|
||||
if (discoverOverride) {
|
||||
return {
|
||||
agentName: "mock-agent",
|
||||
agentVersion: "0.0.0",
|
||||
models: discoverOverride.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description ?? null,
|
||||
})),
|
||||
agentDefaultModel: discoverOverride.agentDefaultModel ?? null,
|
||||
selectedModel: discoverOverride.selectedModel ?? null,
|
||||
supportsSwitching: discoverOverride.supportsSwitching,
|
||||
};
|
||||
}
|
||||
const input = (
|
||||
payload as {
|
||||
input?: { agentCommand?: string; provider?: string };
|
||||
|
||||
Reference in New Issue
Block a user