diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index fd7550eff0..bf2d8e7c22 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -1,9 +1,19 @@ import * as React from "react"; -import { ChevronDown, Cpu } from "lucide-react"; +import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { cn } from "@/shared/lib/cn"; +import { + AgentConfigTextInput, + AgentDropdownSelect, + type AgentDropdownOption, +} from "@/features/agents/ui/agentConfigControls"; +import { + CUSTOM_MODEL_DROPDOWN_VALUE, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "@/features/agents/ui/agentConfigOptions"; import { meshStartNode, @@ -17,10 +27,6 @@ import type { MeshModelOption, MeshNodeStatus, } from "@/shared/api/tauriMesh"; -import { - SettingsOptionGroup, - SettingsOptionRow, -} from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { classifyModelRef } from "../classifyModelRef"; import { @@ -36,6 +42,20 @@ import { deriveServingIndicator } from "../servingUsage"; const MODEL_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.model.v1"; const MAX_VRAM_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.max-vram-gb.v1"; +// Keep the Share compute controls visually and behaviorally aligned with the +// agent configuration fields. This is intentionally the same shell used by +// AgentDefaultsEditor rather than a local approximation of a select. +const MESH_SELECT_TRIGGER_CLASS = cn( + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + "h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60", +); + +const SHARE_COMPUTE_REVEAL_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + function readDraft(key: string): string { try { return window.localStorage.getItem(key) ?? ""; @@ -64,6 +84,7 @@ function writeDraft(key: string, value: string): void { * exposing implementation protocols or raw mesh controls. */ export function MeshComputeSettingsCard() { + const shouldReduceMotion = useReducedMotion(); const { status, error, refresh } = useMeshNodeStatus(); const [installedModels, setInstalledModels] = React.useState< MeshModelOption[] @@ -75,6 +96,7 @@ export function MeshComputeSettingsCard() { const [maxVramGb, setMaxVramGb] = React.useState(() => readDraft(MAX_VRAM_DRAFT_STORAGE_KEY), ); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false); const [actionInFlight, setActionInFlight] = React.useState(false); const [pendingAction, setPendingAction] = React.useState< @@ -163,6 +185,7 @@ export function MeshComputeSettingsCard() { const controlsDisabled = actionInFlight || (slotOccupied && !isConsuming); const refClass = classifyModelRef(modelInput); const canStart = refClass.kind !== "unknown" && !actionInFlight; + const showSharingControls = isSharing || pendingAction === "start"; async function handleToggle(next: boolean) { // Never let the Share switch tear down a consume session. The switch is @@ -204,12 +227,7 @@ export function MeshComputeSettingsCard() {
- Share this machine with your relay. When on, other members can run - their agents here. - - } + description="Share this machine with members of this relay so they can run agents here." /> {error ? ( @@ -226,8 +244,8 @@ export function MeshComputeSettingsCard() { ) : null} - - +
+
- - {servingIndicator.show ? ( -

- {servingIndicator.label} - {servingIndicator.detail ? ( - - {" "} - · {servingIndicator.detail} - - ) : null} -

+ {!isSharing ? ( + ) : null}
- +
+ + { + setModelInput(next); + writeDraft(MODEL_DRAFT_STORAGE_KEY, next); + }} + /> -
-
- ) : null} -
+ ) : null} + + ) : null} -
- setAdvancedOpen((e.target as HTMLDetailsElement).open) - } - open={advancedOpen} - > - - - Advanced - -
- - { - const next = e.target.value; - setMaxVramGb(next); - writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next); - }} - placeholder="No limit" - value={maxVramGb} - /> - {status?.consoleUrl ? ( -

- Debug console:{" "} - - {status.consoleUrl} - -

- ) : null} -
-
-
- -

- Only members of this relay can use this machine's shared compute. -

