diff --git a/Cargo.lock b/Cargo.lock index a3ac30037..8dee6dd3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,6 +719,8 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sherpa-onnx", + "sherpa-onnx-sys", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -806,6 +808,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -4729,6 +4751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -5292,6 +5315,28 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.4" +dependencies = [ + "bzip2", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", +] + [[package]] name = "shlex" version = "2.0.1" @@ -6668,6 +6713,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index fc50185b9..2986ac272 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,7 @@ [workspace] -members = ["crates/pairing", "crates/plugin-manager", "crates/agent-controller", "crates/credential-store", "src-tauri"] +members = ["crates/pairing", "crates/plugin-manager", "crates/agent-controller", "crates/credential-store", "src-tauri", "vendor/sherpa-onnx-sys"] default-members = ["crates/plugin-manager"] resolver = "2" + +[patch.crates-io] +sherpa-onnx-sys = { path = "vendor/sherpa-onnx-sys" } diff --git a/NOTICE.md b/NOTICE.md index 06d1573f3..0f83f5ba7 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -87,3 +87,11 @@ Hermes Agent: the bundled `icons/hermes.png` is reused unchanged from Block Buzz cropped the border, padded it to square, resized to 64×64 and quantised it to 16 colours. The MIT license terms above also apply to this artwork, with the Nous Research copyright notice. + +## Model family brand marks + +DeepSeek, Grok, Kimi, Qwen, and Gemini SVG marks in +`src/shared/design-system/icons/ModelLogos.tsx` are adapted from +[Lobe Icons](https://github.com/lobehub/lobe-icons/tree/c385b2b8d1f9e19aa86e628d4e23c91ee1111a47/packages/static-svg/icons) +at revision `c385b2b8d1f9e19aa86e628d4e23c91ee1111a47`. SVG paths are unchanged; React sizing and accessibility use the shared icon gateway. +The MIT license is preserved in [LOBE-LICENSE.txt](src/shared/design-system/icons/LOBE-LICENSE.txt). diff --git a/crates/agent-controller/src/runtime.rs b/crates/agent-controller/src/runtime.rs index e0b0198d2..0c20aa2a6 100644 --- a/crates/agent-controller/src/runtime.rs +++ b/crates/agent-controller/src/runtime.rs @@ -814,6 +814,29 @@ impl Controller { model_overridden: environment.contains_key("GOOSE_MODEL"), }) } + /// Native-only instruction drafting follows saved Agent defaults, never the + /// unsaved agent or the process environment. Reuse launch resolution. + pub fn instruction_model_context(&self) -> Result<(String, ModelContext)> { + let saved = self.store.defaults()?; + if saved.harness != "buzz-agent" { + return Err("Voice instruction drafting currently needs Buzz Agent with Databricks v2 in Agent defaults".into()); + } + let harness = crate::HarnessEdit { + command: "buzz-agent".into(), + args: vec![], + provider: saved.provider, + model: saved.model, + databricks: None, + integration: None, + configuration: None, + }; + let resolved = crate::build_defaults().resolve(&harness, &saved.environment); + let context = model_context(&resolved, &saved.environment)?; + if resolved.model.trim().is_empty() { + return Err("Choose a model in Agent defaults before drafting instructions".into()); + } + Ok((resolved.model, context)) + } pub fn draft_model_context(edit: AgentEdit) -> Result { let environment = draft_environment(edit.environment); model_context(&edit.harness, &environment) diff --git a/crates/agent-controller/src/runtime/tests.rs b/crates/agent-controller/src/runtime/tests.rs index 092d4043f..61cd82432 100644 --- a/crates/agent-controller/src/runtime/tests.rs +++ b/crates/agent-controller/src/runtime/tests.rs @@ -4260,3 +4260,33 @@ fn codex_saved_adapter_survives_global_install_without_silent_fallback() { .codex_model_context(&saved.id, saved.revision, edit) .is_err()); } + +#[test] +fn instruction_model_uses_saved_defaults_and_native_overrides_without_writes() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(dir.path().join("config")).unwrap(); + let mut defaults = crate::agent_defaults::AgentDefaults { + harness: "buzz-agent".into(), + provider: "databricks_v2".into(), + model: "saved-model".into(), + ..Default::default() + }; + defaults + .environment + .insert("DATABRICKS_HOST".into(), "https://workspace.example".into()); + defaults + .environment + .insert("BUZZ_AGENT_MODEL".into(), "override-model".into()); + store.save_defaults(&defaults).unwrap(); + let controller = Controller::new( + store, + Arc::new(Memory), + Err("No runtime".into()), + dir.path().join("ownership"), + ); + let (model, context) = controller.instruction_model_context().unwrap(); + assert_eq!(model, "override-model"); + assert_eq!(context.host.as_deref(), Some("https://workspace.example")); + assert_eq!(controller.store.defaults().unwrap().model, "saved-model"); + assert!(controller.store.agents().unwrap().is_empty()); +} diff --git a/docs/agent-control.md b/docs/agent-control.md index bab2118c6..03fb0cc5f 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -40,7 +40,14 @@ saves the agent, starts it, then publishes its profile. A failed Start or profil publication retains the saved identity and offers a retry for that step; it never creates another identity. A native Start response can confirm a saved agent while reporting that its process could not run. During Create, Start, or profile setup, -**Close** leaves the operation running and keeps recovery **Stop** available through **Manage agent**. Late completion never closes a subsequently opened dialog. If an operation +**Close** is disabled while setup is pending, and **Stop** remains available inside +the setup dialog for each known agent, using the controller's recovery admission. +After Stop is used, recovery feedback remains mounted when setup settles, until +explicit dismissal. Successful setup shows completion with the existing Close +action instead of offering another profile retry. Escape and backdrop clicks retain a dirty draft even after +validation or setup failure. Once setup settles into an error, +**Close** is available even if status refresh still fails. Late completion after a +host unmount never closes a subsequently opened dialog. If an operation cannot be confirmed, refresh status before repeating it. Create is blocked with an explanation if this app’s runtime is unavailable; @@ -543,6 +550,11 @@ the form; Save first persists the exact native ID/revision, then publishes to th agent's saved community without restarting it. Older native hosts without `avatarEditingAvailable` retain the display-only avatar. +New agent creation offers the bundled Gloopies, Fuzzies and Figgies poster packs. +Picker previews are local; the selected versioned HTTPS poster URL is saved through +`AgentEdit.picture`, so published profiles remain usable by other clients. Initials +remain available. See `src/features/agents/avatar-packs/README.md` for provenance. + An omitted picture preserves the saved override; an empty string explicitly removes it. A changed picture or name durably marks `profilePending` and remains persisted across restart/reload until publication is confirmed. A successful profile publication @@ -1166,3 +1178,26 @@ controls must remain reachable. Linked profiles remain visible on identity cards Before starting an imported identity, stop the old agent and disable its automatic startup in the old application. Do not run duplicate copies of the same identity. + +### Voice instruction drafts + +The desktop create form offers Text and Voice. Voice reuses the voice-note +recorder, limits recordings to two minutes, and encodes mono 16 kHz PCM WAV. +The native host validates size and format before local Parakeet inference via +sherpa-onnx. Audio remains in memory on this device; it is never uploaded. +The current English model is reused from +`~/.buzz/models/parakeet-tdt-ctc-110m-en/{model.int8.onnx,tokens.txt}`. A device +without that model gets an explicit error and can continue with Text; automatic +model provisioning is not implemented. + +Only the transcript is sent to the saved Agent defaults model, using the app's +isolated Databricks OAuth connection. Drafting currently supports Buzz Agent +with Databricks v2 defaults and follows native environment selector precedence. +The pinned runtime's model capability manifest selects the gateway wire route. +Other default providers return an explicit unsupported-configuration error. +The transcript remains editable for retry. Generated instructions are a draft; +only Use instructions (or Replace instructions with draft) changes the form. +Closing or switching input mode discards late results, and never creates an +agent. Native work is bounded to one transcription and one draft at a time; +model requests time out after two minutes. Cancelling UI work does not interrupt +already-running local inference or a model request. diff --git a/docs/agents.md b/docs/agents.md index 795fa86f9..1065f2ee5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -19,14 +19,20 @@ add-existing membership, Save/recovery and all runner management are out of V1. - Only definition ID/name, identity public key/name/definition link, and optional avatar artwork leave the host. Prompts, configuration, credentials and execution receipts are not projected. This is local library evidence, **not verified ownership**. -- The main individual-agent grid is reserved for native local agents; teams follow - it, with old/importable/relay inventory below. Browser-only hosts cannot establish +- The main individual-agent grid includes native local agents and the current + account’s existing relay identities; teams follow it. Only configured local + agents receive execution controls. Import candidates live in the Import tab; + **Your agents** is the default tab. **Import** opens the existing installation + import flow, and **Browse** reads explicitly shared kind-30175 definitions from + the current relay, with read-only details and paginated discovery. The page + header uses the same navigation styling as Channels. The page has no Refresh + control and omits the legacy-library partial-source warning. Browser-only hosts cannot establish current local custody: their read-only library lives in a collapsed **Other agents** section below teams, not in the individual-agent grid. - The compatibility library shows one compact row per exact identity. Identity details are available from its overflow button, not an exposed Public key link. Explicit profile links still supply artwork; names never join identities. - Profiles with no linked identity appear separately; an archived identity does + Profiles with no linked identity are omitted; an archived identity does not become an empty profile. - Only distinct keys with the same displayed name need a short npub suffix. Names alone never create a profile group. Suffix collisions extend deterministically using diff --git a/docs/screenshots/agent-creation/agents.png b/docs/screenshots/agent-creation/agents.png new file mode 100644 index 000000000..7972b4de0 Binary files /dev/null and b/docs/screenshots/agent-creation/agents.png differ diff --git a/docs/screenshots/agent-creation/avatar-editor.png b/docs/screenshots/agent-creation/avatar-editor.png new file mode 100644 index 000000000..199bdc534 Binary files /dev/null and b/docs/screenshots/agent-creation/avatar-editor.png differ diff --git a/docs/screenshots/agent-creation/create-agent.png b/docs/screenshots/agent-creation/create-agent.png new file mode 100644 index 000000000..534dc1332 Binary files /dev/null and b/docs/screenshots/agent-creation/create-agent.png differ diff --git a/scripts/avatar-codecs.test.ts b/scripts/avatar-codecs.test.ts new file mode 100644 index 000000000..a99e52dae --- /dev/null +++ b/scripts/avatar-codecs.test.ts @@ -0,0 +1,40 @@ +import { build } from "vite"; +import { expect, it } from "vitest"; +import { avatarCodecs } from "./avatar-codecs"; + +it.each([ + ["macos", 0, 49], + ["windows", 49, 0], + ["linux", 49, 0], + [undefined, 49, 49], +] as const)( + "emits only supported native avatar codecs for %s", + async (platform, webm, mp4) => { + const result = await build({ + configFile: false, + logLevel: "silent", + plugins: [avatarCodecs(platform)], + build: { + write: false, + minify: false, + assetsInlineLimit: 0, + rollupOptions: { + input: "src/features/agents/avatar-packs.ts", + preserveEntrySignatures: "strict", + }, + }, + }); + if (Array.isArray(result) || !("output" in result)) + throw new Error("Unexpected build result"); + const assets = result.output.filter((item) => item.type === "asset"); + expect( + assets.filter((item) => item.fileName.endsWith(".webm")), + ).toHaveLength(webm); + expect( + assets.filter((item) => item.fileName.endsWith(".mp4")), + ).toHaveLength(mp4); + expect( + assets.filter((item) => item.fileName.endsWith(".png")), + ).toHaveLength(49); + }, +); diff --git a/scripts/avatar-codecs.ts b/scripts/avatar-codecs.ts new file mode 100644 index 000000000..ab646ae1e --- /dev/null +++ b/scripts/avatar-codecs.ts @@ -0,0 +1,30 @@ +import type { Plugin } from "vite"; + +/** Keep native installs offline-capable without shipping the other engine's codec. */ +export function avatarCodecs(platform: string | undefined): Plugin { + const excluded = + platform === "macos" + ? "webm" + : platform === "windows" || platform === "linux" + ? "mp4" + : undefined; + const empty = "\0unused-avatar-codec"; + return { + name: "avatar-codecs", + enforce: "pre", + resolveId(source, importer) { + if ( + excluded && + importer + ?.replaceAll("\\", "/") + .endsWith("/features/agents/avatar-packs.ts") && + source.replaceAll("\\", "/").includes("/avatar-packs/") && + source.split("?")[0]?.endsWith(`.${excluded}`) + ) + return empty; + }, + load(id) { + if (id === empty) return "export default undefined;"; + }, + }; +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9ed8c7a8..0c756840e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,9 @@ tokio = { version = "1", features = ["sync", "time", "macros", "process", "io-ut sha2 = "0.10" hmac = "0.12" base64 = "0.22" +sherpa-onnx = "=1.13.4" +# Keep the C bindings matched to the safe wrapper. +sherpa-onnx-sys = "=1.13.4" reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream"] } bytes = "1" url = "2" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 0d2f37ba5..377127f0d 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -1,3 +1,3 @@ -NSMicrophoneUsageDescriptionBuzz uses your microphone to record voice notes you choose to send. +NSMicrophoneUsageDescriptionBuzz uses your microphone for voice notes and local transcription of agent instructions. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ec00cd6b2..4a8d42d31 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -140,6 +140,8 @@ fn main() { "agent_control_local_clone_settings", "agent_control_clone_settings", "agent_control_use_here", + "agent_instruction_transcribe", + "agent_instruction_generate", "agent_models_begin", "agent_models_cancel", "agent_models_run", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fac28ed35..0630d98ba 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -111,6 +111,8 @@ "allow-agent-control-local-clone-settings", "allow-agent-control-clone-settings", "allow-agent-control-use-here", + "allow-agent-instruction-transcribe", + "allow-agent-instruction-generate", "allow-agent-models-begin", "allow-agent-models-cancel", "allow-agent-models-run", diff --git a/src-tauri/src/agent_instructions.rs b/src-tauri/src/agent_instructions.rs new file mode 100644 index 000000000..404173a97 --- /dev/null +++ b/src-tauri/src/agent_instructions.rs @@ -0,0 +1,335 @@ +//! Short, local recordings → local speech recognition → an explicitly requested +//! instruction draft. Audio never leaves this host and no agent is created here. +use base64::Engine; +use serde_json::{json, Value}; +use std::{path::Path, sync::Arc, time::Duration}; +use tauri::Manager; + +const MAX_SAMPLES: usize = 16_000 * 120; +static TRANSCRIBING: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); +static GENERATING: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); +const PROMPT: &str = "Turn the user's spoken description into clear, concise system instructions for their agent. Preserve their intent, responsibilities, tone and constraints. Do not invent integrations, tools, permissions or facts. Do not carry out the described task. Return only the instructions, without a preamble or code fence."; + +// Accept only the bounded PCM format emitted by our recorder. Do not accept a +// path from the renderer or let arbitrary audio invoke a native codec. +fn decode_wav(bytes: &[u8]) -> Result, String> { + let invalid = || "Recording must be a 16 kHz mono PCM WAV, up to two minutes".to_owned(); + if bytes.len() < 46 || bytes.len() > 44 + MAX_SAMPLES * 2 { + return Err(invalid()); + } + let u16_at = |i| u16::from_le_bytes([bytes[i], bytes[i + 1]]); + let u32_at = |i| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()) as usize; + if &bytes[..4] != b"RIFF" + || &bytes[8..16] != b"WAVEfmt " + || u32_at(4) != bytes.len() - 8 + || u32_at(16) != 16 + || u16_at(20) != 1 + || u16_at(22) != 1 + || u32_at(24) != 16_000 + || u32_at(28) != 32_000 + || u16_at(32) != 2 + || u16_at(34) != 16 + || &bytes[36..40] != b"data" + || u32_at(40) != bytes.len() - 44 + || (bytes.len() - 44) % 2 != 0 + { + return Err(invalid()); + } + Ok(bytes[44..] + .chunks_exact(2) + .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0) + .collect()) +} + +fn transcribe(directory: &Path, samples: &[f32]) -> Result { + use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig}; + if !directory.join("model.int8.onnx").is_file() || !directory.join("tokens.txt").is_file() { + return Err( + "The local Parakeet speech model is not installed on this device. Use Text for now." + .into(), + ); + } + let mut config = OfflineRecognizerConfig::default(); + config.model_config.nemo_ctc.model = Some( + directory + .join("model.int8.onnx") + .to_string_lossy() + .into_owned(), + ); + config.model_config.tokens = Some(directory.join("tokens.txt").to_string_lossy().into_owned()); + config.model_config.num_threads = 2; + let recognizer = + OfflineRecognizer::create(&config).ok_or("Could not load the local speech model")?; + let mut words = Vec::new(); + // Bound inference memory like Sprout's local speech pipeline. + for chunk in samples.chunks(16_000 * 30) { + let stream = recognizer.create_stream(); + stream.accept_waveform(16_000, chunk); + recognizer.decode(&stream); + if let Some(result) = stream.get_result() { + let text = result.text.trim(); + if !text.is_empty() { + words.push(text.to_owned()); + } + } + } + let text = words.join(" "); + if text.is_empty() { + Err("No speech was detected. Try recording again.".into()) + } else { + Ok(text) + } +} + +#[tauri::command] +pub(crate) async fn agent_instruction_transcribe( + app: tauri::AppHandle, + audio: String, +) -> Result { + let permit = TRANSCRIBING + .try_acquire() + .map_err(|_| "A recording is already being transcribed. Try again shortly.")?; + if audio.len() > (44 + MAX_SAMPLES * 2).div_ceil(3) * 4 { + return Err("Recording exceeds two minutes".into()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(audio) + .map_err(|_| "Invalid recording")?; + let samples = decode_wav(&bytes)?; + let directory = app + .path() + .home_dir() + .map_err(|_| "Could not locate local speech models")? + .join(".buzz/models/parakeet-tdt-ctc-110m-en"); + // Permit lives in the blocking job, so a closed UI cannot admit overlapping inference. + tokio::task::spawn_blocking(move || { + let _permit = permit; + transcribe(&directory, &samples) + }) + .await + .map_err(|_| "Local transcription stopped unexpectedly".to_owned())? +} + +pub(crate) fn request(model: &str, transcript: &str) -> (&'static str, Value) { + use buzz_agent::model_capabilities::{resolve, DatabricksV2Route}; + match resolve("databricks_v2", model).databricks_v2_wire_route { + DatabricksV2Route::OpenaiResponses => ( + "/ai-gateway/openai/v1/responses", + json!({"model":model,"instructions":PROMPT,"input":transcript,"max_output_tokens":4096,"store":false}), + ), + DatabricksV2Route::AnthropicMessages => ( + "/ai-gateway/anthropic/v1/messages", + json!({"model":model,"system":PROMPT,"messages":[{"role":"user","content":transcript}],"max_tokens":4096}), + ), + _ => ( + "/ai-gateway/mlflow/v1/chat/completions", + json!({"model":model,"messages":[{"role":"system","content":PROMPT},{"role":"user","content":transcript}],"max_tokens":4096}), + ), + } +} +fn response(value: &Value) -> Result { + if value["status"] == "incomplete" + || value["stop_reason"] == "max_tokens" + || value["choices"][0]["finish_reason"] == "length" + { + return Err("The instruction draft was cut short. Try a shorter description.".into()); + } + let mut text = Vec::new(); + if let Some(content) = value["choices"][0]["message"]["content"].as_str() { + text.push(content); + } + if let Some(content) = value["content"].as_array() { + for item in content { + if item["type"] == "text" { + if let Some(t) = item["text"].as_str() { + text.push(t); + } + } + } + } + if let Some(output) = value["output"].as_array() { + for message in output { + if message["type"] == "message" { + if let Some(content) = message["content"].as_array() { + for item in content { + if item["type"] == "output_text" { + if let Some(t) = item["text"].as_str() { + text.push(t); + } + } + } + } + } + } + } + let text = text.join("\n").trim().to_owned(); + if text.is_empty() || text.len() > 32_000 { + Err("The model did not return an instruction draft. Try again.".into()) + } else { + Ok(text) + } +} + +#[tauri::command] +pub(crate) async fn agent_instruction_generate( + agents: tauri::State<'_, crate::agents::AgentHost>, + models: tauri::State<'_, crate::agent_models::ModelHost>, + transcript: String, +) -> Result { + let _permit = GENERATING + .try_acquire() + .map_err(|_| "An instruction draft is still being prepared. Try again shortly.")?; + if transcript.trim().is_empty() || transcript.len() > 24_000 { + return Err("Describe your agent in up to two minutes".into()); + } + let (model, context) = agents.instruction_model_context().await?; + let workspace = buzz_agent_controller::connection::origin( + context + .host + .as_deref() + .ok_or("Set the workspace in Agent defaults")?, + )?; + let auth = models.instruction_auth(&workspace)?; + tokio::time::timeout( + Duration::from_secs(120), + complete(&workspace, &model, &transcript, auth), + ) + .await + .map_err(|_| "Instruction drafting timed out. Your transcript is ready to retry.".to_owned())? +} +async fn complete( + workspace: &str, + model: &str, + transcript: &str, + auth: Arc, +) -> Result { + use buzz_agent::auth::TokenSource; + use futures_util::StreamExt; + let (path, body) = request(model, transcript); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(100)) + .build() + .map_err(|_| "Could not prepare the model request")?; + let mut token = auth + .bearer_no_browser() + .await + .map_err(|_| "Connect Databricks in the model picker, then retry your transcript")?; + for attempt in 0..2 { + let reply = client + .post(format!("{workspace}{path}")) + .bearer_auth(&token) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(body.to_string()) + .send() + .await + .map_err(|_| { + "Could not reach your default model. Your transcript is ready to retry." + })?; + if reply.status() == reqwest::StatusCode::UNAUTHORIZED && attempt == 0 { + token = auth + .refresh_now(&token) + .await + .map_err(|_| "Reconnect Databricks in the model picker, then retry")?; + continue; + } + if !reply.status().is_success() { + return Err(format!( + "Your default model returned {}. Your transcript is ready to retry.", + reply.status().as_u16() + )); + } + let mut stream = reply.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "The model response was interrupted")?; + if bytes.len() + chunk.len() > 1_048_576 { + return Err("The model response was too large".into()); + } + bytes.extend_from_slice(&chunk); + } + return response( + &serde_json::from_slice(&bytes) + .map_err(|_| "The model returned an unreadable response")?, + ); + } + Err("Reconnect Databricks, then retry".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn validates_recording_before_inference() { + assert!(decode_wav(&[]).is_err()); + assert!(decode_wav(&vec![0; 44 + MAX_SAMPLES * 2 + 2]).is_err()); + let mut wav = Vec::new(); + wav.extend(b"RIFF"); + wav.extend(38u32.to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(1u16.to_le_bytes()); + wav.extend(1u16.to_le_bytes()); + wav.extend(16000u32.to_le_bytes()); + wav.extend(32000u32.to_le_bytes()); + wav.extend(2u16.to_le_bytes()); + wav.extend(16u16.to_le_bytes()); + wav.extend(b"data"); + wav.extend(2u32.to_le_bytes()); + wav.extend(16384i16.to_le_bytes()); + assert_eq!(decode_wav(&wav).unwrap(), vec![0.5]); + wav[24] = 1; + assert!(decode_wav(&wav).is_err()); + } + #[test] + fn routes_default_models_and_extracts_only_final_text() { + assert!(request("system.ai.gpt-6-1-sol", "description") + .0 + .ends_with("/responses")); + assert!(request("system.ai.claude-opus-4-6", "description") + .0 + .ends_with("/messages")); + assert_eq!(response(&json!({"output":[{"type":"reasoning","content":[{"text":"hidden"}]},{"type":"message","content":[{"type":"output_text","text":"Be helpful."}]}]})).unwrap(), "Be helpful."); + assert!(response(&json!({"status":"incomplete","output":[]})).is_err()); + assert_eq!( + response(&json!({"content":[{"type":"text","text":"Instructions"}]})).unwrap(), + "Instructions" + ); + } + #[tokio::test] + #[ignore = "explicit live default-model check; requires app-isolated sign-in"] + async fn connected_default_drafts_instructions() { + let root = std::path::PathBuf::from(std::env::var("BUZZ_INSTRUCTION_TEST_ROOT").unwrap()); + let workspace = std::env::var("BUZZ_INSTRUCTION_TEST_WORKSPACE").unwrap(); + let model = std::env::var("BUZZ_INSTRUCTION_TEST_MODEL").unwrap(); + let host = crate::agent_models::ModelHost::new(Ok(root)); + let auth = host.instruction_auth(&workspace).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(120), complete(&workspace, &model, + "Help me plan weekend walks. Ask about my available time and suggest a short route. Keep replies concise.", auth)).await.unwrap().unwrap(); + assert!(result.to_lowercase().contains("walk")); + println!( + "Default-model instruction drafting succeeded ({} characters)", + result.len() + ); + } + #[test] + #[ignore = "requires the locally installed Parakeet model and its sample"] + fn installed_model_transcribes_sample() { + let dir = std::path::PathBuf::from(std::env::var("HOME").unwrap()) + .join(".buzz/models/parakeet-tdt-ctc-110m-en"); + let file = std::fs::read_dir(dir.join("test_wavs")) + .unwrap() + .find_map(|e| { + let p = e.ok()?.path(); + (p.extension()? == "wav").then_some(p) + }) + .unwrap(); + let bytes = std::fs::read(file).unwrap(); + // Upstream fixture has a conventional PCM header. + let samples = decode_wav(&bytes).unwrap(); + let text = transcribe(&dir, &samples).unwrap(); + assert!(!text.is_empty()); + println!("Local fixture transcript: {text}"); + } +} diff --git a/src-tauri/src/agent_models.rs b/src-tauri/src/agent_models.rs index f646a14cc..ecf4e8d36 100644 --- a/src-tauri/src/agent_models.rs +++ b/src-tauri/src/agent_models.rs @@ -221,6 +221,23 @@ impl ModelHost { }); receive.await.map_err(|_| CANCELLED.to_owned())? } + pub(crate) fn instruction_auth( + &self, + workspace: &str, + ) -> Result, String> { + struct Headless; + impl BrowserOpener for Headless { + fn open(&self, _: &str) -> Result<(), String> { + Err("Connect Databricks in the model picker first".into()) + } + } + Ok(RuntimeConnection::new( + workspace.to_owned(), + &self.cache(workspace)?, + Arc::new(Headless), + )? + .auth) + } fn cache(&self, _host: &str) -> Result { let state = self.state.lock().map_err(|_| CANCELLED)?; oauth_root(&state.root.clone()?) diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index aee86fc12..4f0b9355d 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -848,6 +848,14 @@ impl AgentHost { pub(crate) async fn ensure_open(&self) -> Result<(), String> { run(self.clone(), |_| Ok(())).await } + pub(crate) async fn instruction_model_context( + &self, + ) -> Result<(String, buzz_agent_controller::ModelContext), String> { + run(self.clone(), |host| { + host.controller.instruction_model_context() + }) + .await + } pub(crate) async fn inherited_workspace(&self) -> Result, String> { run(self.clone(), |host| host.controller.inherited_workspace()).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e2d6fc75..6c1107db9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use browser::{ browser_action, browser_attach, browser_detach, browser_navigate, browser_set_bounds, browser_status, }; +mod agent_instructions; mod agent_models; mod agents; mod app_agents; @@ -541,6 +542,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen agent_control_clone_settings, agent_control_import_preview, agent_control_import_commit, + agent_instructions::agent_instruction_transcribe, + agent_instructions::agent_instruction_generate, agent_models_begin, agent_models_cancel, agent_models_run, diff --git a/src/app/shell/AppShell.test.tsx b/src/app/shell/AppShell.test.tsx index d056ff5f3..0c215c3bc 100644 --- a/src/app/shell/AppShell.test.tsx +++ b/src/app/shell/AppShell.test.tsx @@ -13,6 +13,9 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { RegisteredPage } from "../../features/pages/service"; import { createServices, type AppServices } from "../services"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { AgentQuickModel } from "../../bundled/agents/AgentQuickModel"; +import { controlFixture } from "../../features/agents/control-testing"; +import { createAgentControl } from "../../features/agents/control"; import { AppShell } from "./AppShell"; vi.mock("../../bundled", () => ({ bundledPlugins: [] })); @@ -197,6 +200,50 @@ it("contains a throwing page badge to its own nav row", () => { } }); +it("keeps the sidebar unchanged while the card model picker opens and closes", async () => { + const current = createServices(); + services = current; + const fixture = controlFixture(); + const control = createAgentControl(fixture.host); + await control.refresh(); + const user = userEvent.setup(); + render( + + {}} + tone="default" + communities={current.communities} + accountActions={current.accountActions} + > + {}} + /> + + , + ); + const navigation = document.getElementById("shell-navigation"); + for (const hidden of [false, true]) { + if (hidden) + await user.click( + screen.getByRole("button", { name: "Hide Channel sidebar" }), + ); + await user.click(screen.getByRole("combobox", { name: "Model" })); + expect(screen.getByRole("combobox", { name: "Model" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(navigation?.hasAttribute("data-sidebar-open")).toBe(!hidden); + await user.keyboard("{Escape}"); + expect(navigation?.hasAttribute("data-sidebar-open")).toBe(!hidden); + } +}); + it("partitions primary pages without duplicate links and preserves exact selection and removal", async () => { const current = createServices(); services = current; diff --git a/src/bundled/agents/AgentAvatarPicker.test.tsx b/src/bundled/agents/AgentAvatarPicker.test.tsx new file mode 100644 index 000000000..7d127fd23 --- /dev/null +++ b/src/bundled/agents/AgentAvatarPicker.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { AgentAvatarPicker } from "./AgentAvatarPicker"; +import { agentAvatars } from "../../features/agents/avatar-packs"; +afterEach(cleanup); +it("offers all three bundled collections and uses portable HTTPS pictures", () => { + const onChange = vi.fn(); + const view = render( + , + ); + expect(screen.getAllByRole("button", { name: /^Gloopies \d/ })).toHaveLength( + 23, + ); + fireEvent.click(screen.getByRole("button", { name: "Figgies" })); + expect(screen.getAllByRole("button", { name: /^Figgies \d/ })).toHaveLength( + 7, + ); + fireEvent.click(screen.getByRole("button", { name: "Figgies 2" })); + const picture = agentAvatars.find((avatar) => avatar.id === "pollies-2")?.url; + expect(onChange).toHaveBeenLastCalledWith(picture); + view.rerender( + , + ); + expect(screen.getByRole("button", { name: "Figgies 2" })).toHaveAttribute( + "aria-pressed", + "true", + ); + fireEvent.click(screen.getByRole("button", { name: "Fuzzies" })); + expect(screen.getAllByRole("button", { name: /^Fuzzies \d/ })).toHaveLength( + 19, + ); + expect(screen.getByRole("button", { name: "Use image" })).toBeVisible(); + expect(screen.queryByText("Avatar")).toBeNull(); +}); +it("disables avatar changes while saving", () => { + render(); + expect(screen.getByRole("button", { name: "Gloopies 1" })).toBeDisabled(); +}); diff --git a/src/bundled/agents/AgentAvatarPicker.tsx b/src/bundled/agents/AgentAvatarPicker.tsx new file mode 100644 index 000000000..5b6771956 --- /dev/null +++ b/src/bundled/agents/AgentAvatarPicker.tsx @@ -0,0 +1,82 @@ +import { SurfaceButton } from "../../shared/design-system/ui/SurfaceButton"; +import { AvatarEditor } from "../../features/profiles/AvatarEditor"; +import { useState } from "react"; +import { agentAvatars, avatarPacks } from "../../features/agents/avatar-packs"; +import { Button } from "../../shared/design-system/ui/Button"; + +export function AgentAvatarPicker({ + value, + disabled, + onChange, + name = "", + community, + onBusyChange, +}: { + value?: string | undefined; + name?: string; + community?: string | undefined; + onBusyChange?: ((busy: boolean) => void) | undefined; + disabled: boolean; + onChange(picture: string): void; +}) { + const [pack, setPack] = useState("gloopies"); + return ( +
+
+ {avatarPacks.map((collection) => ( + + ))} + +
+
collection.id === pack)?.label + } + > + {agentAvatars + .filter((avatar) => avatar.collectionId === pack) + .map((avatar) => ( + onChange(avatar.url)} + > + + + ))} +
+
+ ); +} diff --git a/src/bundled/agents/AgentBrowse.test.tsx b/src/bundled/agents/AgentBrowse.test.tsx new file mode 100644 index 000000000..fad94306d --- /dev/null +++ b/src/bundled/agents/AgentBrowse.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { AgentBrowse, sharedAgents } from "./AgentBrowse"; +import { createRelayReader } from "../../features/relay/reader"; +import type { ReadTransport } from "../../features/relay/transport"; +import type { RelayEvent } from "../../features/relay/events"; +import type { EventData } from "../../features/relay/events"; +import type { RelaySnapshot } from "../../features/relay/service"; +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +afterEach(cleanup); +const shared: EventData = { + id: "ab".repeat(32), + pubkey: "cd".repeat(32), + created_at: 1, + kind: 30175, + tags: [ + ["d", "helper"], + ["shared", "true"], + ], + content: JSON.stringify({ + display_name: "Shared helper", + model: "test-model", + system_prompt: "Help with tasks.", + }), +}; +it("only includes explicitly shared definitions and honors an unshared replacement", () => { + expect(sharedAgents([shared])).toMatchObject([{ name: "Shared helper" }]); + expect( + sharedAgents([ + shared, + { + ...shared, + id: "ef".repeat(32), + created_at: 2, + tags: [["d", "helper"]], + }, + ]), + ).toEqual([]); + expect( + sharedAgents([ + { + ...shared, + tags: [ + ["d", "helper"], + ["shared", "false"], + ], + }, + ]), + ).toEqual([]); + expect(sharedAgents([{ ...shared, kind: 30177 }])).toEqual([]); + expect(sharedAgents([{ ...shared, content: "invalid" }])).toEqual([]); +}); +it("keeps definitions from distinct publishers separate and sanitizes artwork", () => { + expect( + sharedAgents([shared, { ...shared, pubkey: "ef".repeat(32) }]), + ).toHaveLength(2); + expect( + sharedAgents([ + { + ...shared, + content: JSON.stringify({ + display_name: "Helper", + avatar_url: "javascript:alert(1)", + }), + }, + ])[0]?.avatar, + ).toBeUndefined(); +}); +it("browses shared relay definitions and opens read-only details", async () => { + const read = vi.fn().mockResolvedValue([shared]); + const connection = { + status: "ready", + session: { read, media: () => undefined }, + } as unknown as RelaySnapshot; + render(); + expect( + await screen.findByRole("heading", { name: "Shared helper" }), + ).toBeVisible(); + expect(read).toHaveBeenCalledWith([{ kinds: [30175], limit: 200 }], { + signal: expect.any(AbortSignal), + }); + fireEvent.click(screen.getByRole("button", { name: "View details" })); + expect( + await screen.findByRole("dialog", { name: "Shared helper" }), + ).toBeVisible(); + expect(screen.getByText("Help with tasks.")).toBeVisible(); + expect(screen.queryByText("No linked identity")).toBeNull(); +}); +it("allows retrying a failed read without claiming the community is empty", async () => { + const read = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce([]); + render( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Try again" })); + expect( + await screen.findByText("No shared agents found in this community yet."), + ).toBeVisible(); +}); + +it.each([ + { + tags: [ + ["d", "helper"], + ["shared", "true"], + ["shared", "false"], + ], + }, + { + tags: [ + ["d", "helper"], + ["d", "other"], + ["shared", "true"], + ], + }, + { + tags: [ + ["d", "invalid slug"], + ["shared", "true"], + ], + }, + { content: JSON.stringify({ display_name: "Hidden\u202ename" }) }, + { + content: JSON.stringify({ + display_name: "Helper", + system_prompt: "x".repeat(65537), + }), + }, +])("rejects invalid catalog publications: %j", (invalid) => { + expect(sharedAgents([{ ...shared, ...invalid }])).toEqual([]); +}); +it("does not revive a valid publication behind an invalid newer head", () => { + expect( + sharedAgents([shared, { ...shared, created_at: 2, content: "invalid" }]), + ).toEqual([]); +}); + +it("shrinks over-budget pages and preserves Load more and explicit retry", async () => { + const events = Array.from({ length: 200 }, (_, index) => ({ + ...shared, + id: index.toString(16).padStart(64, "0"), + created_at: 200 - index, + tags: [ + ["d", `helper-${Math.floor(index / 100)}`], + ["shared", "true"], + ], + content: JSON.stringify({ + display_name: `Helper ${index}`, + system_prompt: "x".repeat(48 * 1024), + }), + })) as RelayEvent[]; + let failNextPage = true; + const query = vi.fn(async (filters) => { + const filter = filters[0]; + if (!filter) throw new Error("Missing filter"); + if (filter.until !== undefined && failNextPage) { + failNextPage = false; + throw new Error("offline"); + } + return events + .filter( + (event) => + filter.until === undefined || event.created_at < filter.until, + ) + .slice(0, filter.limit); + }); + const owned = createRelayReader({ + viewer: shared.pubkey, + relayAuthor: "relay", + media: () => undefined, + query, + }); + const view = render( + undefined }, + } as unknown as RelaySnapshot + } + />, + ); + try { + await screen.findByRole("heading", { name: "Helper 0" }); + expect( + query.mock.calls.slice(0, 2).map(([filters]) => filters[0]?.limit), + ).toEqual([200, 100]); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + fireEvent.click(await screen.findByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Helper 100" }), + ).toBeVisible(); + expect(screen.getAllByRole("heading")).toHaveLength(2); + expect( + screen.queryByRole("button", { name: "Load more" }), + ).not.toBeInTheDocument(); + expect( + query.mock.calls.slice(2).map(([filters]) => filters[0]?.until), + ).toEqual([101, 101]); + } finally { + view.unmount(); + owned.dispose(); + } +}); diff --git a/src/bundled/agents/AgentBrowse.tsx b/src/bundled/agents/AgentBrowse.tsx new file mode 100644 index 000000000..dd801f1c7 --- /dev/null +++ b/src/bundled/agents/AgentBrowse.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from "react"; +import type { RelaySnapshot } from "../../features/relay/service"; +import { ReadError } from "../../features/relay/errors"; +import type { EventData } from "../../features/relay/events"; +import { + AGENT_CATALOG_KIND, + catalogHeads, + parsePublication, +} from "../../features/agents/catalog-protocol"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { AgentCard } from "./AgentCard"; + +type SharedAgent = { + id: string; + name: string; + owner: string; + avatar: string | undefined; + model: string; + prompt: string; +}; + +// Select replacement heads before checking sharing, so unsharing hides older heads. +export function sharedAgents(events: readonly EventData[]): SharedAgent[] { + return [...catalogHeads(events).values()] + .flatMap((event) => { + const publication = parsePublication(event); + if (publication?.kind !== AGENT_CATALOG_KIND) return []; + const { agent, owner, d } = publication; + return [ + { + id: `${owner}:${d}`, + name: agent.displayName.trim(), + owner, + avatar: agent.avatarUrl, + model: agent.model ?? "", + prompt: agent.systemPrompt, + }, + ]; + }) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); +} + +const pageSize = 200; +export function AgentBrowse({ connection }: { connection: RelaySnapshot }) { + const [events, setEvents] = useState([]); + const [cursor, setCursor] = useState<{ until: number; before_id: string }>(); + const [more, setMore] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [attempt, setAttempt] = useState(0); + const [selected, setSelected] = useState(); + const trigger = useRef(null); + const { session, status } = connection; + // biome-ignore lint/correctness/useExhaustiveDependencies: An explicit retry reruns this finite read. + useEffect(() => { + if (status !== "ready") return; + const controller = new AbortController(); + setLoading(true); + setError(false); + const readPage = async () => { + let limit = pageSize; + for (;;) { + controller.signal.throwIfAborted(); + try { + const page = await session.read( + [{ kinds: [AGENT_CATALOG_KIND], limit, ...cursor }], + { signal: controller.signal }, + ); + if (controller.signal.aborted) return; + setEvents((previous) => (cursor ? [...previous, ...page] : page)); + setMore(page.length === limit); + return; + } catch (problem) { + // Match the community catalog's bounded retry at the same cursor. + if ( + problem instanceof ReadError && + problem.kind === "invalid-response" && + problem.message === "Relay response exceeds the read budget" && + limit > 1 + ) { + limit = Math.floor(limit / 2); + continue; + } + throw problem; + } + } + }; + void readPage() + .catch(() => { + if (!controller.signal.aborted) setError(true); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [session, status, cursor, attempt]); + if (status !== "ready") + return

