Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing

This commit is contained in:
kenny lopez
2026-07-23 10:49:11 -07:00
44 changed files with 3282 additions and 399 deletions
+1
View File
@@ -714,6 +714,7 @@ export function AppShell() {
}
onAddCommunity={addCommunityDialog.openDialog}
onRemoveCommunity={communitiesHook.removeCommunity}
onReorderCommunities={communitiesHook.reorderCommunities}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
communities={communitiesHook.communities}
+20 -6
View File
@@ -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"
+190 -37
View File
@@ -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
+38
View File
@@ -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 };