+ + {showSharingControls ? ( + +
+

Sharing

+
+ + {servingIndicator.show ? ( +

+ {servingIndicator.label} + {servingIndicator.detail ? ( + + {" "} + · {servingIndicator.detail} + + ) : null} +

+ ) : null} +
+
+
+ ) : null} +
+
); } @@ -465,100 +465,159 @@ const FIT_CLASS: Record = { }; /** - * Hardware-ranked curated model list (mesh-console's diagnose pattern). - * Click a row to fill the model field. Models too large for this machine are - * listed but disabled — honest about why, instead of hiding them. + * Share Compute's models use the agent configuration picker instead of a + * second, hand-rolled option system. The catalog remains hardware-aware, but + * its recommendations, installed models, and advanced choices now appear in + * the same searchable dropdown used when customizing an agent. */ -function CatalogPicker({ +function MeshModelPicker({ catalog, disabled, - onPick, - selected, + installedModels, + isCustomModelEditing, + model, + onCustomModelEditingChange, + onModelChange, }: { - catalog: MeshModelCatalog; + catalog: MeshModelCatalog | null; disabled: boolean; - onPick: (name: string) => void; - selected: string; + installedModels: readonly MeshModelOption[]; + isCustomModelEditing: boolean; + model: string; + onCustomModelEditingChange: (editing: boolean) => void; + onModelChange: (model: string) => void; }) { - const [expanded, setExpanded] = React.useState(false); - // Above the fold: the Buzz-curated picks (models known to work well with - // agents on shared compute). Below: everything else, as advanced options. - const curated = catalog.entries.filter((e) => e.curated); - const advanced = catalog.entries.filter((e) => !e.curated); - const visible = expanded ? catalog.entries : curated; + const options = React.useMemo(() => { + const seen = new Set(); + const catalogOptions = (catalog?.entries ?? []).map((entry) => { + seen.add(entry.name); + return { + disabled: entry.fit === "too_large", + label: , + value: entry.name, + }; + }); + const localOptions = installedModels.flatMap((installed) => { + if (seen.has(installed.id)) return []; + return [ + { + label: ( +
+ + {installed.name ?? installed.id} + + + Installed + +
+ ), + value: installed.id, + }, + ]; + }); + return [ + ...catalogOptions, + ...localOptions, + { label: "Custom model…", value: CUSTOM_MODEL_DROPDOWN_VALUE }, + ]; + }, [catalog?.entries, installedModels]); + const knownModel = options.some((option) => option.value === model.trim()); + const showCustomModelInput = + isCustomModelEditing || (model.trim().length > 0 && !knownModel); + const selectedValue = showCustomModelInput + ? CUSTOM_MODEL_DROPDOWN_VALUE + : model.trim(); + + function handleModelChange(next: string) { + if (next === CUSTOM_MODEL_DROPDOWN_VALUE) { + onCustomModelEditingChange(true); + return; + } + onCustomModelEditingChange(false); + onModelChange(next); + } + return ( -
+
+ + + {showCustomModelInput ? ( + { + // A stored custom ref starts out inferred rather than explicitly + // selected. Mark it as an active custom edit before applying a + // cleared value so the field stays mounted while it is replaced. + onCustomModelEditingChange(true); + onModelChange(event.target.value); + }} + placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main" + usePersonaInputStyle + value={model} + /> + ) : null}

- Recommended for this machine - {catalog.gpuName ? ` (${catalog.gpuName}, ` : " ("} - {catalog.vramDisplay} AI memory): + {catalog + ? `Recommended for this machine${catalog.gpuName ? ` (${catalog.gpuName}, ${catalog.vramDisplay} AI memory)` : ""}.` + : "Choose a model or enter a model reference or local file."}{" "} + Buzz downloads remote models when sharing starts.

-
    - {visible.map((entry) => { - const isSelected = entry.name === selected; - const tooLarge = entry.fit === "too_large"; - return ( -
  • - -
  • - ); - })} -
- {advanced.length > 0 ? ( - +
+ ); +} + +function MeshModelOptionLabel({ entry }: { entry: MeshCatalogEntry }) { + return ( +
+ {entry.name} + {entry.size} + + {FIT_LABEL[entry.fit]} + + {entry.recommended ? ( + + Recommended + + ) : null} + {entry.installed ? ( + + Installed + + ) : null} + {!entry.curated ? ( + + Advanced + ) : null}
); } function StatusLine({ + displayModel, isConsuming, + omitSharingVerb = false, pendingAction, status, }: { + displayModel?: string; isConsuming: boolean; + omitSharingVerb?: boolean; pendingAction: "start" | "stop" | null; status: MeshNodeStatus | null; }) { @@ -583,12 +642,10 @@ function StatusLine({ return

Checking status…

; } const { state, health, modelId, modelName } = status; - const modelLabel = modelName ?? modelId ?? ""; + const modelLabel = displayModel ?? modelName ?? modelId ?? ""; if (state === "off") { - return ( -

Not sharing right now.

- ); + return null; } if (state === "starting") { const reason = @@ -614,7 +671,9 @@ function StatusLine({ } return (

- Sharing{modelLabel ? ` ${modelLabel}` : ""} with relay members. + {omitSharingVerb ? "" : "Sharing"} + {modelLabel ? `${omitSharingVerb ? "" : " "}${modelLabel}` : ""} with + relay members.

); } diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 7ec1daa236..278dbeefb6 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -226,6 +226,7 @@ test.describe("global agent config screenshots", () => { await page .getByTestId("global-agent-default-harness-option-claude") .click(); + await waitForAnimations(page); await expect(page.getByTestId("global-agent-model")).toHaveText( /Default model/, ); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index b2e8fc28a9..df86893050 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -15,9 +15,11 @@ type E2eWindow = Window & { }) => void; }; -test("Share compute selects the curated default and starts and stops sharing", async ({ - page, -}) => { +test("Share compute chooses a model before sharing", async ({ page }) => { + const modelRef = "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"; + await page.addInitScript((model) => { + window.localStorage.setItem("buzz.mesh-compute.share.model.v1", model); + }, modelRef); await installMockBridge(page); await page.goto("/"); await openSettings(page, "compute"); @@ -26,19 +28,31 @@ test("Share compute selects the curated default and starts and stops sharing", a const toggle = page.getByTestId("mesh-share-compute-toggle"); const model = page.getByTestId("mesh-share-compute-model"); - await expect(card).toContainText("Not sharing right now"); - await expect(card).toContainText( - "Choose a suggested model below, or enter a model reference or local file", - ); - await expect(model).toHaveValue("Gemma-4-E4B-it-Q4_K_M"); + await expect(card).not.toContainText("Not sharing right now"); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toHaveCount(0); + await expect(model).toBeVisible(); await expect(toggle).toBeEnabled(); + + await toggle.click(); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toBeVisible(); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toBeVisible(); + await expect(model).toBeVisible(); await expect(card).toContainText( "Buzz downloads remote models when sharing starts", ); - - await toggle.click(); await expect(toggle).toBeChecked(); - await expect(card).toContainText("Sharing Gemma 4 E4B with relay members"); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toContainText("SmolLM2 135M with relay members"); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), @@ -53,13 +67,20 @@ test("Share compute selects the curated default and starts and stops sharing", a .toContainEqual({ command: "mesh_start_node", payload: { - request: { mode: "serve", modelId: "Gemma-4-E4B-it-Q4_K_M" }, + request: { mode: "serve", modelId: modelRef }, }, }); await toggle.click(); await expect(toggle).not.toBeChecked(); - await expect(card).toContainText("Not sharing right now"); + await expect(card).not.toContainText("Not sharing right now"); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toHaveCount(0); + await expect(model).toBeVisible(); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), @@ -97,16 +118,20 @@ test("a consuming client can switch to sharing its saved local model", async ({ const card = page.getByTestId("settings-mesh-share-compute"); const toggle = page.getByTestId("mesh-share-compute-toggle"); - const model = page.getByTestId("mesh-share-compute-model"); - await expect(card).toContainText( "This machine is currently using another member's shared compute", ); await expect(card).toContainText("Buzz may briefly restart"); await expect(toggle).not.toBeChecked(); - await expect(model).toBeEnabled(); - await expect(model).toHaveValue(localModel); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); await expect(toggle).toBeEnabled(); + const customModel = page.getByLabel("Custom model reference"); + await expect(customModel).toHaveValue(localModel); + await customModel.fill(""); + await expect(customModel).toBeVisible(); + await customModel.fill("hf://demo/replacement-model:Q4_K_M"); await toggle.click(); await expect(toggle).toBeChecked(); @@ -117,6 +142,8 @@ test("a consuming client can switch to sharing its saved local model", async ({ expect(commands.names).not.toContain("mesh_stop_node"); expect(commands.payloads).toContainEqual({ command: "mesh_start_node", - payload: { request: { mode: "serve", modelId: localModel } }, + payload: { + request: { mode: "serve", modelId: "hf://demo/replacement-model:Q4_K_M" }, + }, }); });