Connect to a community to browse shared agents.

; + const agents = sharedAgents(events); + return ( +
+ {loading &&

Loading shared agents…

} + {error && ( +
+

Couldn’t load shared agents.

+ +
+ )} + {!loading && !error && !agents.length && ( +

No shared agents found in this community yet.

+ )} +
+ {agents.map((agent) => ( + { + trigger.current = event.currentTarget; + setSelected(agent); + }} + > + View details + + } + /> + ))} +
+ {more && !error && ( + + )} + { + if (!open) setSelected(undefined); + }} + title={selected?.name ?? "Shared agent"} + finalFocus={trigger} + > + {selected && ( +
+ {selected.model &&

{selected.model}

} +

+ Shared by {selected.owner} +

+

+ {selected.prompt || "No instructions included."} +

+
+ )} +
+
+ ); +} diff --git a/src/bundled/agents/AgentCard.module.css b/src/bundled/agents/AgentCard.module.css index 92f357e81..556fc8328 100644 --- a/src/bundled/agents/AgentCard.module.css +++ b/src/bundled/agents/AgentCard.module.css @@ -1,7 +1,8 @@ .card { display: flex; flex-direction: column; - border: 1px solid var(--border-standard); + border: 1px solid transparent; + background: var(--surface-control); border-radius: var(--radius-container); min-height: 15.25rem; } @@ -15,37 +16,28 @@ align-items: center; justify-content: center; gap: var(--space-3); - padding: var(--space-8) var(--space-3); + padding: var(--space-8) var(--space-3) var(--space-4); text-align: center; border-radius: var(--radius-container); } -.open { - position: absolute; - inset: 0; - width: 100%; - border: 0; - border-radius: var(--radius-container); - padding: 0; - background: transparent; - color: inherit; - cursor: pointer; -} -.open:hover, -.open:active { - background: transparent; -} -.content:has(.open:hover), -.content:has(.open:focus-visible) { - background: var(--surface-hover); -} .portrait { - width: 5rem; - height: 5rem; + width: 6rem; + height: 6rem; flex-shrink: 0; } +.portrait[data-animated] { + width: 8rem; + height: 8rem; +} .model { margin-top: var(--space-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.modelPicker { + position: relative; + z-index: 1; + max-width: 100%; +} diff --git a/src/bundled/agents/AgentCard.test.tsx b/src/bundled/agents/AgentCard.test.tsx index 520c21956..28a9c3706 100644 --- a/src/bundled/agents/AgentCard.test.tsx +++ b/src/bundled/agents/AgentCard.test.tsx @@ -6,9 +6,9 @@ import { npubEncode } from "nostr-tools/nip19"; import { act, cleanup, + within, fireEvent, render, - within, screen, waitFor, } from "@testing-library/react"; @@ -22,76 +22,6 @@ import { AgentCard } from "./AgentCard"; afterEach(cleanup); -it.each(["tile", "row"] as const)( - "shows a restart-required badge when any saved %s setup drifts, but not for unmanaged or legacy cards", - (layout) => { - const fixture = controlFixture(); - const drift: AgentView["restartDiff"] = [ - { - field: "systemPrompt", - change: { kind: "text", beforeChars: 18, afterChars: 21 }, - }, - ]; - const stable = { - ...fixture.agent, - id: "stable-fixture-agent", - restartDiff: [], - } satisfies AgentView; - const drifted = { - ...fixture.agent, - id: "drifted-fixture-agent", - relayUrl: "wss://other-relay.example.test", - restartDiff: drift, - } satisfies AgentView; - const card = (editable: AgentView[]) => ( - - ); - const view = render(card([stable, drifted])); - const article = screen.getByRole("article", { name: "Agent Agent" }); - const badge = within(article).getByRole("status", { - name: "Restart required", - }); - - expect(badge).toBeVisible(); - expect(badge.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); - - view.rerender( - card([ - { - ...stable, - restartDiff: [], - }, - { - ...drifted, - restartDiff: [], - }, - ]), - ); - expect( - within(article).queryByRole("status", { name: "Restart required" }), - ).toBeNull(); - - view.rerender(card([])); - expect( - within(article).queryByRole("status", { name: "Restart required" }), - ).toBeNull(); - - const legacy = { ...drifted } as Omit & { - restartDiff?: AgentView["restartDiff"]; - }; - delete legacy.restartDiff; - view.rerender(card([legacy as AgentView])); - expect( - within(article).queryByRole("status", { name: "Restart required" }), - ).toBeNull(); - }, -); - it("badges a single agent only while live presence is known", () => { const pubkey = "a".repeat(64); let status: PresenceStatus = "unknown"; @@ -215,6 +145,23 @@ it("re-reads presence after native start or stop until the badge agrees, within } }); +it("reserves card-header space for a profile-only menu", () => { + render( + {}} + > +

Relay-only identity

+
, + ); + + expect( + screen.getByRole("heading", { level: 3 }).parentElement?.parentElement, + ).toHaveClass("pr-6"); +}); + it("hands focus from the menu to the opened profile", async () => { render( expect(screen.queryByRole("button", { name: "Stop" })).toBeNull(); const card = screen.getByRole("article"); expect(card.querySelector("details")).toBeNull(); - const trigger = screen.getByRole("button", { name: "Manage Agent" }); + const trigger = screen.getByRole("button", { name: "Actions for Agent" }); await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Manage agent" }), + ); const dialog = await screen.findByRole("dialog", { name: "Manage Agent" }); expect(card).not.toContainElement(dialog); expect(screen.getByRole("button", { name: "Stop" })).toBeVisible(); @@ -296,37 +246,31 @@ it("keeps archive feedback visible and management outside the tile", async () => expect(trigger).toHaveFocus(); }); -it("opens the profile from the tile without opening its separate management controls", async () => { +it("keeps profile and management actions in the ellipsis menu", async () => { const user = userEvent.setup(); const profile = vi.fn(); render( - + , ); - const tile = screen.getByRole("button", { name: "View profile for Agent" }); - await user.click(tile); - expect(profile).toHaveBeenLastCalledWith(tile); + const actions = screen.getByRole("button", { name: "Actions for Agent" }); + expect(screen.queryByRole("button", { name: "Manage Agent" })).toBeNull(); + await user.click(actions); + await user.click( + await screen.findByRole("menuitem", { name: "View profile" }), + ); + await waitFor(() => expect(profile).toHaveBeenCalledWith(actions)); expect(screen.queryByRole("dialog")).toBeNull(); - await user.keyboard("{Enter}"); - await user.keyboard(" "); - expect(profile).toHaveBeenCalledTimes(3); - await user.click(screen.getByRole("button", { name: "Actions for Agent" })); + await user.click(actions); await user.click( await screen.findByRole("menuitem", { name: "Manage agent" }), ); expect( await screen.findByRole("dialog", { name: "Manage Agent" }), ).toBeVisible(); - expect(profile).toHaveBeenCalledTimes(3); await user.keyboard("{Escape}"); - expect( - screen.getByRole("button", { name: "Actions for Agent" }), - ).toHaveFocus(); + expect(actions).toHaveFocus(); }); it("returns to persistent Actions when Review disappears while Manage is open", async () => { @@ -384,6 +328,27 @@ it.each(["tile", "row"] as const)( }, ); +it("keeps model picker interaction separate from management", async () => { + const user = userEvent.setup(); + const pick = vi.fn(); + render( + + Choose model + + } + > +

Runtime controls

+
, + ); + await user.click(screen.getByRole("button", { name: "Choose model" })); + expect(pick).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + it("routes a managed card Share directly from its menu without a separate Export", async () => { const agent = controlFixture().agent; const share = vi.fn(); diff --git a/src/bundled/agents/AgentCard.tsx b/src/bundled/agents/AgentCard.tsx index 85067ff77..fda00f37c 100644 --- a/src/bundled/agents/AgentCard.tsx +++ b/src/bundled/agents/AgentCard.tsx @@ -1,3 +1,5 @@ +import { agentAvatars } from "../../features/agents/avatar-packs"; +import { PackedAgentAvatar } from "./PackedAgentAvatar"; import styles from "./AgentCard.module.css"; import { npubEncode } from "nostr-tools/nip19"; import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"; @@ -15,6 +17,8 @@ import { import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; import { useAvatarPreview } from "../../features/profiles/use-avatar-preview"; import { + UserIcon, + GearIcon, ArchiveIcon, ArchiveOffIcon, ArrowsClockwiseIcon, @@ -57,6 +61,10 @@ export function AgentCard({ archived = false, feedback, children, + primaryAction, + menuActions, + modelPicker, + subtitle, identityLabel = (identity) => identity.name, layout = "tile", headingLevel = 3, @@ -64,6 +72,10 @@ export function AgentCard({ imported = false, }: { children?: ReactNode; + primaryAction?: ReactNode; + menuActions?: ReactNode; + modelPicker?: ReactNode; + subtitle?: ReactNode; revealControls?: boolean; imported?: boolean; identityLabel?: (identity: { pubkey: string; name: string }) => string; @@ -158,6 +170,9 @@ export function AgentCard({ ? "Custom model" : managed.harness.model || "Default model")) : undefined; + const packed = agentAvatars.find( + (avatar) => avatar.url === (managed?.picture ?? source), + ); const restartRequired = editable.some( (agent) => (agent.restartDiff?.length ?? 0) > 0, ); @@ -172,19 +187,32 @@ export function AgentCard({ ) : null; const portrait = ( -
- +
+ {packed ? ( +
+ +
+ ) : ( + + )}
); const label = ( @@ -203,7 +231,7 @@ export function AgentCard({ Archived )} - {tile && model && ( + {tile && model && !modelPicker && (

- {(onEdit || onViewProfile || archive || hasControls) && ( -

+ {(onEdit || onViewProfile || archive || hasControls || menuActions) && ( +
{ if (open) profileHandoff.current = false; @@ -253,20 +281,26 @@ export function AgentCard({ } }} > + + + View profile )} {hasControls && ( { - trigger.current?.focus(); profileHandoff.current = true; manage(trigger.current); }} > + + + Manage agent )} + {menuActions} {archive && ( {editable.length === 1 ? ( - "Edit" + "Edit agent settings" ) : ( {portrait} {label} - {tile && (onViewProfile || hasControls) && ( -
{hasControls && ( @@ -480,7 +505,7 @@ export function AgentCard({
) : !children && !identities.length ? (

- No linked identity + {subtitle ?? "No linked identity"}

) : null} diff --git a/src/bundled/agents/AgentControlPanel.test.tsx b/src/bundled/agents/AgentControlPanel.test.tsx index 93f6ea6f5..b89048a10 100644 --- a/src/bundled/agents/AgentControlPanel.test.tsx +++ b/src/bundled/agents/AgentControlPanel.test.tsx @@ -226,7 +226,7 @@ it("dialog actions choose the destination record when several local setups share expect(f.calls.find((call) => call.action === "localClone")?.payload).toEqual( { id: "fixture-agent" }, ); - fireEvent.click(within(create).getByRole("button", { name: "Cancel" })); + fireEvent.click(within(create).getByRole("button", { name: "Close" })); fireEvent.click(within(card).getByRole("button", { name: "Use here" })); const setupDialog = await screen.findByRole("dialog", { name: "Set up agent here", diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index f853a907e..6d690d991 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -1,3 +1,5 @@ +import { AgentQuickModel } from "./AgentQuickModel"; +import { NewAgentTile } from "./NewAgentTile"; import { createPortal } from "react-dom"; import { Dialog } from "@base-ui/react/dialog"; import type { useIdentityNames } from "../../features/identity-names/react"; @@ -45,8 +47,12 @@ export function AgentControlPanel({ headerActions, session, catalog, + importTab = false, + onImported, }: { session?: RelaySession | undefined; + importTab?: boolean; + onImported?: () => void; headerActions?: HTMLElement | null; /** A header launcher that may seed the create flow; it owns any relay use. */ catalog?: @@ -82,7 +88,8 @@ export function AgentControlPanel({ action: "use" | "clone", source?: ImportSource, ) => void, - onImport: (pubkey: string, source?: ImportSource) => void, + onImport: (pubkey?: string, source?: ImportSource) => void, + newAgent: ReactNode, onShare: (agent: AgentView) => void, ) => ReactNode; }) { @@ -109,7 +116,7 @@ export function AgentControlPanel({ const [importSelection, setImportSelection] = useState<{ destination: string; trigger: HTMLElement | null; - pubkey: string; + pubkey?: string | undefined; name: string; source?: ImportSource; } | null>(null); @@ -226,7 +233,9 @@ export function AgentControlPanel({ const importForm = state.data ? ( { importCompleted.current = true; setImportedId(agents[0]?.id ?? null); + onImported?.(); setImportSections([]); setImportSelection(null); }} @@ -298,6 +308,7 @@ export function AgentControlPanel({ className="agent-controls flex min-w-0 flex-col gap-section-gap text-body text-primary" > {state.data && + (!children || state.data.parked === undefined) && (headerActions ? ( createPortal(headerButtons, headerActions) ) : ( @@ -321,56 +332,92 @@ export function AgentControlPanel({ )} {state.busy &&

Waiting for the desktop app…

} - {children ? ( - children( - state, - edit, - duplicate, - remove, - importedId, - label, - (pubkey, action, source) => - setHandover({ - pubkey, - action, - destination: importDestination, - ...(source ? { source } : {}), - }), - (pubkey, source) => { - importCompleted.current = false; - setImportSelection({ - destination: importDestination, - trigger: - document.activeElement instanceof HTMLElement - ? document.activeElement - : null, - pubkey, - name: - state.data?.parked?.find((agent) => agent.pubkey === pubkey) - ?.name ?? "agent", - ...(source ? { source } : {}), - }); - setImportSections(["old-buzz"]); - }, - (agent) => setExporting(agent.id), - ) - ) : ( -
- {state.data?.agents.map((agent) => ( - setExporting(agent.id)} - onDelete={control.delete ? remove : undefined} - /> - ))} -
- )} +
+ {children ? ( + children( + state, + edit, + duplicate, + remove, + importedId, + label, + (pubkey, action, source) => + setHandover({ + pubkey, + action, + destination: importDestination, + ...(source ? { source } : {}), + }), + (pubkey, source) => { + importCompleted.current = false; + setImportSelection({ + destination: importDestination, + trigger: + document.activeElement instanceof HTMLElement + ? document.activeElement + : null, + pubkey, + name: + state.data?.parked?.find((agent) => agent.pubkey === pubkey) + ?.name ?? "agent", + ...(source ? { source } : {}), + }); + setImportSections(["old-buzz"]); + }, + !importTab && ( + + setAdding({ + destination: importDestination, + owner: createOwner ?? "", + }) + } + /> + ), + (agent) => setExporting(agent.id), + ) + ) : ( +
+ {state.data?.agents.map((agent) => ( + edit(agent)} + /> + } + onEdit={edit} + onDuplicate={duplicate} + onShare={(agent) => setExporting(agent.id)} + onDelete={control.delete ? remove : undefined} + /> + ))} +
+ )} + {importTab && !importSelection && ( +
+ {control.create && ( + + )} + {importForm ??

Connect to the desktop app to import agents.

} +
+ )} +
{state.data && + !importTab && !importSelection && (state.data.parked === undefined || needsRepair) && ( + {!importSelection.pubkey && ( + + Import agents + + )} {importForm} + {!importSelection.pubkey && ( + + )} @@ -526,17 +586,9 @@ export function AgentControlPanel({ ? { initialSettings: adding.initialSettings } : {})} onClose={() => setAdding(null)} + catalogSession={catalog ? session : undefined} onCreated={adding.onCreated} onOpenHarnesses={onOpenHarnesses} - catalogSession={session} - onImport={ - control.create && !adding.source && !adding.initialSettings - ? () => { - setAdding(null); - setImportingSnapshot(true); - } - : undefined - } /> )} {editing && ( diff --git a/src/bundled/agents/AgentControls.css b/src/bundled/agents/AgentControls.css index 4068d9f27..49c6f35d9 100644 --- a/src/bundled/agents/AgentControls.css +++ b/src/bundled/agents/AgentControls.css @@ -78,6 +78,766 @@ } } +.agent-page-content { + padding-block: clamp( + var(--space-4), + 6vh, + calc(var(--space-16) + var(--space-4)) + ); +} +.agent-page-content .agent-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} +.agent-new-tile { + position: relative; + isolation: isolate; + min-height: 15.25rem; + overflow: hidden; + border: 1px solid var(--border-standard); + border-radius: var(--radius-container); + background: var(--surface); + cursor: pointer; +} +.agent-new-tile:disabled { + opacity: 0.5; + cursor: default; +} +.agent-new-artwork { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} +.agent-new-artwork img { + position: absolute; + width: 4.5rem; + height: 4.5rem; + object-fit: contain; +} +.agent-new-artwork img:nth-child(1) { + left: -8%; + top: 12%; + transform: rotate(-12deg); +} +.agent-new-artwork img:nth-child(2) { + right: -9%; + top: 8%; + transform: rotate(12deg); +} +.agent-new-artwork img:nth-child(3) { + left: 34%; + top: 0; +} +.agent-new-artwork img:nth-child(4) { + left: 35%; + bottom: 0; + transform: rotate(-10deg); +} +.agent-new-artwork img:nth-child(5) { + left: -10%; + bottom: 10%; +} +.agent-new-artwork img:nth-child(6) { + right: -8%; + bottom: 0; +} +.agent-new-artwork img:nth-child(7) { + right: -20%; + top: 43%; +} +.agent-new-artwork img:nth-child(8) { + left: -24%; + top: 45%; +} +.agent-new-label { + display: inline-block; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-pill); + color: var(--text-inverse); + background: var(--text-standard); +} +.agent-new-tile:hover { + background: var(--surface-hover); +} +.agent-avatar-picker { + min-width: 0; + border: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.agent-avatar-options { + border: 0; + margin: 0; + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr)); + max-height: 15rem; + overflow-y: auto; + gap: var(--space-2); + padding: var(--space-1); +} +.agent-avatar-option { + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: var(--surface-hover); + cursor: pointer; + padding: var(--space-1); +} +.agent-avatar-option img { + object-fit: contain; +} +.agent-avatar-option[aria-pressed="true"] { + border-color: var(--border-focus); +} +.agent-avatar-option:disabled { + opacity: 0.5; + cursor: default; +} +@media (max-width: 560px) { + .agent-page-content .agent-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 360px) { + .agent-page-content .agent-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.agent-create-identity { + background: var(--surface-panel); + margin: 0; + padding: 0; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-standard); + border-radius: var(--radius-container); +} +.agent-create-cover { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-4); + height: 28rem; + padding: var(--space-6) calc(var(--space-6) * 2) var(--space-2); + background: var(--surface-panel); +} +.agent-create-avatar-choice { + isolation: isolate; + position: relative; + display: grid; + place-items: center; + width: 13rem; + height: 13rem; +} +.agent-create-avatar-choice > img, +.agent-create-avatar-choice > video { + width: 100%; + height: 100%; + object-fit: contain; +} +.agent-create-avatar-action { + z-index: 1; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} +@media (hover: hover) and (pointer: fine) { + .agent-create-avatar-action { + opacity: 0; + pointer-events: none; + } + .agent-create-avatar-choice:hover .agent-create-avatar-action, + html[data-keyboard-navigation] + .agent-create-avatar-choice:focus-within + .agent-create-avatar-action { + opacity: 1; + pointer-events: auto; + } +} +.agent-create-fields { + border: 1px solid var(--border-standard); + padding: var(--space-4); + border-radius: var(--radius-container); + background: var(--surface-panel); +} +.agent-create-picker { + padding: var(--space-4); +} + +.agent-create-fields > h3:first-child { + margin-top: 0; +} + +.agent-avatar-artwork { + width: 100%; + height: 100%; + position: relative; + z-index: 0; + pointer-events: none; +} +.agent-avatar-artwork > img, +.agent-avatar-artwork > video { + width: 100%; + height: 100%; + object-fit: contain; +} +.agent-create-avatar-action { + z-index: 10; + transform: translateZ(1px); +} +.agent-create-cover[data-view]:not([data-view="selected"]) { + justify-content: flex-start; + align-items: stretch; +} +.agent-avatar-navigation { + display: flex; + align-items: center; + gap: var(--space-3); +} +.agent-avatar-collections { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); + width: 100%; + margin-block: auto; +} +.agent-avatar-collection { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + min-height: 13rem; + border: 1px solid var(--border-standard); + border-radius: var(--radius-container); + background: var(--surface-control); + padding: var(--space-3); + cursor: pointer; +} +.agent-avatar-collection .agent-avatar-artwork { + height: 9rem; +} +.agent-avatar-collection:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.agent-inline-image-editor { + width: 100%; +} +.agent-inline-image-editor [role="tablist"] { + display: none; +} +@media (max-width: 540px) { + .agent-avatar-collections { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.agent-create-cover .agent-avatar-options { + max-height: none; + overflow: visible; +} +.agent-create-cover .agent-avatar-option .agent-avatar-artwork { + height: auto; + aspect-ratio: 1; +} + +.agent-create-cover { + position: relative; +} +.agent-avatar-view { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-4); + width: 100%; + flex: 1; + min-height: 0; + transform-origin: center; +} +.agent-avatar-view[data-avatar-view="selected"] { + align-items: center; + justify-content: flex-end; +} + +.agent-create-title .buzz-input { + font-size: var(--text-title); + line-height: 1.25; + font-weight: var(--text-title--font-weight); + min-height: 3.5rem; + padding: var(--space-2) 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--text-primary); +} +.agent-create-title .buzz-input::placeholder { + color: var(--text-unavailable); +} + +.agent-create-title-in-cover { + padding: 0 calc(var(--space-6) * 2) var(--space-4); + background: var(--surface-panel); +} + +.agent-create-title-in-cover .buzz-input { + text-align: center; +} + +.agent-create-cover .agent-avatar-options { + align-content: start; +} +@media (max-width: 540px) { + .agent-create-cover { + height: 44rem; + } +} + +.agent-create-identity { + position: relative; + height: 32.5rem; + flex-shrink: 0; +} +.agent-create-cover { + height: 100%; +} +.agent-create-title-in-cover { + position: absolute; + inset-inline: 0; + bottom: 0; +} +.agent-avatar-view[data-avatar-view="selected"] { + justify-content: flex-end; + padding-bottom: calc(var(--space-8) + var(--space-6)); +} +.agent-avatar-navigation { + position: absolute; + top: var(--space-6); + left: var(--space-4); + z-index: 2; +} +.agent-avatar-view { + position: relative; + justify-content: center; +} +.agent-avatar-collections { + margin-block: 0; +} +.agent-create-cover .agent-avatar-options, +.agent-inline-image-editor { + margin-top: 0; +} +@media (max-width: 540px) { + .agent-create-identity { + height: 48.5rem; + } + .agent-create-cover { + height: 100%; + } +} + +.agent-create-identity:has([data-view="selected"]) .agent-create-cover { + padding-block: var(--space-4); +} +.agent-avatar-view[data-avatar-view="selected"] { + justify-content: center; + padding-bottom: calc(var(--space-16) + var(--space-6)); +} +.agent-create-identity:has([data-view="selected"]) + .agent-create-title-in-cover { + top: calc(50% + 4.75rem); + bottom: auto; + padding-bottom: 0; +} + +.agent-create-cover .agent-avatar-options { + flex: 1; + min-height: 0; + align-content: stretch; +} +.agent-create-cover .agent-avatar-option { + min-height: 0; + min-width: 0; +} +.agent-create-cover .agent-avatar-option .agent-avatar-artwork { + height: 100%; + min-height: 0; + aspect-ratio: auto; +} + +.agent-create-model { + position: absolute; + z-index: 3; + top: var(--space-4); + right: var(--space-4); + width: max-content; + max-width: calc(100% - 7rem); + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-1); +} +.agent-create-model > button { + align-self: flex-end; +} + +/* This header picker keeps a steady frame; keyboard focus stays on its control. */ +:is(.agent-create-model, .agent-quick-model-picker) +.buzz-input-group:focus-within { + --border: transparent; + border-color: transparent; +} + +html[data-keyboard-navigation] + :is(.agent-create-model, .agent-quick-model-picker) + .buzz-input-group:has(input:focus-visible) { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +.agent-model-measure { + position: absolute; + visibility: hidden; + pointer-events: none; + white-space: pre; + font: inherit; +} +:is(.agent-create-model, .agent-quick-model-picker) + .buzz-input-group:has(.buzz-input-trailing) { + padding-inline: var(--space-control-inset); +} +:is(.agent-create-model, .agent-quick-model-picker) .buzz-input-trailing, +:is(.agent-create-model, .agent-quick-model-picker) + .buzz-input-trailing + .buzz-button { + width: 18px; + min-width: 18px; +} +:is(.agent-create-model, .agent-quick-model-picker) + .buzz-input-trailing + .buzz-button { + background: transparent; + box-shadow: none; + border-color: transparent; + padding: 0; +} + +.agent-family-popup { + min-width: min(18rem, var(--available-width)); +} + +.agent-model-options[data-variant="field"] { + width: max-content; + max-width: min(var(--available-width), calc(100vw - var(--space-6))); + overflow-wrap: normal; +} +.agent-model-options .buzz-combobox-item-content > span:first-child { + white-space: nowrap; +} + +:is(.agent-create-model, .agent-quick-model-picker) .buzz-input { + caret-color: transparent; + cursor: pointer; +} +.agent-model-family-row { + width: 100%; +} +.agent-model-options + .buzz-combobox-option:has(.agent-model-family-row) + .buzz-combobox-indicator { + display: none; +} + +.agent-instruction-container { + height: 18rem; + display: flex; + flex-direction: column; + gap: var(--space-4); +} +.agent-instruction-toolbar { + height: 2rem; + flex-shrink: 0; + display: flex; + justify-content: flex-end; +} +.agent-instruction-content { + flex: 1; + min-height: 0; + overflow-y: auto; +} +.agent-instruction-content[data-mode="choices"] { + display: grid; + align-items: center; +} +.agent-instruction-content textarea { + resize: none; +} +.agent-instruction-content[data-mode="text"] > *, +.agent-instruction-content[data-mode="text"] textarea { + height: 100%; +} +.agent-instruction-voice { + min-height: 100%; + text-align: center; +} +.agent-instruction-voice[data-results="false"] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.agent-instruction-waveform { + width: 12.5rem; + height: 24px; + overflow: hidden; + mask-image: linear-gradient( + to right, + transparent, + black 12px, + black calc(100% - 12px), + transparent + ); +} +.agent-instruction-waveform-track { + display: flex; + align-items: center; + gap: var(--space-half); + height: 100%; +} +.agent-instruction-waveform-track > span { + width: 3px; + flex-shrink: 0; + border-radius: var(--radius-pill); + background: var(--text-standard); +} + +.agent-instruction-waveform-slot { + height: 24px; +} + +.agent-instruction-entry { + position: relative; + align-items: center; + justify-content: center; +} +.agent-header-instructions { + position: absolute; + inset: auto calc(var(--space-6) * 2) var(--space-4); + z-index: 2; +} +.agent-create-identity[data-instructions-active="true"] + .agent-header-instructions { + top: 14.75rem; +} +.agent-header-instructions > div, +.agent-instruction-embedded { + height: 100%; +} +.agent-instruction-embedded { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.agent-header-instructions + [data-instruction-view="entry"] + > .agent-instruction-entry { + display: flex; + width: 50%; + min-height: 6rem; + margin-inline: auto; + white-space: normal; +} +.agent-instruction-entry-copy { + display: grid; + gap: var(--space-2); + text-align: center; +} +.agent-instruction-example { + color: var(--text-unavailable); + display: grid; + align-items: center; + min-height: 3em; +} +@media (max-width: 540px) { + .agent-header-instructions + [data-instruction-view="entry"] + > .agent-instruction-entry { + width: 100%; + } +} + +.agent-compact-model-fields { + display: flex; + flex-direction: column; + align-items: flex-end; +} +.agent-compact-model-fields > div:has(.buzz-input-group) { + width: max-content; + max-width: 100%; +} + +.agent-create-cover .agent-inline-image-editor { + width: 50%; + align-self: center; + margin-block: auto; +} +@media (max-width: 540px) { + .agent-create-cover .agent-inline-image-editor { + width: 100%; + } +} + +.agent-uploaded-avatar { + overflow: hidden; +} +.agent-uploaded-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.agent-create-identity[data-instructions-active="false"] + .agent-header-instructions { + bottom: calc(var(--space-6) + var(--space-4)); +} + +.agent-instruction-embedded > .agent-instruction-toolbar { + display: none; +} + +.agent-header-instructions + [data-instruction-view="entry"] + > .agent-instruction-entry { + background: transparent; + border-color: transparent; + box-shadow: none; +} +.agent-header-instructions + [data-instruction-view="entry"] + .agent-instruction-entry-copy + > span:first-child:not(.agent-instruction-example) { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.agent-header-instructions:has([data-instruction-view="text"]) { + bottom: calc(var(--space-6) * 2); +} + +.agent-instruction-entry-copy { + position: relative; +} +.agent-instruction-edit-action { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; +} +@media (hover: hover) and (pointer: fine) { + .agent-instruction-edit-action { + opacity: 0; + pointer-events: none; + } + .agent-instruction-entry:hover .agent-instruction-edit-action, + html[data-keyboard-navigation] + .agent-instruction-entry:focus-within + .agent-instruction-edit-action { + opacity: 1; + pointer-events: auto; + } +} + +.agent-quick-model { + min-width: 0; +} +.agent-quick-model-picker .buzz-input-group { + background: transparent; + border-color: transparent; + box-shadow: none; +} +.agent-quick-model-picker + .buzz-input-group:is(:hover, :focus-within, [data-popup-open]) { + background: var(--surface-hover); +} + +.agent-profile-avatar-editor { + width: 100%; + margin-block: auto; + align-self: center; +} + +.agent-quick-model-picker .buzz-input-group:has(.buzz-input-trailing) { + padding-inline: var(--space-control-inset); +} +.agent-quick-model-picker .buzz-input-trailing { + display: none; +} +.agent-packed-video::-webkit-media-controls, +.agent-packed-video::-webkit-media-controls-start-playback-button { + display: none; +} + +.agent-model-loading { + width: 18rem; + max-width: 100%; + padding: var(--space-2); +} +.agent-model-loading-row { + display: flex; + align-items: center; + gap: var(--space-2); + height: var(--size-control); + padding-inline: var(--space-2); +} +.agent-model-loading-icon, +.agent-model-loading-label { + background: linear-gradient( + 100deg, + var(--surface-control) 25%, + var(--surface-hover) 50%, + var(--surface-control) 75% + ); + background-size: 200% 100%; + border-radius: var(--radius-control); + animation: agent-model-shimmer 1.4s ease-in-out infinite; +} +.agent-model-loading-icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; +} +.agent-model-loading-label { + height: 0.875rem; +} +@keyframes agent-model-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} +@media (prefers-reduced-motion: reduce) { + .agent-model-loading-icon, + .agent-model-loading-label { + animation: none; + } +} .agent-add-layout { display: grid; grid-template-columns: 12rem minmax(0, 1fr); diff --git a/src/bundled/agents/AgentCreateDialog.catalog.test.tsx b/src/bundled/agents/AgentCreateDialog.catalog.test.tsx index e386b629e..792c2f77d 100644 --- a/src/bundled/agents/AgentCreateDialog.catalog.test.tsx +++ b/src/bundled/agents/AgentCreateDialog.catalog.test.tsx @@ -1,4 +1,6 @@ // @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); import "@testing-library/jest-dom/vitest"; import { act, @@ -9,6 +11,7 @@ import { waitFor, within, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import type { AgentControl, @@ -38,10 +41,10 @@ afterEach(() => { localStorage.clear(); }); -function fixture() { +function fixture(workspace = "/fixture/workspace") { const native = controlFixture(); native.data.createAvailable = true; - native.data.defaultWorkspace = "/fixture/workspace"; + native.data.defaultWorkspace = workspace; let state: AgentControlState = { status: "ready", data: native.data, @@ -199,7 +202,7 @@ it.each(["start", "profile"])( }, ); -it("keeps catalog status recovery and Close available when Create cannot be confirmed", async () => { +it("keeps Close available after creation fails even when status cannot recover", async () => { const f = fixture(); f.create.mockRejectedValueOnce(Error("create timed out")); f.select(); @@ -215,11 +218,14 @@ it("keeps catalog status recovery and Close available when Create cannot be conf ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Retry status" })); expect(f.control.refresh).toHaveBeenCalledTimes(2); + await waitFor(() => + expect(screen.getByRole("button", { name: "Close" })).toBeEnabled(), + ); fireEvent.click(screen.getByRole("button", { name: "Close" })); expect(f.close).toHaveBeenCalledOnce(); }); -it("allows Close during deferred catalog Create without late dismissal", async () => { +it("blocks Close during deferred catalog Create and ignores completion after host unmount", async () => { const f = fixture(); let complete!: ( agent: Awaited>>, @@ -236,10 +242,10 @@ it("allows Close during deferred catalog Create without late dismissal", async ( "Adding agent…", ); fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(f.close).toHaveBeenCalledOnce(); + expect(f.close).not.toHaveBeenCalled(); f.view.unmount(); await act(async () => complete(f.native.agent)); - expect(f.close).toHaveBeenCalledOnce(); + expect(f.close).not.toHaveBeenCalled(); }); it("does not admit the viewer's own publication", () => { @@ -250,3 +256,83 @@ it("does not admit the viewer's own publication", () => { screen.getByRole("button", { name: "Added to My Agents" }), ).toBeDisabled(); }); + +it.each(["write", "validation"])( + "retains a dirty draft after %s failure until explicit Close", + async (failure) => { + const f = fixture(failure === "validation" ? "" : undefined); + const user = userEvent.setup(); + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: "Keep me" }, + }); + f.create.mockRejectedValueOnce(Error("write failed")); + fireEvent.click(screen.getByRole("button", { name: "Create agent" })); + await screen.findByRole("alert"); + if (failure === "write") expect(f.create).toHaveBeenCalledOnce(); + else expect(f.create).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await user.click( + document.querySelector(".buzz-dialog-backdrop") as HTMLElement, + ); + expect(f.close).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Name")).toHaveValue("Keep me"); + await user.click(screen.getByRole("button", { name: "Close" })); + expect(f.close).toHaveBeenCalledOnce(); + }, +); + +it("retains a rejected recovery Stop after creation settles and status refresh succeeds", async () => { + const f = fixture(); + let rejectCreate!: (reason: Error) => void; + f.create.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCreate = reject; + }), + ); + f.action.mockRejectedValueOnce(Error("stop rejected")); + f.native.agent.status = "running"; + f.select(); + fireEvent.click(screen.getByRole("button", { name: "Add agent" })); + fireEvent.click(screen.getByRole("button", { name: /^Stop Fixture agent/ })); + const notice = await screen.findByText(/The agent didn't stop/); + await act(async () => rejectCreate(Error("create failed"))); + await waitFor(() => + expect(screen.getByRole("button", { name: "Close" })).toBeEnabled(), + ); + expect(notice).toBeVisible(); + expect(f.close).not.toHaveBeenCalled(); +}); + +it("finishes successful setup after recovery Stop without offering another profile retry", async () => { + const f = fixture(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + f.profile.mockImplementationOnce(async () => { + await gate; + const created = f.native.data.agents.find((agent) => agent.id === "copy"); + if (created) created.profilePending = false; + return f.native.data; + }); + f.native.agent.status = "running"; + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: "Helper" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => expect(f.profile).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole("button", { name: /^Stop Fixture agent/ })); + await act(async () => { + release(); + await gate; + }); + expect(await screen.findByText("Helper: setup is complete.")).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Finish profile" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Agent recovery" })).toBeVisible(); + expect(f.close).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(f.close).toHaveBeenCalledOnce(); +}); diff --git a/src/bundled/agents/AgentCreateDialog.test.tsx b/src/bundled/agents/AgentCreateDialog.test.tsx index 25479ecc8..5c9cb306d 100644 --- a/src/bundled/agents/AgentCreateDialog.test.tsx +++ b/src/bundled/agents/AgentCreateDialog.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { createAgentControl } from "../../features/agents/control"; @@ -66,6 +68,7 @@ it("creates Codex directly without an inference validation phase", async () => { vi.spyOn(communityApi, "communityRequest").mockResolvedValue({ auth: [] }); const control = createAgentControl(fixture.host); await control.refresh(); + const onClose = vi.fn(); function Dialog() { const state = useSyncExternalStore(control.subscribe, control.snapshot); return ( @@ -75,7 +78,7 @@ it("creates Codex directly without an inference validation phase", async () => { destination="https://relay.example.test" owner={"de".repeat(32)} source={fixture.agent} - onClose={() => {}} + onClose={onClose} /> ); } @@ -86,7 +89,56 @@ it("creates Codex directly without an inference validation phase", async () => { expect( screen.queryByRole("button", { name: "Cancel validation" }), ).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Close" })).toBeVisible(); + const close = screen.getByRole("button", { name: "Close" }); + expect(close).toBeDisabled(); + await userEvent.click(close); + await userEvent.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); committed.resolve(structuredClone(fixture.data)); control.dispose(); }); + +it.each(["starting", "publishing"] as const)( + "prevents dismissal during %s", + async (phase) => { + const fixture = codexFixture(); + fixture.agent.status = phase === "starting" ? "stopped" : "running"; + const control = createAgentControl(fixture.host); + await control.refresh(); + const pending = deferred(); + const onClose = vi.fn(); + control.create = vi.fn().mockResolvedValue(fixture.agent); + vi.spyOn(control, "action").mockImplementation(async () => { + if (phase === "starting") return pending.promise; + return { + ...fixture.data, + agents: [{ ...fixture.agent, status: "running" }], + }; + }); + vi.spyOn(control, "publishProfile").mockImplementation( + () => pending.promise, + ); + const view = render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => + expect( + phase === "starting" ? control.action : control.publishProfile, + ).toHaveBeenCalled(), + ); + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + view.unmount(); + control.dispose(); + }, +); diff --git a/src/bundled/agents/AgentCreateDialog.tsx b/src/bundled/agents/AgentCreateDialog.tsx index 7b464c3da..b8db4b832 100644 --- a/src/bundled/agents/AgentCreateDialog.tsx +++ b/src/bundled/agents/AgentCreateDialog.tsx @@ -1,7 +1,17 @@ +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { XIcon } from "../../shared/design-system/icons"; +import type { InstructionEditingActions } from "./AgentInstructions"; +import { + randomAgentAvatar, + isRetiredAgentAvatar, +} from "../../features/agents/avatar-packs"; +import { useManagedAgentActions } from "./ManagedAgentActions"; +import { AgentCreateHeader } from "./AgentCreateHeader"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Dialog } from "@base-ui/react/dialog"; import { agentFailureReason, + canStopAgent, type AgentControl, type AgentControlState, type CatalogSeed, @@ -172,8 +182,18 @@ export function AgentCreateDialog({ const cloned = !!initialSettings && !("origin" in initialSettings); const [dirty, setDirty] = useState(false); const [saved, setSaved] = useState(null); - const [nextStep, setNextStep] = useState<"start" | "profile">("start"); + const [nextStep, setNextStep] = useState<"start" | "profile" | "complete">( + "start", + ); const [error, setError] = useState(); + useEffect(() => { + if (source || !state.data?.avatarEditingAvailable) return; + setDraft((current) => + !current.picture || isRetiredAgentAvatar(current.picture) + ? { ...current, picture: randomAgentAvatar().url } + : current, + ); + }, [source, state.data?.avatarEditingAvailable]); const [phase, setPhase] = useState(null); const mounted = useRef(true); useEffect(() => { @@ -190,10 +210,26 @@ export function AgentCreateDialog({ ); const runtimeBlocked = !state.data?.runtimeAvailable && (!saved || nextStep === "start"); + const [modelTarget, setModelTarget] = useState(null); + const [avatarActionTarget, setAvatarActionTarget] = + useState(null); + const avatarNavigation = useRef<{ back(): void }>(null); + const [configurationOpen, setConfigurationOpen] = useState(false); + const [avatarActive, setAvatarActive] = useState(false); + const instructionEditing = useRef(null); + const [instructionActive, setInstructionActive] = useState(false); + const [instructionBusy, setInstructionBusy] = useState(false); + const [avatarBusy, setAvatarBusy] = useState(false); + const form = useRef(null); const busy = phase !== null; const blocked = busy || state.busy || state.status !== "ready"; + const recoveryAction = useManagedAgentActions(state, control); + // Keep recovery outcomes mounted until the user explicitly closes the dialog. + const recoveryUsed = useRef(false); + const closeBlocked = busy || !!state.stopping; const create = async (publication?: AgentPublication) => { if ( + nextStep === "complete" || blocked || runtimeBlocked || (publication && @@ -276,7 +312,10 @@ export function AgentCreateDialog({ } if (mounted.current) { if (startFailure) setError(startFailure); - else onClose(); + else { + setNextStep("complete"); + if (!recoveryUsed.current) onClose(); + } } } catch (problem) { if (mounted.current) setPhase("checking"); @@ -294,7 +333,10 @@ export function AgentCreateDialog({ setError(undefined); } else if (step === "publishing" && current?.profilePending === false) { if (startFailure) setError(startFailure); - else onClose(); + else { + setNextStep("complete"); + if (!recoveryUsed.current) onClose(); + } } else if (step === "creating") { setError( `We couldn't confirm whether the agent was created.${reason} Check the agent list before trying again.`, @@ -322,7 +364,7 @@ export function AgentCreateDialog({ { - if (!open && !dirty && !blocked) onClose(); + if (!open && !dirty && !closeBlocked) onClose(); }} > @@ -334,18 +376,54 @@ export function AgentCreateDialog({ >
- {source - ? `Duplicate ${source.name}` - : cloned - ? "Clone agent" - : "Add agent"} + {configurationOpen + ? "AI configuration" + : source + ? `Duplicate ${source.name}` + : cloned + ? "Clone agent" + : "Create agent"} + } + onClick={onClose} + />
- - Create and start an agent in{" "} - {destination || "a connected community"}. It won't join a channel - automatically. - + {(busy || recoveryUsed.current) && !!state.data?.agents.length && ( +
+ {state.data.agents.map((agent) => { + const action = recoveryAction(agent); + return ( +
+ + {action.checking && ( +

Checking agent status…

+ )} + {action.notice &&

{action.notice}

} + {agent.error &&

{agent.error}

} +
+ ); + })} +
+ )} {cloned && (

Only the name and instructions were copied. Review them for @@ -442,6 +520,9 @@ export function AgentCreateDialog({ )} {error &&

{error}

} {busy &&

Adding agent…

} + {saved && nextStep === "complete" && !busy && ( +

{saved.name}: setup is complete.

+ )} - {saved && ( + {saved && nextStep !== "complete" && ( )}
- - + {(instructionActive || avatarActive || configurationOpen) && ( + + )} + )} diff --git a/src/bundled/agents/AgentCreateHeader.test.tsx b/src/bundled/agents/AgentCreateHeader.test.tsx new file mode 100644 index 000000000..68f3a1654 --- /dev/null +++ b/src/bundled/agents/AgentCreateHeader.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { createRef } from "react"; +import type { InstructionEditingActions } from "./AgentInstructions"; +import { AgentCreateHeader } from "./AgentCreateHeader"; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +it("keeps the name under the avatar and expands choices only on request", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const onChange = vi.fn(); + const editing = createRef(); + render( + , + ); + expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Helper"); + fireEvent.click(screen.getByRole("button", { name: "Agent instructions" })); + fireEvent.click(screen.getByRole("button", { name: "Text" })); + expect(screen.getByLabelText("Agent instructions")).toHaveValue( + "Existing instructions", + ); + expect( + screen + .getByLabelText("Agent instructions") + .closest(".agent-create-identity"), + ).not.toBeNull(); + act(() => editing.current?.done()); + expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Helper"); + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { + target: { value: "New name" }, + }); + expect(onChange).toHaveBeenLastCalledWith({ name: "New name" }); + expect(screen.queryByRole("button", { name: "Gloopies 1" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Choose avatar" })); + fireEvent.click(await screen.findByRole("button", { name: /Gloopies$/ })); + fireEvent.click(await screen.findByRole("button", { name: "Gloopies 1" })); + expect(onChange).toHaveBeenLastCalledWith({ + picture: expect.stringMatching(/^https:/), + }); + expect(screen.getByRole("button", { name: "Choose avatar" })).toHaveFocus(); + expect( + screen + .getByRole("button", { name: "Choose avatar" }) + .closest("[data-avatar-view]"), + ).toHaveAttribute("data-avatar-view", "selected"); +}); diff --git a/src/bundled/agents/AgentCreateHeader.tsx b/src/bundled/agents/AgentCreateHeader.tsx new file mode 100644 index 000000000..20c5a08d7 --- /dev/null +++ b/src/bundled/agents/AgentCreateHeader.tsx @@ -0,0 +1,355 @@ +import { + AgentInstructions, + type InstructionEditingActions, +} from "./AgentInstructions"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; +import { + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + AnimatePresence, + LayoutGroup, + motion, + useReducedMotion, +} from "motion/react"; +import { useAvatarPreview } from "../../features/profiles/use-avatar-preview"; +import { AvatarEditor } from "../../features/profiles/AvatarEditor"; +import { agentAvatars, avatarPacks } from "../../features/agents/avatar-packs"; +import { Button } from "../../shared/design-system/ui/Button"; +import { CloudUploadIcon } from "../../shared/design-system/icons"; +import { PackedAgentAvatar } from "./PackedAgentAvatar"; + +export function AgentCreateHeader({ + name, + picture, + disabled, + onChange, + community, + onBusyChange, + modelSlotRef, + instructions, + onInstructionBusyChange, + onInstructionActiveChange, + instructionEditingRef, + avatarNavigationRef, + avatarActionTarget, + onAvatarActiveChange, +}: { + instructionEditingRef?: import("react").Ref; + avatarActionTarget?: HTMLElement | null; + avatarNavigationRef?: import("react").Ref<{ back(): void }>; + onAvatarActiveChange?: (active: boolean) => void; + instructions: string; + onInstructionActiveChange?: (active: boolean) => void; + onInstructionBusyChange?: ((busy: boolean) => void) | undefined; + modelSlotRef?: ((element: HTMLDivElement | null) => void) | undefined; + community?: string | undefined; + onBusyChange?: ((busy: boolean) => void) | undefined; + name: string; + picture?: string | undefined; + disabled: boolean; + onChange(patch: { + name?: string; + picture?: string; + systemPrompt?: string; + }): void; +}) { + const preview = useAvatarPreview(picture ?? "", community); + const [instructionsActive, setInstructionsActive] = useState(false); + useEffect(() => { + onInstructionActiveChange?.(instructionsActive); + }, [instructionsActive, onInstructionActiveChange]); + const [view, setView] = useState("selected"); + useEffect(() => { + onAvatarActiveChange?.(view !== "selected"); + }, [view, onAvatarActiveChange]); + useImperativeHandle(avatarNavigationRef, () => ({ + back() { + setView(view === "collections" ? "selected" : "collections"); + }, + })); + const [keyboard, setKeyboard] = useState(false); + const reducedMotion = useReducedMotion(); + const root = useRef(null); + const previousView = useRef(view); + const [representatives] = useState(() => + avatarPacks.map((pack) => { + const members = agentAvatars.filter( + (avatar) => avatar.collectionId === pack.id, + ); + return { + ...pack, + avatar: members[Math.floor(Math.random() * members.length)], + }; + }), + ); + // Hot updates preserve the open dialog's random choices; retire removed assets. + const currentRepresentatives = useMemo( + () => + avatarPacks.map((pack) => { + const members = agentAvatars.filter( + (avatar) => avatar.collectionId === pack.id, + ); + const previous = representatives.find( + (item) => item.id === pack.id, + )?.avatar; + return { + ...pack, + avatar: + members.find((avatar) => avatar.id === previous?.id) ?? + members[Math.floor(Math.random() * members.length)], + }; + }), + [representatives], + ); + useLayoutEffect(() => { + if (previousView.current !== view) + root.current + ?.querySelector( + `[data-avatar-view="${view}"] button`, + ) + ?.focus({ preventScroll: true }); + previousView.current = view; + }, [view]); + const [gridSize, setGridSize] = useState({ width: 768, height: 432 }); + useLayoutEffect(() => { + const element = root.current; + if (!element) return; + const observer = new ResizeObserver(([entry]) => { + if (entry) + setGridSize({ + width: Math.max(1, entry.contentRect.width - 96), + height: Math.max(1, entry.contentRect.height - 32), + }); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + const choices = agentAvatars.filter((avatar) => avatar.collectionId === view); + let columns = 1; + let largestTile = 0; + for (let candidate = 1; candidate <= choices.length; candidate++) { + const rows = Math.ceil(choices.length / candidate); + const size = Math.min( + (gridSize.width - (candidate - 1) * 8) / candidate, + (gridSize.height - (rows - 1) * 8) / rows, + ); + if (size > largestTile) { + columns = candidate; + largestTile = size; + } + } + const selected = agentAvatars.find((avatar) => avatar.url === picture); + const transition = { + duration: reducedMotion || keyboard ? 0 : 0.24, + ease: [0.22, 1, 0.36, 1] as const, + }; + const artwork = (avatar: (typeof agentAvatars)[number]) => ( + + + + ); + return ( + +
setKeyboard(false)} + onKeyDown={() => setKeyboard(true)} + > + +
+ + + {view === "selected" ? ( + + {selected ? ( + artwork(selected) + ) : preview ? ( + + Agent avatar + + ) : ( + + {name.trim().slice(0, 2) || "?"} + + )} +
+ +
+
+ ) : view === "collections" ? ( +
+ {currentRepresentatives.map((pack) => ( + + ))} + +
+ ) : view === "image" ? ( +
+ { + onChange({ picture: value }); + setView("selected"); + }} + /> +
+ ) : ( +
pack.id === view)?.label + } + > + {choices.map((avatar) => ( + + ))} +
+ )} +
+
+
+ + + onChange({ name: event.target.value })} + /> + + + +
+
+ ); +} diff --git a/src/bundled/agents/AgentHarnessEditor.tsx b/src/bundled/agents/AgentHarnessEditor.tsx index 321c70007..4498a5494 100644 --- a/src/bundled/agents/AgentHarnessEditor.tsx +++ b/src/bundled/agents/AgentHarnessEditor.tsx @@ -19,7 +19,11 @@ export function AgentHarnessEditor({ onOpenHarnesses, discardEdits = false, disabled = false, + hideHarness = false, + hideSetupHints = false, }: { + hideHarness?: boolean; + hideSetupHints?: boolean; draft: AgentDraft; onOpenHarnesses?: (() => void) | undefined; discardEdits?: boolean; @@ -60,117 +64,126 @@ export function AgentHarnessEditor({ const missingPreset = preset && (!harness || harness.available === false); return (
- ({ - value: command, - label: - available === false && status === "not-enabled" - ? `${label} (coming in a later update)` - : available === false - ? `${label} (install first)` - : label, - disabled: available === false, - })), - ...(isPreset && - !options.some((option) => option.command === draft.command) - ? [ - { - value: draft.command, - label: `${harness?.label ?? preset?.label} (current executable)`, - }, - ] - : []), - ]} - onChange={(command, pickedOption) => { - const option = options.find((item) => item.command === command); - const enteringExternal = - !!option && - (!!harnessPreset(option.command) || - (option.configurationPolicy - ? option.configurationPolicy.authentication === - "harnessWithOverrides" - : ["goose", "pi"].includes(harnessKind(option.command) ?? ""))); - const wasCodex = draft.integration === "codex"; - // Rebinding Codex (its option or a custom adapter path) keeps its - // saved Default/Advanced settings. - if (wasCodex && (!pickedOption || option?.id === "codex")) { - onChange({ command }); - return; - } - onChange({ - command, - ...(pickedOption - ? { - integration: option?.id === "codex" ? option.id : undefined, - } - : { integration: undefined, configuration: undefined }), - ...(pickedOption && (enteringExternal || external || isPreset) - ? { - args: JSON.stringify(option?.defaultArgs ?? []), - provider: enteringExternal - ? "" - : (option?.providers[0]?.value ?? ""), - model: "", - } - : {}), - ...(pickedOption && option?.id === "codex" - ? { - args: "[]", - provider: "", - model: "", - configuration: { mode: "default" }, + {!hideHarness && ( +
+ ({ + value: command, + label: available === false ? `${label} (install first)` : label, + disabled: available === false, + })), + ...(isPreset && + !options.some((option) => option.command === draft.command) + ? [ + { + value: draft.command, + label: `${harness?.label ?? preset?.label} (current executable)`, + }, + ] + : []), + ]} + onChange={(command, pickedOption) => { + const option = options.find((item) => item.command === command); + const enteringExternal = + !!option && + (!!harnessPreset(option.command) || + (option.configurationPolicy + ? option.configurationPolicy.authentication === + "harnessWithOverrides" + : ["goose", "pi"].includes( + harnessKind(option.command) ?? "", + ))); + const wasCodex = draft.integration === "codex"; + // Rebinding Codex (its option or a custom adapter path) keeps its + // saved Default/Advanced settings. + if (wasCodex && (!pickedOption || option?.id === "codex")) { + onChange({ command }); + return; + } + onChange({ + command, + ...(pickedOption + ? { + integration: + option?.id === "codex" ? option.id : undefined, + } + : { integration: undefined, configuration: undefined }), + ...(pickedOption && (enteringExternal || external || isPreset) + ? { + args: JSON.stringify(option?.defaultArgs ?? []), + provider: enteringExternal + ? "" + : (option?.providers[0]?.value ?? ""), + model: "", + } + : {}), + ...(pickedOption && option?.id === "codex" + ? { + args: "[]", + provider: "", + model: "", + configuration: { mode: "default" }, + } + : wasCodex + ? { + args: JSON.stringify(option?.defaultArgs ?? []), + model: "", + configuration: undefined, + } + : {}), + }); + }} + /> + {onOpenHarnesses && + (hideSetupHints || missingPi || missingPreset || missingCodex) && ( + + )} +
)} - {(missingPi || missingPreset || missingCodex) && onOpenHarnesses && ( -
- - {discardEdits && ( -

- Opening Settings discards unsaved edits. + {!hideSetupHints && ( + <> + {missingPi && ( +

+ Pi needs its CLI, Node.js and buzz-pi-acp before you can select + it.

)} -
+ {missingCodex && ( +

+ Codex needs its CLI and ACP adapter before you can select it. Set + it up under Settings → Agents → Harnesses. +

+ )} + {missingPreset && ( +

+ {preset.label} needs its ACP launcher. Set it up under Settings → + Agents → Harnesses. +

+ )} + )} - {!isPreset && policy?.provider !== "external" && ( + {!isPreset && draft.integration !== "codex" && ( option.value === value); - const showInput = custom || index < 0; + const showInput = + custom || + index < 0 || + !!options[index]?.label.endsWith("(current executable)"); return (