mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refine agent catalog sharing
This commit is contained in:
@@ -610,6 +610,7 @@ mod tests {
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
@@ -659,6 +660,7 @@ mod tests {
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
@@ -704,6 +706,7 @@ mod tests {
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
|
||||
@@ -432,6 +432,7 @@ mod png_body_tests {
|
||||
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
|
||||
definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition {
|
||||
name: "Agent".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
|
||||
@@ -13,7 +13,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel},
|
||||
agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel},
|
||||
load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition,
|
||||
ManagedAgentRecord, RespondTo,
|
||||
},
|
||||
@@ -50,6 +50,13 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri
|
||||
pub struct AgentSnapshotImportPreview {
|
||||
/// Agent display name from the snapshot.
|
||||
pub display_name: String,
|
||||
/// Whether the exported source definition was built in. This is display
|
||||
/// metadata only; confirmed imports are always independent custom agents.
|
||||
pub is_builtin: bool,
|
||||
/// Preferred model from the exported definition.
|
||||
pub model: Option<String>,
|
||||
/// Preferred runtime from the exported definition.
|
||||
pub runtime: Option<String>,
|
||||
/// System prompt, if any.
|
||||
pub system_prompt: Option<String>,
|
||||
/// Effective avatar: data URL if present, otherwise the source URL fallback.
|
||||
@@ -262,32 +269,41 @@ pub async fn preview_agent_snapshot_import(
|
||||
reject_legacy_persona_filename(&file_name)?;
|
||||
let snapshot = decode_snapshot_from_bytes(&file_bytes)?;
|
||||
|
||||
let memory_level = match snapshot.memory.level {
|
||||
MemoryLevel::None => "none",
|
||||
MemoryLevel::Core => "core",
|
||||
MemoryLevel::Everything => "everything",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
Ok(AgentSnapshotImportPreview {
|
||||
display_name: snapshot.profile.display_name.clone(),
|
||||
system_prompt: snapshot.definition.system_prompt.clone(),
|
||||
// Effective avatar: data URL wins; URL fallback if no data URL.
|
||||
avatar_url: snapshot
|
||||
.profile
|
||||
.avatar_data_url
|
||||
.clone()
|
||||
.or_else(|| snapshot.profile.avatar_url.clone()),
|
||||
memory_level,
|
||||
memory_entry_count: snapshot.memory.entries.len(),
|
||||
source_allowlist_count: snapshot.definition.respond_to_allowlist.len(),
|
||||
has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(),
|
||||
})
|
||||
Ok(build_agent_snapshot_import_preview(&snapshot))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn build_agent_snapshot_import_preview(
|
||||
snapshot: &AgentSnapshot,
|
||||
) -> AgentSnapshotImportPreview {
|
||||
let memory_level = match snapshot.memory.level {
|
||||
MemoryLevel::None => "none",
|
||||
MemoryLevel::Core => "core",
|
||||
MemoryLevel::Everything => "everything",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
AgentSnapshotImportPreview {
|
||||
display_name: snapshot.profile.display_name.clone(),
|
||||
is_builtin: snapshot.definition.source_is_builtin,
|
||||
model: snapshot.definition.model.clone(),
|
||||
runtime: snapshot.definition.runtime.clone(),
|
||||
system_prompt: snapshot.definition.system_prompt.clone(),
|
||||
// Effective avatar: data URL wins; URL fallback if no data URL.
|
||||
avatar_url: snapshot
|
||||
.profile
|
||||
.avatar_data_url
|
||||
.clone()
|
||||
.or_else(|| snapshot.profile.avatar_url.clone()),
|
||||
memory_level,
|
||||
memory_entry_count: snapshot.memory.entries.len(),
|
||||
source_allowlist_count: snapshot.definition.respond_to_allowlist.len(),
|
||||
has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── `confirm_agent_snapshot_import` ──────────────────────────────────────────
|
||||
|
||||
/// Import a `buzz-agent-snapshot v1` file as a brand-new agent.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::import::{
|
||||
decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior,
|
||||
AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES,
|
||||
build_agent_snapshot_import_preview, decode_snapshot_from_bytes,
|
||||
reject_legacy_persona_filename, resolve_snapshot_import_behavior, AgentSnapshotImportResult,
|
||||
MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES,
|
||||
};
|
||||
use super::*;
|
||||
use crate::managed_agents::{
|
||||
@@ -94,6 +95,7 @@ fn make_snapshot(
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "Test Agent".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: Some("You are helpful.".to_string()),
|
||||
runtime: None,
|
||||
model: None,
|
||||
@@ -551,6 +553,22 @@ fn import_preview_flags_non_empty_source_allowlist() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_includes_exported_definition_metadata() {
|
||||
let mut snapshot = make_snapshot(MemoryLevel::None, vec![]);
|
||||
snapshot.definition.source_is_builtin = true;
|
||||
snapshot.definition.model = Some("claude-opus-4-5".to_string());
|
||||
snapshot.definition.runtime = Some("goose".to_string());
|
||||
let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap();
|
||||
let decoded = decode_snapshot_from_bytes(&bytes).unwrap();
|
||||
|
||||
let preview = build_agent_snapshot_import_preview(&decoded);
|
||||
|
||||
assert!(preview.is_builtin);
|
||||
assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5"));
|
||||
assert_eq!(preview.runtime.as_deref(), Some("goose"));
|
||||
}
|
||||
|
||||
// ── Import: resolve_snapshot_import_behavior — the production selection path
|
||||
//
|
||||
// All tests below call `resolve_snapshot_import_behavior` directly. This is
|
||||
|
||||
@@ -13,6 +13,7 @@ fn member(name: &str) -> AgentSnapshot {
|
||||
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: name.to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: Some(format!("{name} prompt")),
|
||||
runtime: Some("goose".to_string()),
|
||||
model: None,
|
||||
|
||||
@@ -31,7 +31,11 @@
|
||||
//! - lineage ids: `persona_id`, `team_id`, `source_team`, `source_team_persona_slug`,
|
||||
//! `persona_source_version`
|
||||
//! - internal bookkeeping: `start_on_app_launch`,
|
||||
//! `auto_restart_on_config_change`, `is_builtin`
|
||||
//! `auto_restart_on_config_change`
|
||||
//!
|
||||
//! The portable `sourceIsBuiltIn` hint preserves how the exported definition
|
||||
//! should be described in an import preview. It never grants built-in status
|
||||
//! to the newly imported definition.
|
||||
//!
|
||||
//! These exclusions are enforced by construction (only explicit fields are
|
||||
//! placed into `AgentSnapshotDefinition`) and asserted by unit tests.
|
||||
@@ -87,6 +91,10 @@ pub enum MemoryLevel {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentSnapshotDefinition {
|
||||
pub name: String,
|
||||
/// Portable source classification for import-preview metadata. Imported
|
||||
/// definitions are still created as custom agents with fresh identities.
|
||||
#[serde(default)]
|
||||
pub source_is_builtin: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -191,6 +199,7 @@ pub fn build_snapshot(
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| record.name.clone()),
|
||||
source_is_builtin: record.is_builtin,
|
||||
system_prompt: record.system_prompt.clone(),
|
||||
runtime: record.runtime.clone(),
|
||||
model: record.model.clone(),
|
||||
@@ -913,6 +922,7 @@ mod tests {
|
||||
let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
|
||||
|
||||
assert_eq!(snapshot.definition.name, "Test Agent Display");
|
||||
assert!(!snapshot.definition.source_is_builtin);
|
||||
assert_eq!(
|
||||
snapshot.definition.system_prompt.as_deref(),
|
||||
Some("You are a test agent.")
|
||||
|
||||
@@ -254,10 +254,7 @@ pub fn ensure_persona_is_active(
|
||||
.ok_or_else(|| format!("agent {persona_id} not found"))?;
|
||||
|
||||
if !persona.is_active {
|
||||
return Err(format!(
|
||||
"{} is not in My Agents. Choose it from Agent Catalog first.",
|
||||
persona.display_name
|
||||
));
|
||||
return Err(format!("{} is not in My Agents.", persona.display_name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -171,10 +171,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() {
|
||||
|
||||
let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
"Fizz is not in My Agents. Choose it from Agent Catalog first."
|
||||
);
|
||||
assert_eq!(err, "Fizz is not in My Agents.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
@@ -25,33 +25,38 @@ function createPersona(id, displayName, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("getCatalogPersonas keeps built-ins visible whether selected or not", () => {
|
||||
test("getCatalogPersonas hides built-ins and includes shared custom agents", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
|
||||
createPersona("custom:builder", "Builder"),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
getCatalogPersonas(personas).map((persona) => persona.id),
|
||||
["builtin:fizz"],
|
||||
getCatalogPersonas(personas, new Set(["custom:builder"])).map(
|
||||
(persona) => persona.id,
|
||||
),
|
||||
["custom:builder"],
|
||||
);
|
||||
});
|
||||
|
||||
test("getCatalogSelectionState keeps built-in selection rules in one place", () => {
|
||||
test("getCatalogSelectionState only selects shared custom agents", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
|
||||
createPersona("custom:builder", "Builder"),
|
||||
];
|
||||
|
||||
const state = getCatalogSelectionState(personas);
|
||||
const state = getCatalogSelectionState(
|
||||
personas,
|
||||
new Set(["builtin:fizz", "custom:builder"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.catalogPersonas.map((persona) => persona.id),
|
||||
["builtin:fizz"],
|
||||
["custom:builder"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.selectedCatalogPersonas.map((persona) => persona.id),
|
||||
["builtin:fizz"],
|
||||
["custom:builder"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.unselectedCatalogPersonas.map((persona) => persona.id),
|
||||
@@ -61,23 +66,22 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", ()
|
||||
|
||||
test("getCatalogPersonas keeps chooser order stable when selection changes", () => {
|
||||
const inactive = [
|
||||
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
|
||||
createPersona("builtin:reviewer", "Reviewer", {
|
||||
isBuiltIn: true,
|
||||
createPersona("custom:fizz", "Fizz", { isActive: false }),
|
||||
createPersona("custom:reviewer", "Reviewer", {
|
||||
isActive: true,
|
||||
}),
|
||||
];
|
||||
const active = [
|
||||
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
|
||||
createPersona("builtin:reviewer", "Reviewer", {
|
||||
isBuiltIn: true,
|
||||
createPersona("custom:fizz", "Fizz", { isActive: true }),
|
||||
createPersona("custom:reviewer", "Reviewer", {
|
||||
isActive: false,
|
||||
}),
|
||||
];
|
||||
const shared = new Set(["custom:fizz", "custom:reviewer"]);
|
||||
|
||||
assert.deepEqual(
|
||||
getCatalogPersonas(inactive).map((persona) => persona.id),
|
||||
getCatalogPersonas(active).map((persona) => persona.id),
|
||||
getCatalogPersonas(inactive, shared).map((persona) => persona.id),
|
||||
getCatalogPersonas(active, shared).map((persona) => persona.id),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -118,13 +122,16 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("getPersonaLibraryState keeps the working library and full catalog in one place", () => {
|
||||
test("getPersonaLibraryState keeps built-ins in the library but not the catalog", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
|
||||
createPersona("custom:builder", "Builder"),
|
||||
];
|
||||
|
||||
const state = getPersonaLibraryState(personas);
|
||||
const state = getPersonaLibraryState(
|
||||
personas,
|
||||
new Set(["builtin:fizz", "custom:builder"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.libraryPersonas.map((persona) => persona.id),
|
||||
@@ -132,7 +139,7 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.catalogPersonas.map((persona) => persona.id),
|
||||
["builtin:fizz"],
|
||||
["custom:builder"],
|
||||
);
|
||||
assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz");
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export function isPersonaVisibleInCatalog(
|
||||
persona: AgentPersona,
|
||||
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id);
|
||||
return !persona.isBuiltIn && sharedCatalogPersonaIds.has(persona.id);
|
||||
}
|
||||
|
||||
export function getCatalogPersonas(
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { clearLegacyPersonaCatalogVisibility } from "./legacyPersonaCatalogVisibility.ts";
|
||||
|
||||
test("clearLegacyPersonaCatalogVisibility removes the retired preference", () => {
|
||||
const removedKeys = [];
|
||||
|
||||
clearLegacyPersonaCatalogVisibility({
|
||||
removeItem(key) {
|
||||
removedKeys.push(key);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(removedKeys, ["buzz-persona-catalog-visibility-v1"]);
|
||||
});
|
||||
|
||||
test("clearLegacyPersonaCatalogVisibility ignores unavailable storage", () => {
|
||||
assert.doesNotThrow(() => clearLegacyPersonaCatalogVisibility(null));
|
||||
assert.doesNotThrow(() =>
|
||||
clearLegacyPersonaCatalogVisibility({
|
||||
removeItem() {
|
||||
throw new Error("storage unavailable");
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
const LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY =
|
||||
"buzz-persona-catalog-visibility-v1";
|
||||
|
||||
/**
|
||||
* Removes the retired custom-persona catalog preference so it cannot resurface
|
||||
* agents after the visibility control has been removed.
|
||||
*/
|
||||
export function clearLegacyPersonaCatalogVisibility(
|
||||
storage?: Pick<Storage, "removeItem"> | null,
|
||||
) {
|
||||
let targetStorage = storage;
|
||||
if (targetStorage === undefined) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
targetStorage = window.localStorage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!targetStorage) return;
|
||||
|
||||
try {
|
||||
targetStorage.removeItem(LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY);
|
||||
} catch {
|
||||
// Catalog cleanup is best-effort and should not block the agents view.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
readSharedCatalogPersonaIds,
|
||||
writeSharedCatalogPersonaIds,
|
||||
} from "./personaCatalogVisibility.ts";
|
||||
|
||||
test("catalog visibility reads stored persona ids", () => {
|
||||
const storage = {
|
||||
getItem: () => JSON.stringify(["custom:analyst", 42, "custom:writer"]),
|
||||
};
|
||||
|
||||
assert.deepEqual(readSharedCatalogPersonaIds(storage), [
|
||||
"custom:analyst",
|
||||
"custom:writer",
|
||||
]);
|
||||
});
|
||||
|
||||
test("catalog visibility tolerates unavailable and invalid storage", () => {
|
||||
assert.deepEqual(readSharedCatalogPersonaIds(null), []);
|
||||
assert.deepEqual(
|
||||
readSharedCatalogPersonaIds({ getItem: () => "not-json" }),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(readSharedCatalogPersonaIds({ getItem: () => "{}" }), []);
|
||||
assert.deepEqual(
|
||||
readSharedCatalogPersonaIds({
|
||||
getItem: () => {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog visibility persists persona ids without blocking on storage errors", () => {
|
||||
let storedKey = "";
|
||||
let storedValue = "";
|
||||
writeSharedCatalogPersonaIds(["custom:analyst"], {
|
||||
setItem: (key, value) => {
|
||||
storedKey = key;
|
||||
storedValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(storedKey, "buzz-persona-catalog-visibility-v1");
|
||||
assert.equal(storedValue, '["custom:analyst"]');
|
||||
assert.doesNotThrow(() =>
|
||||
writeSharedCatalogPersonaIds(["custom:analyst"], {
|
||||
setItem: () => {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY =
|
||||
"buzz-persona-catalog-visibility-v1";
|
||||
|
||||
function resolveStorage<T extends "getItem" | "setItem">(
|
||||
storage: Pick<Storage, T> | null | undefined,
|
||||
): Pick<Storage, T> | null {
|
||||
if (storage !== undefined) return storage;
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readSharedCatalogPersonaIds(
|
||||
storage?: Pick<Storage, "getItem"> | null,
|
||||
): string[] {
|
||||
const targetStorage = resolveStorage(storage);
|
||||
if (!targetStorage) return [];
|
||||
|
||||
try {
|
||||
const raw = targetStorage.getItem(PERSONA_CATALOG_VISIBILITY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter((id): id is string => typeof id === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSharedCatalogPersonaIds(
|
||||
ids: readonly string[],
|
||||
storage?: Pick<Storage, "setItem"> | null,
|
||||
): void {
|
||||
const targetStorage = resolveStorage(storage);
|
||||
if (!targetStorage) return;
|
||||
|
||||
try {
|
||||
targetStorage.setItem(
|
||||
PERSONA_CATALOG_VISIBILITY_STORAGE_KEY,
|
||||
JSON.stringify(ids),
|
||||
);
|
||||
} catch {
|
||||
// Catalog visibility is a convenience setting and should not block sharing.
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Dialog } from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -83,6 +82,7 @@ import {
|
||||
} from "./agentAiConfigurationPolicy";
|
||||
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
|
||||
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
|
||||
import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter";
|
||||
|
||||
type AgentDefinitionDialogProps = {
|
||||
open: boolean;
|
||||
@@ -734,41 +734,14 @@ export function AgentDefinitionDialog({
|
||||
headerClassName="pb-2"
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex min-h-9 items-center">
|
||||
{submitBlockReason ? (
|
||||
<p
|
||||
className="text-2xs text-muted-foreground"
|
||||
data-testid="persona-dialog-submit-reason"
|
||||
>
|
||||
{submitBlockReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isPending || isAvatarUploadPending}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="persona-dialog-submit"
|
||||
disabled={!canSubmit}
|
||||
form="persona-dialog-form"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? "Saving..."
|
||||
: isAvatarUploadPending
|
||||
? "Uploading..."
|
||||
: submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AgentDefinitionDialogFooter
|
||||
canSubmit={canSubmit}
|
||||
isAvatarUploadPending={isAvatarUploadPending}
|
||||
isPending={isPending}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
submitBlockReason={displayName.trim() ? submitBlockReason : null}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<form
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type AgentDefinitionDialogFooterProps = {
|
||||
canSubmit: boolean;
|
||||
isAvatarUploadPending: boolean;
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
submitBlockReason: string | null;
|
||||
submitLabel: string;
|
||||
};
|
||||
|
||||
export function AgentDefinitionDialogFooter({
|
||||
canSubmit,
|
||||
isAvatarUploadPending,
|
||||
isPending,
|
||||
onCancel,
|
||||
submitBlockReason,
|
||||
submitLabel,
|
||||
}: AgentDefinitionDialogFooterProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-3">
|
||||
{submitBlockReason ? (
|
||||
<p
|
||||
className="text-2xs text-muted-foreground"
|
||||
data-testid="persona-dialog-submit-reason"
|
||||
>
|
||||
{submitBlockReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isPending || isAvatarUploadPending}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="persona-dialog-submit"
|
||||
disabled={!canSubmit}
|
||||
form="persona-dialog-form"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? "Saving..."
|
||||
: isAvatarUploadPending
|
||||
? "Uploading..."
|
||||
: submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export function AgentDefinitionMetadata({
|
||||
className,
|
||||
isBuiltIn,
|
||||
model,
|
||||
runtime,
|
||||
}: {
|
||||
className?: string;
|
||||
isBuiltIn: boolean;
|
||||
model: string | null;
|
||||
runtime: string | null;
|
||||
}) {
|
||||
const items = [
|
||||
{
|
||||
label: "Type",
|
||||
value: isBuiltIn ? "Built-in agent" : "Custom agent",
|
||||
},
|
||||
{
|
||||
label: "Preferred model",
|
||||
value: model ?? "Use app default",
|
||||
},
|
||||
{
|
||||
label: "Preferred runtime",
|
||||
value: runtime ?? "Use app default",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border border-border/70 bg-card/70", className)}
|
||||
data-testid="agent-definition-metadata"
|
||||
>
|
||||
<div className="grid sm:grid-cols-3">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative px-4 py-3",
|
||||
index > 0 &&
|
||||
"border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70",
|
||||
)}
|
||||
key={item.label}
|
||||
>
|
||||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-foreground">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
|
||||
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ImportPhase = "preview" | "confirming" | "result";
|
||||
@@ -164,6 +166,12 @@ function PreviewBody({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AgentDefinitionMetadata
|
||||
isBuiltIn={preview.isBuiltIn}
|
||||
model={preview.model}
|
||||
runtime={preview.runtime}
|
||||
/>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new agent will be created with a fresh keypair. The imported agent is
|
||||
independent of the source — identity never travels.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { OctagonX } from "lucide-react";
|
||||
import { OctagonX, Settings2 } from "lucide-react";
|
||||
import {
|
||||
consumePendingSnapshotImport,
|
||||
subscribeSnapshotImport,
|
||||
@@ -21,7 +21,10 @@ import { SecretRevealDialog } from "./SecretRevealDialog";
|
||||
import { TeamDeleteDialog } from "./TeamDeleteDialog";
|
||||
import { TeamDialog } from "./TeamDialog";
|
||||
import { TeamsSection } from "./TeamsSection";
|
||||
import { UnifiedAgentsSection } from "./UnifiedAgentsSection";
|
||||
import {
|
||||
AGENT_CARD_GRID_COLUMNS_CLASS,
|
||||
UnifiedAgentsSection,
|
||||
} from "./UnifiedAgentsSection";
|
||||
import { useManagedAgentActions } from "./useManagedAgentActions";
|
||||
import { usePersonaActions } from "./usePersonaActions";
|
||||
import { useTeamActions } from "./useTeamActions";
|
||||
@@ -71,11 +74,14 @@ export function AgentsView() {
|
||||
const runningAgentCount = agents.managedAgents.filter((agent) =>
|
||||
isManagedAgentActive(agent),
|
||||
).length;
|
||||
// Show the resolved effective model, not just the structured `model` field:
|
||||
// most providers persist the model as a provider env var (e.g. DATABRICKS_MODEL)
|
||||
// or inherit a baked build default, leaving `globalConfig.model` null.
|
||||
const configuredGlobalModel = inheritedDefaults.model.value;
|
||||
|
||||
const hasSavedAgentDefaults = Boolean(
|
||||
globalConfig.preferred_runtime?.trim() ||
|
||||
globalConfig.provider?.trim() ||
|
||||
globalConfig.model?.trim() ||
|
||||
Object.values(globalConfig.env_vars).some(
|
||||
(value) => value.trim().length > 0,
|
||||
),
|
||||
);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable
|
||||
React.useEffect(() => {
|
||||
// Consume a snapshot import that was enqueued before navigation (e.g. from
|
||||
@@ -107,18 +113,23 @@ export function AgentsView() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div
|
||||
className={`mx-auto grid w-full max-w-6xl ${AGENT_CARD_GRID_COLUMNS_CLASS} justify-start gap-x-3 gap-y-8`}
|
||||
>
|
||||
<PageHeader
|
||||
className="col-[1/-1]"
|
||||
action={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => setIsAiDefaultsOpen(true)}
|
||||
data-testid="agent-defaults-button"
|
||||
ref={aiDefaultsTriggerRef}
|
||||
onClick={() => setIsAiDefaultsOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{configuredGlobalModel
|
||||
? `Default model: ${configuredGlobalModel}`
|
||||
<Settings2 />
|
||||
{hasSavedAgentDefaults
|
||||
? "Agent defaults"
|
||||
: "Set agent defaults"}
|
||||
</Button>
|
||||
{runningAgentCount > 0 ? (
|
||||
@@ -139,7 +150,7 @@ export function AgentsView() {
|
||||
description="Set up and manage your agents."
|
||||
title="Agents"
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="col-[1/-1] flex flex-col gap-8">
|
||||
<UnifiedAgentsSection
|
||||
defaultModel={inheritedDefaults.model.value}
|
||||
actionErrorMessage={agents.actionErrorMessage}
|
||||
@@ -167,7 +178,6 @@ export function AgentsView() {
|
||||
void agents.handleStartPersona(persona);
|
||||
}}
|
||||
// Persona props
|
||||
canChooseCatalog={personas.catalogPersonas.length > 0}
|
||||
personas={personas.libraryPersonas}
|
||||
personasError={
|
||||
personas.personasQuery.error instanceof Error
|
||||
@@ -186,10 +196,8 @@ export function AgentsView() {
|
||||
}
|
||||
isPersonasLoading={personas.personasQuery.isLoading}
|
||||
isPersonasPending={personas.isPending}
|
||||
onCreatePersona={() => {
|
||||
openUnifiedCreate();
|
||||
}}
|
||||
onChooseCatalog={personas.openCatalog}
|
||||
onCreatePersona={openUnifiedCreate}
|
||||
onDiscoverPersonas={personas.openCatalog}
|
||||
onDuplicatePersona={personas.openDuplicate}
|
||||
onEditPersona={personas.openEdit}
|
||||
onSharePersona={personas.openShare}
|
||||
@@ -341,8 +349,19 @@ export function AgentsView() {
|
||||
) : null}
|
||||
{personas.personaToShare ? (
|
||||
<PersonaShareDialog
|
||||
isCatalogVisible={
|
||||
personas.personaToShare.persona.isBuiltIn ||
|
||||
personas.sharedCatalogPersonaIdSet.has(
|
||||
personas.personaToShare.persona.id,
|
||||
)
|
||||
}
|
||||
isPending={personas.isPending}
|
||||
linkedAgentPubkey={personas.personaToShare.linkedAgentPubkey}
|
||||
onCatalogVisibilityChange={(visible) => {
|
||||
const shareTarget = personas.personaToShare;
|
||||
if (!shareTarget) return;
|
||||
personas.setPersonaCatalogVisibility(shareTarget.persona, visible);
|
||||
}}
|
||||
onExport={() => {
|
||||
const shareTarget = personas.personaToShare;
|
||||
if (!shareTarget) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
type CreateIdentityCardProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
ariaLabel: string;
|
||||
dataTestId: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const CreateIdentityCard = React.forwardRef<
|
||||
@@ -30,7 +30,9 @@ export const CreateIdentityCard = React.forwardRef<
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-2 text-center">
|
||||
<Plus className="h-7 w-7 transition-colors" />
|
||||
<span className="text-sm font-medium leading-5">{label}</span>
|
||||
{label ? (
|
||||
<span className="text-sm font-medium leading-5">{label}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/shared/ui/sheet";
|
||||
|
||||
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
|
||||
import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge";
|
||||
import {
|
||||
getPersonaCatalogDetailSelectionCopy,
|
||||
@@ -121,30 +122,11 @@ export function PersonaCatalogDetailsSheet({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Type
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium">Built-in agent</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Preferred model
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{persona.model ?? "Use app default"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-card/70 p-4 sm:col-span-2">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Preferred runtime
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{persona.runtime ?? "Use app default"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AgentDefinitionMetadata
|
||||
isBuiltIn={persona.isBuiltIn}
|
||||
model={persona.model}
|
||||
runtime={persona.runtime}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
|
||||
@@ -11,6 +11,8 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import agentOutlineUrl from "../assets/agent-outline.svg";
|
||||
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
|
||||
import { PersonaAddedBy } from "./PersonaAddedBy";
|
||||
import { personaCatalogCopy } from "./personaLibraryCopy";
|
||||
|
||||
@@ -28,7 +30,7 @@ type PersonaCatalogDialogProps = {
|
||||
};
|
||||
|
||||
const agentInstructionMarkdownClassName = [
|
||||
"mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
|
||||
"mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
|
||||
"[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
|
||||
"[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground",
|
||||
"[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground",
|
||||
@@ -154,6 +156,31 @@ function PersonaCatalogChooser({
|
||||
selectedPersona,
|
||||
selectedPersonaId,
|
||||
}: PersonaCatalogChooserProps) {
|
||||
if (!isLoading && personas.length === 0 && !error) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center bg-sidebar px-6 py-10 text-center"
|
||||
data-testid="persona-catalog-empty-state"
|
||||
>
|
||||
<div className="flex max-w-sm flex-col items-center">
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-32 w-32 dark:opacity-60"
|
||||
data-testid="persona-catalog-empty-agent-artwork"
|
||||
src={agentOutlineUrl}
|
||||
/>
|
||||
<p className="mt-4 text-sm font-semibold">
|
||||
{personaCatalogCopy.emptyCatalogTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{personaCatalogCopy.emptyCatalogDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-sidebar sm:flex-row">
|
||||
<div className="flex max-h-56 min-h-0 flex-col sm:max-h-none sm:w-56">
|
||||
@@ -200,9 +227,9 @@ function PersonaCatalogChooser({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 ml-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-tl-xl bg-background shadow-[-1px_0_0_0_hsl(var(--sidebar-border)/0.45)]">
|
||||
<div className="relative z-10 mb-3 ml-px mr-3 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-[-1px_0_0_0_hsl(var(--sidebar-border)/0.45)]">
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-5 pb-24 pt-5"
|
||||
className="min-h-0 min-w-0 max-w-full flex-1 overflow-x-hidden overflow-y-auto px-5 pb-24 pt-5"
|
||||
data-testid="persona-catalog-detail-pane"
|
||||
>
|
||||
{isLoading ? <PersonaCatalogDetailSkeleton /> : null}
|
||||
@@ -211,19 +238,6 @@ function PersonaCatalogChooser({
|
||||
<PersonaCatalogDetail persona={selectedPersona} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && personas.length === 0 && !error ? (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-border/70 px-6 text-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{personaCatalogCopy.emptyCatalogTitle}
|
||||
</p>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{personaCatalogCopy.emptyCatalogDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
@@ -263,7 +277,7 @@ function PersonaCatalogChooser({
|
||||
|
||||
function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="w-full min-w-0 max-w-full space-y-6 overflow-x-hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProfileAvatar
|
||||
avatarUrl={persona.avatarUrl}
|
||||
@@ -278,24 +292,13 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PersonaCatalogMetaGroup
|
||||
items={[
|
||||
{
|
||||
label: "Type",
|
||||
value: persona.isBuiltIn ? "Built-in agent" : "Custom agent",
|
||||
},
|
||||
{
|
||||
label: "Preferred model",
|
||||
value: persona.model ?? "Use app default",
|
||||
},
|
||||
{
|
||||
label: "Preferred runtime",
|
||||
value: persona.runtime ?? "Use app default",
|
||||
},
|
||||
]}
|
||||
<AgentDefinitionMetadata
|
||||
isBuiltIn={persona.isBuiltIn}
|
||||
model={persona.model}
|
||||
runtime={persona.runtime}
|
||||
/>
|
||||
|
||||
<div className="pt-3">
|
||||
<div className="min-w-0 max-w-full pt-3">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Agent instruction
|
||||
</p>
|
||||
@@ -309,36 +312,6 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PersonaCatalogMetaGroup({
|
||||
items,
|
||||
}: {
|
||||
items: { label: string; value: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70">
|
||||
<div className="grid sm:grid-cols-3">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative px-4 py-3",
|
||||
index > 0 &&
|
||||
"border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70",
|
||||
)}
|
||||
key={item.label}
|
||||
>
|
||||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-foreground">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonaCatalogListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookUser,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
|
||||
import {
|
||||
formatShareRecipientName,
|
||||
@@ -51,8 +53,10 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng";
|
||||
import { useSnapshotSendController } from "./useSnapshotSendController";
|
||||
|
||||
type PersonaShareDialogProps = {
|
||||
isCatalogVisible: boolean;
|
||||
isPending: boolean;
|
||||
linkedAgentPubkey: string | null;
|
||||
onCatalogVisibilityChange: (visible: boolean) => void;
|
||||
onExport: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
@@ -60,6 +64,7 @@ type PersonaShareDialogProps = {
|
||||
};
|
||||
|
||||
type SnapshotShareDialogProps = {
|
||||
beforeExport?: React.ReactNode;
|
||||
displayName: string;
|
||||
encodeSnapshot: (
|
||||
memoryLevel: SnapshotMemoryLevel,
|
||||
@@ -231,6 +236,7 @@ function ShareLevelControl({
|
||||
}
|
||||
|
||||
export function SnapshotShareDialog({
|
||||
beforeExport,
|
||||
displayName,
|
||||
encodeSnapshot,
|
||||
hasMemoryOptions,
|
||||
@@ -691,6 +697,7 @@ export function SnapshotShareDialog({
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{beforeExport}
|
||||
<button
|
||||
className="relative flex min-h-14 w-full items-center gap-3 rounded-2xl bg-background px-5 py-4 text-left text-sm font-medium shadow-2xl outline-hidden transition-colors hover:bg-muted focus-visible:bg-muted disabled:cursor-default disabled:opacity-100"
|
||||
data-testid={`${testIdPrefix}-export`}
|
||||
@@ -715,13 +722,16 @@ export function SnapshotShareDialog({
|
||||
}
|
||||
|
||||
export function PersonaShareDialog({
|
||||
isCatalogVisible,
|
||||
isPending,
|
||||
linkedAgentPubkey,
|
||||
onCatalogVisibilityChange,
|
||||
onExport,
|
||||
onOpenChange,
|
||||
open,
|
||||
persona,
|
||||
}: PersonaShareDialogProps) {
|
||||
const switchId = `persona-share-catalog-${persona.id}`;
|
||||
const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation();
|
||||
const encodeSnapshot = React.useCallback(
|
||||
async (memoryLevel: SnapshotMemoryLevel) =>
|
||||
@@ -742,6 +752,31 @@ export function PersonaShareDialog({
|
||||
|
||||
return (
|
||||
<SnapshotShareDialog
|
||||
beforeExport={
|
||||
persona.isBuiltIn ? null : (
|
||||
<section
|
||||
className="relative flex min-h-16 w-full items-center gap-3 rounded-2xl bg-background px-5 py-4 shadow-2xl"
|
||||
data-testid="persona-share-catalog"
|
||||
>
|
||||
<BookUser className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="text-sm font-medium" htmlFor={switchId}>
|
||||
Share to catalog
|
||||
</label>
|
||||
<p className="text-xs text-secondary-foreground/75">
|
||||
Let anyone on this relay find and use this agent.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isCatalogVisible}
|
||||
data-testid="persona-share-show-in-catalog"
|
||||
disabled={isPending}
|
||||
id={switchId}
|
||||
onCheckedChange={onCatalogVisibilityChange}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
displayName={persona.displayName}
|
||||
encodeSnapshot={encodeSnapshot}
|
||||
hasMemoryOptions={linkedAgentPubkey !== null}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Rocket,
|
||||
Share2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
|
||||
@@ -201,11 +200,7 @@ function NewTeamCard({
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<CreateIdentityCard
|
||||
ariaLabel="New team"
|
||||
dataTestId="new-team-card"
|
||||
label="New team"
|
||||
/>
|
||||
<CreateIdentityCard ariaLabel="New team" dataTestId="new-team-card" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
@@ -215,8 +210,7 @@ function NewTeamCard({
|
||||
Create team
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={isPending} onClick={onImport}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import team snapshot
|
||||
Import
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -40,7 +40,6 @@ type UnifiedAgentsSectionProps = {
|
||||
onOpenPersonaProfile: (persona: AgentPersona) => void;
|
||||
onStartAgent: (pubkey: string) => void;
|
||||
onStartPersona: (persona: AgentPersona) => void;
|
||||
canChooseCatalog: boolean;
|
||||
personas: AgentPersona[];
|
||||
personasError: Error | null;
|
||||
personaFeedbackErrorMessage: string | null;
|
||||
@@ -48,7 +47,7 @@ type UnifiedAgentsSectionProps = {
|
||||
isPersonasLoading: boolean;
|
||||
isPersonasPending: boolean;
|
||||
onCreatePersona: () => void;
|
||||
onChooseCatalog: () => void;
|
||||
onDiscoverPersonas: () => void;
|
||||
onDuplicatePersona: (persona: AgentPersona) => void;
|
||||
onEditPersona: (persona: AgentPersona) => void;
|
||||
onSharePersona: (
|
||||
@@ -61,7 +60,9 @@ type UnifiedAgentsSectionProps = {
|
||||
};
|
||||
|
||||
const AGENT_CARD_COLUMN_CLASS = "w-full";
|
||||
const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`;
|
||||
export const AGENT_CARD_GRID_COLUMNS_CLASS =
|
||||
"grid-cols-[repeat(auto-fill,minmax(220px,240px))]";
|
||||
const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`;
|
||||
|
||||
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
const {
|
||||
@@ -78,7 +79,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onOpenPersonaProfile,
|
||||
onStartAgent,
|
||||
onStartPersona,
|
||||
canChooseCatalog,
|
||||
personas,
|
||||
personasError,
|
||||
personaFeedbackErrorMessage,
|
||||
@@ -86,7 +86,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
isPersonasLoading,
|
||||
isPersonasPending,
|
||||
onCreatePersona,
|
||||
onChooseCatalog,
|
||||
onDiscoverPersonas,
|
||||
onDuplicatePersona,
|
||||
onEditPersona,
|
||||
onSharePersona,
|
||||
@@ -179,11 +179,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
);
|
||||
})}
|
||||
<NewAgentCard
|
||||
canChooseCatalog={canChooseCatalog}
|
||||
isPersonasPending={isPersonasPending}
|
||||
openFilePicker={openFilePicker}
|
||||
onChooseCatalog={onChooseCatalog}
|
||||
onCreatePersona={onCreatePersona}
|
||||
isPending={isPersonasPending}
|
||||
onCreate={onCreatePersona}
|
||||
onDiscover={onDiscoverPersonas}
|
||||
onImport={openFilePicker}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -419,50 +418,37 @@ function firstAvatarUrl(
|
||||
}
|
||||
|
||||
function NewAgentCard({
|
||||
canChooseCatalog,
|
||||
isPersonasPending,
|
||||
openFilePicker,
|
||||
onChooseCatalog,
|
||||
onCreatePersona,
|
||||
isPending,
|
||||
onCreate,
|
||||
onDiscover,
|
||||
onImport,
|
||||
}: {
|
||||
canChooseCatalog: boolean;
|
||||
isPersonasPending: boolean;
|
||||
openFilePicker: () => void;
|
||||
onChooseCatalog: () => void;
|
||||
onCreatePersona: () => void;
|
||||
isPending: boolean;
|
||||
onCreate: () => void;
|
||||
onDiscover: () => void;
|
||||
onImport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<CreateIdentityCard
|
||||
ariaLabel="New agent"
|
||||
dataTestId="new-agent-card"
|
||||
label="New agent"
|
||||
/>
|
||||
<CreateIdentityCard ariaLabel="New agent" dataTestId="new-agent-card" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={isPersonasPending}
|
||||
onClick={onCreatePersona}
|
||||
>
|
||||
Create from scratch
|
||||
<DropdownMenuItem disabled={isPending} onClick={onCreate}>
|
||||
Create agent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={isPending} onClick={onDiscover}>
|
||||
Discover agents
|
||||
</DropdownMenuItem>
|
||||
{canChooseCatalog ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isPersonasPending}
|
||||
onClick={onChooseCatalog}
|
||||
>
|
||||
Choose from catalog
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
data-testid="import-agent-snapshot-menu-item"
|
||||
onClick={openFilePicker}
|
||||
disabled={isPending}
|
||||
onClick={onImport}
|
||||
>
|
||||
Import agent snapshot
|
||||
Import
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -14,14 +14,13 @@ export const personaLibraryCopy = {
|
||||
|
||||
export const personaCatalogCopy = {
|
||||
title: "Agent Catalog",
|
||||
description: "Browse built-in agents and add them to My Agents.",
|
||||
description: "Browse agents shared to this relay.",
|
||||
dialogTitle: "Agent Catalog",
|
||||
dialogDescription: "Browse built-in agents and add them to My Agents.",
|
||||
dialogDescription: "Browse agents shared to this relay.",
|
||||
emptyTitle: "You're all set",
|
||||
emptyDescription: "Everything in Agent Catalog is already in My Agents.",
|
||||
emptyCatalogDescription:
|
||||
"New agents will show up here when the app ships more options.",
|
||||
emptyCatalogTitle: "No agents in the catalog yet",
|
||||
emptyCatalogDescription: "Shared agents will appear here.",
|
||||
emptyCatalogTitle: "No agents are being shared",
|
||||
detailsAction: "View details",
|
||||
selectAction: "Choose",
|
||||
deselectAction: "Deselect",
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type AgentSnapshotImportResult,
|
||||
} from "@/features/agents/hooks";
|
||||
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
|
||||
import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility";
|
||||
import {
|
||||
readSharedCatalogPersonaIds,
|
||||
writeSharedCatalogPersonaIds,
|
||||
} from "@/features/agents/lib/personaCatalogVisibility";
|
||||
import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment";
|
||||
import type {
|
||||
SnapshotFormat,
|
||||
@@ -88,6 +91,9 @@ export function usePersonaActions() {
|
||||
const [snapshotImportConfirmError, setSnapshotImportConfirmError] =
|
||||
React.useState<string | null>(null);
|
||||
const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false);
|
||||
const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState<
|
||||
string[]
|
||||
>(readSharedCatalogPersonaIds);
|
||||
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -101,9 +107,10 @@ export function usePersonaActions() {
|
||||
React.useState(false);
|
||||
|
||||
const personas = personasQuery.data ?? [];
|
||||
React.useEffect(() => {
|
||||
clearLegacyPersonaCatalogVisibility();
|
||||
}, []);
|
||||
const sharedCatalogPersonaIdSet = React.useMemo(
|
||||
() => new Set(sharedCatalogPersonaIds),
|
||||
[sharedCatalogPersonaIds],
|
||||
);
|
||||
const availableRuntimes = React.useMemo(
|
||||
() =>
|
||||
(acpRuntimesQuery.data ?? []).filter(
|
||||
@@ -113,8 +120,8 @@ export function usePersonaActions() {
|
||||
[acpRuntimesQuery.data],
|
||||
);
|
||||
const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo(
|
||||
() => getPersonaLibraryState(personas),
|
||||
[personas],
|
||||
() => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet),
|
||||
[personas, sharedCatalogPersonaIdSet],
|
||||
);
|
||||
|
||||
function clearFeedback(
|
||||
@@ -386,6 +393,27 @@ export function usePersonaActions() {
|
||||
);
|
||||
}
|
||||
|
||||
function setPersonaCatalogVisibility(
|
||||
persona: AgentPersona,
|
||||
visible: boolean,
|
||||
) {
|
||||
if (persona.isBuiltIn) return;
|
||||
|
||||
clearFeedback("library");
|
||||
setSharedCatalogPersonaIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (visible) {
|
||||
next.add(persona.id);
|
||||
} else {
|
||||
next.delete(persona.id);
|
||||
}
|
||||
|
||||
const ids = Array.from(next);
|
||||
writeSharedCatalogPersonaIds(ids);
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
|
||||
const isPending =
|
||||
isPersonaSubmitPending ||
|
||||
createPersonaMutation.isPending ||
|
||||
@@ -431,6 +459,8 @@ export function usePersonaActions() {
|
||||
personaToExportSnapshot,
|
||||
setPersonaToExportSnapshot,
|
||||
handleExportSnapshot,
|
||||
setPersonaCatalogVisibility,
|
||||
sharedCatalogPersonaIdSet,
|
||||
clearFeedback,
|
||||
snapshotImportState,
|
||||
snapshotImportResult,
|
||||
|
||||
@@ -12,6 +12,9 @@ import test from "node:test";
|
||||
function makePreview(overrides = {}) {
|
||||
return {
|
||||
displayName: "Test Agent",
|
||||
isBuiltIn: false,
|
||||
model: null,
|
||||
runtime: null,
|
||||
systemPrompt: "You are helpful.",
|
||||
avatarUrl: null,
|
||||
memoryLevel: "none",
|
||||
|
||||
@@ -172,6 +172,10 @@ export async function encodeAgentSnapshotForSend(
|
||||
/** Preview returned by `preview_agent_snapshot_import` before any write. */
|
||||
export type AgentSnapshotImportPreview = {
|
||||
displayName: string;
|
||||
/** Source classification shown in the preview; imports remain custom. */
|
||||
isBuiltIn: boolean;
|
||||
model: string | null;
|
||||
runtime: string | null;
|
||||
systemPrompt: string | null;
|
||||
/** Effective avatar: data URL if present, source URL fallback otherwise. */
|
||||
avatarUrl: string | null;
|
||||
|
||||
@@ -7282,9 +7282,7 @@ function ensureMockPersonaIsActive(personaId: string) {
|
||||
throw new Error(`agent ${personaId} not found`);
|
||||
}
|
||||
if (!persona.is_active) {
|
||||
throw new Error(
|
||||
`${persona.display_name} is not in My Agents. Choose it from Agent Catalog first.`,
|
||||
);
|
||||
throw new Error(`${persona.display_name} is not in My Agents.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9957,6 +9955,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
// Return a minimal preview — no writes performed.
|
||||
return {
|
||||
displayName: "Imported Agent",
|
||||
isBuiltIn: true,
|
||||
model: "claude-opus-4-5",
|
||||
runtime: "goose",
|
||||
systemPrompt: null,
|
||||
avatarUrl: null,
|
||||
memoryLevel: "none",
|
||||
|
||||
@@ -271,6 +271,13 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({
|
||||
|
||||
// Decoded display name must appear.
|
||||
await expect(dialog).toContainText("Imported Agent");
|
||||
const metadata = dialog.getByTestId("agent-definition-metadata");
|
||||
await expect(metadata).toContainText("Type");
|
||||
await expect(metadata).toContainText("Built-in agent");
|
||||
await expect(metadata).toContainText("Preferred model");
|
||||
await expect(metadata).toContainText("claude-opus-4-5");
|
||||
await expect(metadata).toContainText("Preferred runtime");
|
||||
await expect(metadata).toContainText("goose");
|
||||
});
|
||||
|
||||
// ── Confirm imports the agent ─────────────────────────────────────────────────
|
||||
|
||||
+357
-107
@@ -32,7 +32,9 @@ async function gotoApp(page: import("@playwright/test").Page) {
|
||||
|
||||
async function openPersonaCatalog(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByRole("menuitem", { name: "Choose from catalog" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { exact: true, name: "Discover agents" })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function getCatalogOrder(page: import("@playwright/test").Page) {
|
||||
@@ -50,12 +52,16 @@ async function selectCatalogPersona(
|
||||
await page.getByTestId(`persona-catalog-list-item-${personaId}`).click();
|
||||
}
|
||||
|
||||
async function useCatalogPersona(
|
||||
async function sharePersonaToCatalog(
|
||||
page: import("@playwright/test").Page,
|
||||
personaId: string,
|
||||
displayName: string,
|
||||
) {
|
||||
await page.getByLabel(`Open actions for ${displayName}`).click();
|
||||
await page.getByRole("menuitem", { name: "Share" }).click();
|
||||
await page.getByTestId("persona-share-show-in-catalog").click();
|
||||
await page
|
||||
.getByTestId(`persona-catalog-use-agent-target-${personaId}`)
|
||||
.getByTestId("persona-share-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -154,78 +160,72 @@ async function invokeTauriExpectError(
|
||||
);
|
||||
}
|
||||
|
||||
test("built-in personas are used from the catalog dialog", async ({ page }) => {
|
||||
test("catalog hides built-ins and shows the shared-agent empty state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 420 });
|
||||
await installMockBridge(page, {
|
||||
activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
await expect(page.getByTestId("agents-library-personas")).toBeVisible();
|
||||
await openPersonaCatalog(page);
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).toContainText(
|
||||
"Fizz",
|
||||
);
|
||||
for (const personaName of ["Fizz", "Honey", "Bumble"]) {
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).toContainText(
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
personaName,
|
||||
);
|
||||
}
|
||||
for (const retiredPersonaName of [
|
||||
"Product Strategist",
|
||||
"Implementation Partner",
|
||||
"QA Reviewer",
|
||||
"Work Coordinator",
|
||||
"Support Guide",
|
||||
"Experiment Designer",
|
||||
]) {
|
||||
|
||||
await openPersonaCatalog(page);
|
||||
for (const personaName of ["Fizz", "Honey", "Bumble"]) {
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText(
|
||||
retiredPersonaName,
|
||||
personaName,
|
||||
);
|
||||
}
|
||||
await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-dialog-scroll-area"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-dialog-scroll-area"),
|
||||
).toHaveCSS("overflow-y", "auto");
|
||||
const catalogScrollAreaMetrics = await page
|
||||
.getByTestId("persona-catalog-dialog-scroll-area")
|
||||
.evaluate((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
}));
|
||||
expect(catalogScrollAreaMetrics.clientHeight).toBeGreaterThan(0);
|
||||
expect(catalogScrollAreaMetrics.scrollHeight).toBeGreaterThanOrEqual(
|
||||
catalogScrollAreaMetrics.clientHeight,
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible();
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText(
|
||||
"Done",
|
||||
);
|
||||
await expect(page.getByRole("tooltip")).toHaveCount(0);
|
||||
const initialCatalogOrder = await getCatalogOrder(page);
|
||||
|
||||
await selectCatalogPersona(page, "builtin:fizz");
|
||||
await useCatalogPersona(page, "builtin:fizz");
|
||||
const emptyState = page.getByTestId("persona-catalog-empty-state");
|
||||
await expect(emptyState).toContainText("No agents are being shared");
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Selected Fizz for My Agents." }),
|
||||
emptyState.getByTestId("persona-catalog-empty-agent-artwork"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid^="persona-catalog-list-item-"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-use-agent-target"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
"Fizz",
|
||||
await page
|
||||
.getByTestId("persona-catalog-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
await page.getByLabel("Open actions for Fizz").click();
|
||||
await page.getByRole("menuitem", { name: "Share" }).click();
|
||||
await expect(page.getByTestId("persona-share-catalog")).toHaveCount(0);
|
||||
await expect(page.getByTestId("persona-share-show-in-catalog")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"),
|
||||
).toHaveText("Added to My Agents");
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText(
|
||||
"Delete",
|
||||
});
|
||||
|
||||
test("catalog empty state remains available after reopening", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await openPersonaCatalog(page);
|
||||
await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId("persona-catalog-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible();
|
||||
await openPersonaCatalog(page);
|
||||
await expect(page.getByTestId("persona-catalog-empty-state")).toContainText(
|
||||
"No agents are being shared",
|
||||
);
|
||||
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
|
||||
});
|
||||
|
||||
test("built-in persona edits persist", async ({ page }) => {
|
||||
@@ -269,7 +269,9 @@ test("agent avatar emoji picker scrolls inside its popover", async ({
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByRole("menuitem", { name: "Create from scratch" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { exact: true, name: "Create agent" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("persona-dialog")).toBeVisible();
|
||||
await page.getByLabel("Add avatar").click();
|
||||
@@ -306,70 +308,239 @@ test("agent avatar emoji picker scrolls inside its popover", async ({
|
||||
.toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
test("agent catalog can reopen from the populated library header", async ({
|
||||
test("the new agent card offers create, discover, and import", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"],
|
||||
personas: [
|
||||
{
|
||||
id: "custom:code-reviewer",
|
||||
displayName: "Code Reviewer",
|
||||
systemPrompt: "Review code changes.",
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await openPersonaCatalog(page);
|
||||
|
||||
await selectCatalogPersona(page, "builtin:fizz");
|
||||
await useCatalogPersona(page, "builtin:fizz");
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
"Fizz",
|
||||
const newAgentCard = page.getByTestId("new-agent-card");
|
||||
await expect(newAgentCard).toHaveText("");
|
||||
await expect(newAgentCard.locator(".lucide-plus")).toBeVisible();
|
||||
|
||||
const personaCards = page.locator('[data-testid^="persona-agent-row-"]');
|
||||
await expect(personaCards.first()).toBeVisible();
|
||||
const headerBox = await page
|
||||
.getByRole("heading", { level: 1, name: "Agents" })
|
||||
.locator("../..")
|
||||
.boundingBox();
|
||||
const cardBoxes = await personaCards.evaluateAll((cards) =>
|
||||
cards.map((card) => {
|
||||
const box = card.getBoundingClientRect();
|
||||
return { right: box.right, top: box.top };
|
||||
}),
|
||||
);
|
||||
const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top));
|
||||
const rightmostFirstRowCard = Math.max(
|
||||
...cardBoxes
|
||||
.filter(({ top }) => Math.abs(top - firstRowTop) < 1)
|
||||
.map(({ right }) => right),
|
||||
);
|
||||
expect(headerBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
(headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard,
|
||||
),
|
||||
).toBeLessThan(1);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await openPersonaCatalog(page);
|
||||
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
|
||||
await selectCatalogPersona(page, "builtin:fizz");
|
||||
await newAgentCard.click();
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"),
|
||||
).toBeDisabled();
|
||||
page.getByRole("menuitem", { exact: true, name: "Create agent" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { exact: true, name: "Discover agents" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { exact: true, name: "Import" }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("menuitem", { exact: true, name: "Discover agents" })
|
||||
.click();
|
||||
await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
|
||||
await page
|
||||
.getByTestId("persona-catalog-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
await newAgentCard.click();
|
||||
await page
|
||||
.getByRole("menuitem", { exact: true, name: "Create agent" })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByTestId("persona-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByTestId("import-agent-snapshot-dialog-action"),
|
||||
).toHaveCount(0);
|
||||
await expect(dialog).not.toContainText("Enter a name for this agent.");
|
||||
|
||||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await newAgentCard.click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("menuitem", { exact: true, name: "Import" }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles({
|
||||
buffer: Buffer.from("{}"),
|
||||
mimeType: "application/json",
|
||||
name: "imported.agent.json",
|
||||
});
|
||||
await expect(page.getByTestId("agent-snapshot-import-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the new team card offers create and import", async ({ page }) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
const newTeamCard = page.getByTestId("new-team-card");
|
||||
await expect(newTeamCard).toHaveText("");
|
||||
await expect(newTeamCard.locator(".lucide-plus")).toBeVisible();
|
||||
|
||||
await newTeamCard.click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { exact: true, name: "Create team" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { exact: true, name: "Import" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("agent defaults stays in the header without an actions menu", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
acpRuntimesCatalog: [
|
||||
{
|
||||
auth_status: { status: "logged_in" },
|
||||
availability: "available",
|
||||
avatar_url: "",
|
||||
binary_path: "/usr/local/bin/codex",
|
||||
can_auto_install: false,
|
||||
command: "codex",
|
||||
default_args: [],
|
||||
id: "codex",
|
||||
install_hint: "",
|
||||
install_instructions_url: "https://example.com",
|
||||
label: "Codex",
|
||||
login_hint: null,
|
||||
mcp_command: null,
|
||||
node_required: false,
|
||||
underlying_cli_path: null,
|
||||
},
|
||||
],
|
||||
globalAgentConfig: {
|
||||
env_vars: {},
|
||||
model: "gpt-5.5[high]",
|
||||
preferred_runtime: "codex",
|
||||
provider: null,
|
||||
},
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
await expect(page.getByTestId("agent-header-actions-button")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Import agent" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const defaultsButton = page.getByTestId("agent-defaults-button");
|
||||
await expect(defaultsButton).toHaveText("Agent defaults");
|
||||
await defaultsButton.click();
|
||||
const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog");
|
||||
await expect(defaultsDialog).toBeVisible();
|
||||
await expect(
|
||||
defaultsDialog.getByTestId("global-agent-default-harness"),
|
||||
).toHaveAttribute("data-value", "codex");
|
||||
await expect(
|
||||
defaultsDialog.getByTestId("global-agent-default-harness"),
|
||||
).toContainText("Codex");
|
||||
await expect(
|
||||
defaultsDialog.getByTestId("global-agent-model"),
|
||||
).toHaveAttribute("data-value", "gpt-5.5[high]");
|
||||
await expect(defaultsDialog.getByTestId("global-agent-model")).toContainText(
|
||||
"gpt-5.5[high]",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(defaultsDialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("unconfigured agent defaults use the setup label", async ({ page }) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
await expect(page.getByTestId("agent-defaults-button")).toHaveText(
|
||||
"Set agent defaults",
|
||||
);
|
||||
});
|
||||
|
||||
test("agent catalog chooser order stays stable when selection changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: "custom:builder",
|
||||
displayName: "Builder",
|
||||
systemPrompt: "Build the requested change.",
|
||||
},
|
||||
{
|
||||
id: "custom:reviewer",
|
||||
displayName: "Reviewer",
|
||||
systemPrompt: "Review the requested change.",
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await sharePersonaToCatalog(page, "Builder");
|
||||
await sharePersonaToCatalog(page, "Reviewer");
|
||||
await openPersonaCatalog(page);
|
||||
|
||||
const before = await getCatalogOrder(page);
|
||||
|
||||
await selectCatalogPersona(page, "builtin:fizz");
|
||||
await useCatalogPersona(page, "builtin:fizz");
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Selected Fizz for My Agents." }),
|
||||
).toBeVisible();
|
||||
|
||||
await selectCatalogPersona(page, "custom:reviewer");
|
||||
expect(await getCatalogOrder(page)).toEqual(before);
|
||||
});
|
||||
|
||||
test("catalog detail pane shows the full persona details", async ({ page }) => {
|
||||
const personaId = "custom:researcher";
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: personaId,
|
||||
displayName: "Researcher",
|
||||
systemPrompt: "Research the question and cite the evidence.",
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await sharePersonaToCatalog(page, "Researcher");
|
||||
await openPersonaCatalog(page);
|
||||
|
||||
await selectCatalogPersona(page, "builtin:fizz");
|
||||
await selectCatalogPersona(page, personaId);
|
||||
const useAgentTarget = page.getByTestId(
|
||||
"persona-catalog-use-agent-target-builtin:fizz",
|
||||
`persona-catalog-use-agent-target-${personaId}`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
|
||||
"Fizz",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("persona-catalog-detail-pane"),
|
||||
).not.toContainText("Added by You");
|
||||
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
|
||||
"You are Fizz.",
|
||||
"Researcher",
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
|
||||
"Built-in agent",
|
||||
"Added by You",
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
|
||||
"Research the question and cite the evidence.",
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
|
||||
"Custom agent",
|
||||
);
|
||||
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
|
||||
"Preferred model",
|
||||
@@ -382,14 +553,10 @@ test("catalog detail pane shows the full persona details", async ({ page }) => {
|
||||
);
|
||||
await expect(useAgentTarget).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Add Fizz from Agent Catalog",
|
||||
);
|
||||
await expect(useAgentTarget).toHaveText("Add agent");
|
||||
|
||||
await useAgentTarget.click();
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
"Fizz",
|
||||
"Researcher is already in My Agents",
|
||||
);
|
||||
await expect(useAgentTarget).toHaveText("Added to My Agents");
|
||||
await expect(useAgentTarget).toBeDisabled();
|
||||
});
|
||||
|
||||
type AgentShareCommand = { command: string; payload: unknown };
|
||||
@@ -667,18 +834,25 @@ test("custom personas share with people and keep export separate", async ({
|
||||
).toHaveCount(0);
|
||||
await expect(shareDialog.getByText("Memories")).toHaveCount(0);
|
||||
await expect(shareDialog.getByText("File format")).toHaveCount(0);
|
||||
await expect(page.getByText("Show in my catalog")).toHaveCount(0);
|
||||
const shareMainCard = page.getByTestId("persona-share-main-card");
|
||||
const exportAgentRow = page.getByTestId("persona-share-export");
|
||||
const catalogSection = page.getByTestId("persona-share-catalog");
|
||||
const catalogToggle = page.getByTestId("persona-share-show-in-catalog");
|
||||
await expect(exportAgentRow).toHaveText("Export agent");
|
||||
await expect(catalogSection).toContainText("Share to catalog");
|
||||
await expect(catalogSection).toContainText(
|
||||
"Let anyone on this relay find and use this agent.",
|
||||
);
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "false");
|
||||
await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
const shareMainCardBox = await shareMainCard.boundingBox();
|
||||
const exportAgentRowBox = await exportAgentRow.boundingBox();
|
||||
const catalogSectionBox = await catalogSection.boundingBox();
|
||||
const shareCardGap =
|
||||
(exportAgentRowBox?.y ?? 0) -
|
||||
(catalogSectionBox?.y ?? 0) -
|
||||
((shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0));
|
||||
expect(shareCardGap).toBeGreaterThanOrEqual(12);
|
||||
expect(shareCardGap).toBeLessThan(16);
|
||||
@@ -700,6 +874,9 @@ test("custom personas share with people and keep export separate", async ({
|
||||
expect(exportAgentRowShadow).toBe(shareMainCardShadow);
|
||||
expect(exportAgentRowShadow).not.toBe("none");
|
||||
await expect(exportAgentRow).toHaveCSS("position", "relative");
|
||||
expect(exportAgentRowBox?.y ?? 0).toBeGreaterThanOrEqual(
|
||||
(catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 12,
|
||||
);
|
||||
await expect(page.getByTestId("agent-snapshot-export-dialog")).toHaveCount(0);
|
||||
|
||||
await exportAgentRow.click();
|
||||
@@ -1034,6 +1211,83 @@ test("custom personas share with people and keep export separate", async ({
|
||||
await expect(shareDialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("custom personas can be shared to the relay catalog", async ({ page }) => {
|
||||
const personaId = "custom:catalog-analyst";
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: personaId,
|
||||
displayName: "Catalog Analyst",
|
||||
systemPrompt: `## Design System And Styling
|
||||
|
||||
- For design-system changes, check the local guidance in \`DESIGN.md\`, \`docs/color-token-mapping.md\`, \`src/shared/ui/AGENTS.md\`, and \`src/features/design-system/AGENTS.md\` before judging the implementation.
|
||||
- Check every changed visual surface in both light and dark mode. Missing dark-mode support is a review issue, not visual polish.
|
||||
- Review the selected changes and explain whether \`git diff --cached --name-only --some-extremely-long-inline-option-that-must-wrap\` stays inside the catalog detail column.
|
||||
|
||||
\`\`\`text
|
||||
This deliberately long fenced-code example must not establish the minimum width of the full custom-agent instruction document or force earlier prose outside the catalog detail pane.
|
||||
\`\`\``,
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = "24px";
|
||||
});
|
||||
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await openPersonaCatalog(page);
|
||||
await expect(
|
||||
page.getByTestId(`persona-catalog-list-item-${personaId}`),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByLabel("Open actions for Catalog Analyst").click();
|
||||
await page.getByRole("menuitem", { name: "Share" }).click();
|
||||
const catalogToggle = page.getByTestId("persona-share-show-in-catalog");
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "false");
|
||||
await catalogToggle.click();
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
|
||||
await page
|
||||
.getByTestId("persona-share-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
|
||||
await openPersonaCatalog(page);
|
||||
await expect(
|
||||
page.getByTestId(`persona-catalog-list-item-${personaId}`),
|
||||
).toContainText("Catalog Analyst");
|
||||
await selectCatalogPersona(page, personaId);
|
||||
const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane");
|
||||
await expect(catalogDetailPane).toContainText("Design System And Styling");
|
||||
expect(
|
||||
await catalogDetailPane.evaluate(
|
||||
(element) => element.scrollWidth - element.clientWidth,
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
const catalogInstruction = catalogDetailPane.locator(".message-markdown");
|
||||
expect(
|
||||
await catalogInstruction.evaluate(
|
||||
(element) => element.scrollWidth - element.clientWidth,
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByLabel("Open actions for Catalog Analyst").click();
|
||||
await page.getByRole("menuitem", { name: "Share" }).click();
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
|
||||
await catalogToggle.click();
|
||||
await page
|
||||
.getByTestId("persona-share-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
|
||||
await openPersonaCatalog(page);
|
||||
await expect(
|
||||
page.getByTestId(`persona-catalog-list-item-${personaId}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("share access controls include the selected memories", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1571,19 +1825,16 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBe(
|
||||
"Honey is not in My Agents. Choose it from Agent Catalog first.",
|
||||
);
|
||||
expect(error).toBe("Honey is not in My Agents.");
|
||||
});
|
||||
|
||||
test("built-in removal failures show up from My Agents", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
activePersonaIds: ["builtin:honey"],
|
||||
});
|
||||
await gotoApp(page);
|
||||
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await openPersonaCatalog(page);
|
||||
await selectCatalogPersona(page, "builtin:honey");
|
||||
await useCatalogPersona(page, "builtin:honey");
|
||||
|
||||
await invokeTauri(page, "create_team", {
|
||||
input: {
|
||||
name: "Honeys",
|
||||
@@ -1591,7 +1842,6 @@ test("built-in removal failures show up from My Agents", async ({ page }) => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByLabel("Open actions for Honey").click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user