feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
@ -879,9 +879,14 @@ pub async fn update_project_settings(
|
||||
.map(|value| value.map(domain::ThemeColor::new).transpose())
|
||||
.transpose()
|
||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||
let icon = request
|
||||
.icon
|
||||
.map(|value| value.map(domain::ProjectIconRef::try_from).transpose())
|
||||
.transpose()
|
||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||
state
|
||||
.update_project_settings
|
||||
.execute(&project, theme_color)
|
||||
.execute(&project, theme_color, icon)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
use crate::AppError;
|
||||
use domain::{
|
||||
IdeaUserSettings, Project, ProjectSettings, ProjectSettingsStore, ThemeColor, UserSettingsStore,
|
||||
IdeaUserSettings, Project, ProjectIconRef, ProjectSettings, ProjectSettingsStore, ThemeColor,
|
||||
UserSettingsStore,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -64,18 +65,109 @@ impl UpdateProjectSettings {
|
||||
}
|
||||
/// Applies the supplied fields and returns the accepted project settings.
|
||||
///
|
||||
/// An absent outer option leaves the colour unchanged; an explicit inner
|
||||
/// `None` restores the product fallback.
|
||||
/// An absent outer option leaves a field unchanged; an explicit inner
|
||||
/// `None` restores that field's product fallback.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: &Project,
|
||||
theme_color: Option<Option<ThemeColor>>,
|
||||
icon: Option<Option<ProjectIconRef>>,
|
||||
) -> Result<ProjectSettings, AppError> {
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
let mut changed = false;
|
||||
if let Some(theme_color) = theme_color {
|
||||
settings.theme_color = theme_color;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(icon) = icon {
|
||||
settings.icon = icon;
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{ProjectIconSet, ProjectPath, RemoteRef, StoreError};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MemoryProjectSettings(Mutex<ProjectSettings>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectSettingsStore for MemoryProjectSettings {
|
||||
async fn load(&self, _root: &ProjectPath) -> Result<ProjectSettings, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
settings: &ProjectSettings,
|
||||
) -> Result<(), StoreError> {
|
||||
*self.0.lock().unwrap() = settings.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
domain::ProjectId::new_random(),
|
||||
"Settings",
|
||||
ProjectPath::new("/settings-project").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_settings_patch_updates_fields_independently_and_supports_clear() {
|
||||
let store = Arc::new(MemoryProjectSettings(Mutex::new(ProjectSettings {
|
||||
theme_color: Some(ThemeColor::new("#123456").unwrap()),
|
||||
icon: Some(ProjectIconRef::new(ProjectIconSet::Devicon, "rust").unwrap()),
|
||||
})));
|
||||
let update = UpdateProjectSettings::new(store);
|
||||
let project = project();
|
||||
|
||||
let icon = ProjectIconRef::new(ProjectIconSet::Lucide, "star").unwrap();
|
||||
let after_icon = update
|
||||
.execute(&project, None, Some(Some(icon.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after_icon.theme_color.as_ref().map(ThemeColor::as_str),
|
||||
Some("#123456")
|
||||
);
|
||||
assert_eq!(after_icon.icon, Some(icon));
|
||||
|
||||
let after_theme = update
|
||||
.execute(
|
||||
&project,
|
||||
Some(Some(ThemeColor::new("#ABCDEF").unwrap())),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after_theme.theme_color.as_ref().map(ThemeColor::as_str),
|
||||
Some("#ABCDEF")
|
||||
);
|
||||
assert_eq!(
|
||||
after_theme.icon.as_ref().map(ProjectIconRef::name),
|
||||
Some("star")
|
||||
);
|
||||
|
||||
let cleared = update.execute(&project, None, Some(None)).await.unwrap();
|
||||
assert!(cleared.icon.is_none());
|
||||
assert_eq!(
|
||||
cleared.theme_color.as_ref().map(ThemeColor::as_str),
|
||||
Some("#ABCDEF")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -899,6 +899,7 @@ fn launch_fixture_with_preference(interface: DefaultAgentTerminalInterface) -> L
|
||||
.expect("fixture owns its launcher")
|
||||
.with_user_settings(Arc::new(FixedUserSettings(IdeaUserSettings {
|
||||
default_agent_terminal_interface: Some(interface),
|
||||
language: None,
|
||||
})));
|
||||
fixture.launch = Arc::new(launch);
|
||||
fixture
|
||||
@ -949,6 +950,7 @@ async fn changing_global_preference_does_not_replace_an_existing_terminal() {
|
||||
settings
|
||||
.save(&IdeaUserSettings {
|
||||
default_agent_terminal_interface: Some(DefaultAgentTerminalInterface::IdeaCli),
|
||||
language: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -3266,12 +3266,16 @@ pub struct UserSettingsDto {
|
||||
/// Default interface for newly opened agent terminals.
|
||||
#[serde(default)]
|
||||
pub default_agent_terminal_interface: Option<domain::DefaultAgentTerminalInterface>,
|
||||
/// Preferred interface language. Absence preserves the product default.
|
||||
#[serde(default)]
|
||||
pub language: Option<domain::IdeaLanguage>,
|
||||
}
|
||||
|
||||
impl From<domain::IdeaUserSettings> for UserSettingsDto {
|
||||
fn from(value: domain::IdeaUserSettings) -> Self {
|
||||
Self {
|
||||
default_agent_terminal_interface: value.default_agent_terminal_interface,
|
||||
language: value.language,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3280,10 +3284,38 @@ impl From<UserSettingsDto> for domain::IdeaUserSettings {
|
||||
fn from(value: UserSettingsDto) -> Self {
|
||||
Self {
|
||||
default_agent_terminal_interface: value.default_agent_terminal_interface,
|
||||
language: value.language,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated project icon wire reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectIconRefDto {
|
||||
/// Known icon catalogue.
|
||||
pub set: domain::ProjectIconSet,
|
||||
/// Stable icon name within the catalogue.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<domain::ProjectIconRef> for ProjectIconRefDto {
|
||||
fn from(value: domain::ProjectIconRef) -> Self {
|
||||
Self {
|
||||
set: value.set(),
|
||||
name: value.name().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ProjectIconRefDto> for domain::ProjectIconRef {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: ProjectIconRefDto) -> Result<Self, Self::Error> {
|
||||
Self::new(value.set, value.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Project-owned settings wire contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -3291,12 +3323,16 @@ pub struct ProjectSettingsDto {
|
||||
/// Optional validated project theme colour.
|
||||
#[serde(default)]
|
||||
pub theme_color: Option<String>,
|
||||
/// Optional validated project icon.
|
||||
#[serde(default)]
|
||||
pub icon: Option<ProjectIconRefDto>,
|
||||
}
|
||||
|
||||
impl From<domain::ProjectSettings> for ProjectSettingsDto {
|
||||
fn from(value: domain::ProjectSettings) -> Self {
|
||||
Self {
|
||||
theme_color: value.theme_color.map(|color| color.as_str().to_owned()),
|
||||
icon: value.icon.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3308,6 +3344,8 @@ pub struct UpdateProjectSettingsRequestDto {
|
||||
pub project_id: String,
|
||||
/// Missing leaves the colour unchanged; null restores the product default.
|
||||
pub theme_color: Option<Option<String>>,
|
||||
/// Missing leaves the icon unchanged; null restores the product default.
|
||||
pub icon: Option<Option<ProjectIconRefDto>>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UpdateProjectSettingsRequestDto {
|
||||
@ -3323,9 +3361,12 @@ impl<'de> Deserialize<'de> for UpdateProjectSettingsRequestDto {
|
||||
take_required_string(object, "projectId").map_err(serde::de::Error::custom)?;
|
||||
let theme_color = take_optional_value::<String>(object, "themeColor")
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
let icon = take_optional_value::<ProjectIconRefDto>(object, "icon")
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok(Self {
|
||||
project_id,
|
||||
theme_color,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -6257,6 +6298,52 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn settings_dtos_are_additive_and_validate_language_and_icon_contracts() {
|
||||
let legacy_user: UserSettingsDto = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(legacy_user.language, None);
|
||||
let english: UserSettingsDto = serde_json::from_value(json!({ "language": "en" })).unwrap();
|
||||
assert_eq!(english.language, Some(domain::IdeaLanguage::En));
|
||||
assert!(serde_json::from_value::<UserSettingsDto>(json!({ "language": "de" })).is_err());
|
||||
|
||||
let project_id = Uuid::from_u128(325).to_string();
|
||||
let absent: UpdateProjectSettingsRequestDto = serde_json::from_value(json!({
|
||||
"projectId": project_id
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(absent.theme_color.is_none());
|
||||
assert!(absent.icon.is_none());
|
||||
|
||||
let clear: UpdateProjectSettingsRequestDto = serde_json::from_value(json!({
|
||||
"projectId": project_id,
|
||||
"icon": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(clear.icon, Some(None));
|
||||
|
||||
let set: UpdateProjectSettingsRequestDto = serde_json::from_value(json!({
|
||||
"projectId": project_id,
|
||||
"icon": { "set": "simple-icons", "name": "github" }
|
||||
}))
|
||||
.unwrap();
|
||||
let icon: domain::ProjectIconRef = set.icon.unwrap().unwrap().try_into().unwrap();
|
||||
assert_eq!(icon.set(), domain::ProjectIconSet::SimpleIcons);
|
||||
assert_eq!(icon.name(), "github");
|
||||
|
||||
let invalid_name = ProjectIconRefDto {
|
||||
set: domain::ProjectIconSet::Lucide,
|
||||
name: "two words".to_owned(),
|
||||
};
|
||||
assert!(domain::ProjectIconRef::try_from(invalid_name).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateProjectSettingsRequestDto>(json!({
|
||||
"projectId": project_id,
|
||||
"icon": { "set": "font-awesome", "name": "star" }
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_requests_accept_optional_project_scope() {
|
||||
let project_id = Uuid::from_u128(321).to_string();
|
||||
|
||||
@ -267,7 +267,10 @@ pub use scheduled_task::{
|
||||
Weekday, MIN_SCHEDULE_INTERVAL_MINUTES,
|
||||
};
|
||||
|
||||
pub use settings::{DefaultAgentTerminalInterface, IdeaUserSettings, ProjectSettings, ThemeColor};
|
||||
pub use settings::{
|
||||
DefaultAgentTerminalInterface, IdeaLanguage, IdeaUserSettings, ProjectIconRef, ProjectIconSet,
|
||||
ProjectSettings, ThemeColor, MAX_PROJECT_ICON_NAME_LEN,
|
||||
};
|
||||
|
||||
pub use orchestrator::{
|
||||
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
|
||||
|
||||
@ -12,6 +12,16 @@ pub enum DefaultAgentTerminalInterface {
|
||||
IdeaCli,
|
||||
}
|
||||
|
||||
/// User-selected IdeA interface language.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IdeaLanguage {
|
||||
/// French interface.
|
||||
Fr,
|
||||
/// English interface.
|
||||
En,
|
||||
}
|
||||
|
||||
/// Global, machine-local IdeA preferences.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -19,6 +29,83 @@ pub struct IdeaUserSettings {
|
||||
/// Default for newly launched human-facing agent terminals.
|
||||
#[serde(default)]
|
||||
pub default_agent_terminal_interface: Option<DefaultAgentTerminalInterface>,
|
||||
/// Preferred interface language. Absence preserves the product default.
|
||||
#[serde(default)]
|
||||
pub language: Option<IdeaLanguage>,
|
||||
}
|
||||
|
||||
/// Supported icon catalogue for project visual identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProjectIconSet {
|
||||
/// Devicon language and platform catalogue.
|
||||
Devicon,
|
||||
/// Simple Icons brand catalogue.
|
||||
SimpleIcons,
|
||||
/// Lucide general-purpose icon catalogue.
|
||||
Lucide,
|
||||
/// IdeA-owned built-in icons.
|
||||
Idea,
|
||||
}
|
||||
|
||||
/// Maximum byte length of a stable project icon name.
|
||||
pub const MAX_PROJECT_ICON_NAME_LEN: usize = 64;
|
||||
|
||||
/// Validated reference to one icon in a known catalogue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", try_from = "UncheckedProjectIconRef")]
|
||||
pub struct ProjectIconRef {
|
||||
set: ProjectIconSet,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UncheckedProjectIconRef {
|
||||
set: ProjectIconSet,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl ProjectIconRef {
|
||||
/// Builds a validated stable icon reference.
|
||||
pub fn new(set: ProjectIconSet, name: impl Into<String>) -> Result<Self, String> {
|
||||
let name = name.into();
|
||||
let bytes = name.as_bytes();
|
||||
let valid_name = !name.is_empty()
|
||||
&& name.len() <= MAX_PROJECT_ICON_NAME_LEN
|
||||
&& bytes.first().is_some_and(u8::is_ascii_alphanumeric)
|
||||
&& bytes.last().is_some_and(u8::is_ascii_alphanumeric)
|
||||
&& bytes
|
||||
.iter()
|
||||
.copied()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
|
||||
if !valid_name {
|
||||
return Err(format!(
|
||||
"project icon name must be 1..={MAX_PROJECT_ICON_NAME_LEN} ASCII letters, digits, '-', '_' or '.', starting and ending with a letter or digit"
|
||||
));
|
||||
}
|
||||
Ok(Self { set, name })
|
||||
}
|
||||
|
||||
/// Returns the owning icon catalogue.
|
||||
#[must_use]
|
||||
pub const fn set(&self) -> ProjectIconSet {
|
||||
self.set
|
||||
}
|
||||
|
||||
/// Returns the stable catalogue icon name.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<UncheckedProjectIconRef> for ProjectIconRef {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: UncheckedProjectIconRef) -> Result<Self, Self::Error> {
|
||||
Self::new(value.set, value.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict project theme colour (`#RRGGBB`).
|
||||
@ -66,4 +153,7 @@ pub struct ProjectSettings {
|
||||
/// Optional visual theme colour; absence keeps the product fallback.
|
||||
#[serde(default)]
|
||||
pub theme_color: Option<ThemeColor>,
|
||||
/// Optional validated icon used as the project's visual identity.
|
||||
#[serde(default)]
|
||||
pub icon: Option<ProjectIconRef>,
|
||||
}
|
||||
|
||||
93
crates/domain/tests/settings.rs
Normal file
93
crates/domain/tests/settings.rs
Normal file
@ -0,0 +1,93 @@
|
||||
use domain::{
|
||||
DefaultAgentTerminalInterface, IdeaLanguage, IdeaUserSettings, ProjectIconRef, ProjectIconSet,
|
||||
ProjectSettings, ThemeColor, MAX_PROJECT_ICON_NAME_LEN,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_documents_keep_additive_defaults() {
|
||||
let user: IdeaUserSettings =
|
||||
serde_json::from_str(r#"{"defaultAgentTerminalInterface":"ideaCli"}"#).unwrap();
|
||||
assert_eq!(
|
||||
user.default_agent_terminal_interface,
|
||||
Some(DefaultAgentTerminalInterface::IdeaCli)
|
||||
);
|
||||
assert_eq!(user.language, None);
|
||||
|
||||
let project: ProjectSettings = serde_json::from_str(r##"{"themeColor":"#123ABC"}"##).unwrap();
|
||||
assert_eq!(
|
||||
project.theme_color.as_ref().map(ThemeColor::as_str),
|
||||
Some("#123ABC")
|
||||
);
|
||||
assert_eq!(project.icon, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_roundtrips_as_stable_codes_and_rejects_unknown_values() {
|
||||
for (language, wire) in [(IdeaLanguage::Fr, "fr"), (IdeaLanguage::En, "en")] {
|
||||
let settings = IdeaUserSettings {
|
||||
default_agent_terminal_interface: None,
|
||||
language: Some(language),
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&json).unwrap()["language"],
|
||||
wire
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<IdeaUserSettings>(&json).unwrap(),
|
||||
settings
|
||||
);
|
||||
}
|
||||
|
||||
assert!(serde_json::from_str::<IdeaUserSettings>(r#"{"language":"de"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_icon_roundtrips_for_every_known_set() {
|
||||
for (set, wire) in [
|
||||
(ProjectIconSet::Devicon, "devicon"),
|
||||
(ProjectIconSet::SimpleIcons, "simple-icons"),
|
||||
(ProjectIconSet::Lucide, "lucide"),
|
||||
(ProjectIconSet::Idea, "idea"),
|
||||
] {
|
||||
let settings = ProjectSettings {
|
||||
theme_color: None,
|
||||
icon: Some(ProjectIconRef::new(set, "arrow-up.circle_2").unwrap()),
|
||||
};
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["icon"]["set"], wire);
|
||||
assert_eq!(value["icon"]["name"], "arrow-up.circle_2");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ProjectSettings>(value).unwrap(),
|
||||
settings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_icon_rejects_unknown_sets_and_unstable_or_oversized_names() {
|
||||
assert!(
|
||||
serde_json::from_str::<ProjectIconRef>(r#"{"set":"font-awesome","name":"star"}"#).is_err()
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
"",
|
||||
" leading",
|
||||
"two words",
|
||||
"étoile",
|
||||
"slash/name",
|
||||
".",
|
||||
"-star",
|
||||
"star-",
|
||||
] {
|
||||
assert!(
|
||||
ProjectIconRef::new(ProjectIconSet::Lucide, invalid).is_err(),
|
||||
"accepted {invalid:?}"
|
||||
);
|
||||
}
|
||||
assert!(ProjectIconRef::new(
|
||||
ProjectIconSet::Idea,
|
||||
"x".repeat(MAX_PROJECT_ICON_NAME_LEN + 1)
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
@ -2,8 +2,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
DefaultAgentTerminalInterface, FileSystem, IdeaUserSettings, ProjectPath, ProjectSettings,
|
||||
ProjectSettingsStore, ThemeColor, UserSettingsStore,
|
||||
DefaultAgentTerminalInterface, FileSystem, IdeaLanguage, IdeaUserSettings, ProjectIconRef,
|
||||
ProjectIconSet, ProjectPath, ProjectSettings, ProjectSettingsStore, ThemeColor,
|
||||
UserSettingsStore,
|
||||
};
|
||||
use infrastructure::{FsProjectSettingsStore, FsUserSettingsStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
@ -35,6 +36,7 @@ async fn global_user_preference_persists_outside_project() {
|
||||
assert_eq!(store.load().await.unwrap(), IdeaUserSettings::default());
|
||||
let settings = IdeaUserSettings {
|
||||
default_agent_terminal_interface: Some(DefaultAgentTerminalInterface::IdeaCli),
|
||||
language: Some(IdeaLanguage::En),
|
||||
};
|
||||
store.save(&settings).await.unwrap();
|
||||
assert_eq!(
|
||||
@ -59,12 +61,13 @@ async fn project_settings_missing_then_valid_round_trip() {
|
||||
assert!(!tmp.0.join(".ideai/project-settings.json").exists());
|
||||
let settings = ProjectSettings {
|
||||
theme_color: Some(ThemeColor::new("#12AbEF").unwrap()),
|
||||
icon: Some(ProjectIconRef::new(ProjectIconSet::Devicon, "cplusplus").unwrap()),
|
||||
};
|
||||
store.save(&tmp.root(), &settings).await.unwrap();
|
||||
assert_eq!(store.load(&tmp.root()).await.unwrap(), settings);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tmp.0.join(".ideai/project-settings.json")).unwrap(),
|
||||
"{\n \"themeColor\": \"#12AbEF\"\n}\n"
|
||||
"{\n \"themeColor\": \"#12AbEF\",\n \"icon\": {\n \"set\": \"devicon\",\n \"name\": \"cplusplus\"\n }\n}\n"
|
||||
);
|
||||
assert!(!tmp.0.join(".ideai/CONTEXT.md").exists());
|
||||
}
|
||||
@ -73,6 +76,7 @@ async fn project_settings_missing_then_valid_round_trip() {
|
||||
async fn project_settings_are_isolated_and_missing_defaults_are_not_persisted() {
|
||||
let first = TempDir::new();
|
||||
let second = TempDir::new();
|
||||
let third = TempDir::new();
|
||||
let store = FsProjectSettingsStore::new(fs());
|
||||
|
||||
store
|
||||
@ -80,16 +84,32 @@ async fn project_settings_are_isolated_and_missing_defaults_are_not_persisted()
|
||||
&first.root(),
|
||||
&ProjectSettings {
|
||||
theme_color: Some(ThemeColor::new("#AA3366").unwrap()),
|
||||
icon: Some(ProjectIconRef::new(ProjectIconSet::Devicon, "rust").unwrap()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(
|
||||
&second.root(),
|
||||
&ProjectSettings {
|
||||
theme_color: None,
|
||||
icon: Some(ProjectIconRef::new(ProjectIconSet::SimpleIcons, "docker").unwrap()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second_settings = store.load(&second.root()).await.unwrap();
|
||||
assert_eq!(second_settings.theme_color, None);
|
||||
assert_eq!(
|
||||
store.load(&second.root()).await.unwrap(),
|
||||
ProjectSettings::default()
|
||||
second_settings.icon.as_ref().map(ProjectIconRef::set),
|
||||
Some(ProjectIconSet::SimpleIcons)
|
||||
);
|
||||
assert_eq!(
|
||||
second_settings.icon.as_ref().map(ProjectIconRef::name),
|
||||
Some("docker")
|
||||
);
|
||||
assert!(!second.0.join(".ideai/project-settings.json").exists());
|
||||
assert_eq!(
|
||||
store
|
||||
.load(&first.root())
|
||||
@ -100,6 +120,14 @@ async fn project_settings_are_isolated_and_missing_defaults_are_not_persisted()
|
||||
.map(ThemeColor::as_str),
|
||||
Some("#AA3366")
|
||||
);
|
||||
let first_icon = store.load(&first.root()).await.unwrap().icon.unwrap();
|
||||
assert_eq!(first_icon.set(), ProjectIconSet::Devicon);
|
||||
assert_eq!(first_icon.name(), "rust");
|
||||
assert_eq!(
|
||||
store.load(&third.root()).await.unwrap(),
|
||||
ProjectSettings::default()
|
||||
);
|
||||
assert!(!third.0.join(".ideai/project-settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -3931,9 +3931,14 @@ async fn invoke_update_project_settings(
|
||||
.map(|value| value.map(domain::ThemeColor::new).transpose())
|
||||
.transpose()
|
||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||
let icon = request
|
||||
.icon
|
||||
.map(|value| value.map(domain::ProjectIconRef::try_from).transpose())
|
||||
.transpose()
|
||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||
let output: ProjectSettingsDto = state
|
||||
.update_project_settings
|
||||
.execute(&project, theme_color)
|
||||
.execute(&project, theme_color, icon)
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.into();
|
||||
@ -10630,16 +10635,18 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(initial_user["defaultAgentTerminalInterface"], Value::Null);
|
||||
assert_eq!(initial_user["language"], Value::Null);
|
||||
|
||||
let (status, saved_user) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"update_user_settings",
|
||||
json!({ "request": { "defaultAgentTerminalInterface": "ideaCli" } }),
|
||||
json!({ "request": { "defaultAgentTerminalInterface": "ideaCli", "language": "en" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved_user["defaultAgentTerminalInterface"], "ideaCli");
|
||||
assert_eq!(saved_user["language"], "en");
|
||||
|
||||
let (status, initial_project) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
@ -10650,16 +10657,25 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(initial_project["themeColor"], Value::Null);
|
||||
assert_eq!(initial_project["icon"], Value::Null);
|
||||
|
||||
let (status, saved_project) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"update_project_settings",
|
||||
json!({ "request": { "projectId": project_id, "themeColor": "#AA3366" } }),
|
||||
json!({
|
||||
"request": {
|
||||
"projectId": project_id,
|
||||
"themeColor": "#AA3366",
|
||||
"icon": { "set": "devicon", "name": "rust" }
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved_project["themeColor"], "#AA3366");
|
||||
assert_eq!(saved_project["icon"]["set"], "devicon");
|
||||
assert_eq!(saved_project["icon"]["name"], "rust");
|
||||
|
||||
let (status, unchanged_project) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
@ -10670,6 +10686,7 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(unchanged_project["themeColor"], "#AA3366");
|
||||
assert_eq!(unchanged_project["icon"]["name"], "rust");
|
||||
|
||||
let (status, reset_project) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
@ -10680,6 +10697,18 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(reset_project["themeColor"], Value::Null);
|
||||
assert_eq!(reset_project["icon"]["name"], "rust");
|
||||
|
||||
let (status, cleared_icon) = invoke_request_for_test(
|
||||
state,
|
||||
&cookie,
|
||||
"update_project_settings",
|
||||
json!({ "request": { "projectId": project_id, "icon": null } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(cleared_icon["themeColor"], Value::Null);
|
||||
assert_eq!(cleared_icon["icon"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -166,7 +166,7 @@ class HttpUserSettingsGateway implements UserSettingsGateway {
|
||||
return this.http.invoke<UserSettings>("read_user_settings");
|
||||
}
|
||||
|
||||
updateUserSettings(settings: UserSettings): Promise<UserSettings> {
|
||||
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings> {
|
||||
return this.http.invoke<UserSettings>("update_user_settings", { request: settings });
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,9 +141,9 @@ export class HttpProjectGateway implements ProjectGateway {
|
||||
readProjectSettings(projectId: string): Promise<ProjectSettings> {
|
||||
return this.http.invoke<ProjectSettings>("read_project_settings", { projectId });
|
||||
}
|
||||
updateProjectSettings(projectId: string, settings: ProjectSettings): Promise<ProjectSettings> {
|
||||
updateProjectSettings(projectId: string, settings: Partial<ProjectSettings>): Promise<ProjectSettings> {
|
||||
return this.http.invoke<ProjectSettings>("update_project_settings", {
|
||||
request: { projectId, themeColor: settings.themeColor },
|
||||
request: { projectId, ...settings },
|
||||
});
|
||||
}
|
||||
listProjectFiles(projectId: string, path: string): Promise<ProjectFileListing> {
|
||||
|
||||
@ -1466,20 +1466,26 @@ export class MockProjectGateway implements ProjectGateway {
|
||||
if (!this.projects.some((project) => project.id === projectId)) {
|
||||
throw { code: "NOT_FOUND", message: `project ${projectId} not found` };
|
||||
}
|
||||
return this.settings.get(projectId) ?? { themeColor: null };
|
||||
return this.settings.get(projectId) ?? { themeColor: null, icon: null };
|
||||
}
|
||||
|
||||
async updateProjectSettings(
|
||||
projectId: string,
|
||||
settings: ProjectSettings,
|
||||
settings: Partial<ProjectSettings>,
|
||||
): Promise<ProjectSettings> {
|
||||
if (!this.projects.some((project) => project.id === projectId)) {
|
||||
throw { code: "NOT_FOUND", message: `project ${projectId} not found` };
|
||||
}
|
||||
if (settings.themeColor !== null && !/^#[0-9A-Fa-f]{6}$/.test(settings.themeColor)) {
|
||||
if (settings.themeColor !== undefined && settings.themeColor !== null && !/^#[0-9A-Fa-f]{6}$/.test(settings.themeColor)) {
|
||||
throw { code: "INVALID", message: "themeColor must use #RRGGBB" };
|
||||
}
|
||||
const stored = { themeColor: settings.themeColor?.toUpperCase() ?? null };
|
||||
const previous = this.settings.get(projectId) ?? { themeColor: null, icon: null };
|
||||
const stored = {
|
||||
themeColor: settings.themeColor === undefined
|
||||
? previous.themeColor
|
||||
: settings.themeColor?.toUpperCase() ?? null,
|
||||
icon: settings.icon === undefined ? (previous.icon ?? null) : settings.icon,
|
||||
};
|
||||
this.settings.set(projectId, stored);
|
||||
return stored;
|
||||
}
|
||||
@ -5015,14 +5021,14 @@ export function createMockGateways(): Gateways {
|
||||
}
|
||||
|
||||
export class MockUserSettingsGateway implements UserSettingsGateway {
|
||||
private settings: UserSettings = { defaultAgentTerminalInterface: null };
|
||||
private settings: UserSettings = { defaultAgentTerminalInterface: null, language: null };
|
||||
|
||||
async readUserSettings(): Promise<UserSettings> {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
async updateUserSettings(settings: UserSettings): Promise<UserSettings> {
|
||||
this.settings = { ...settings };
|
||||
async updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings> {
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
return { ...this.settings };
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,4 +63,17 @@ describe("TauriProjectGateway invoke payloads", () => {
|
||||
request: { projectId: "proj-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes project icon updates through the additive settings request", async () => {
|
||||
const gateway = new TauriProjectGateway();
|
||||
await gateway.updateProjectSettings("proj-1", {
|
||||
icon: { set: "devicon", name: "typescript" },
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("update_project_settings", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
icon: { set: "devicon", name: "typescript" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -84,10 +84,10 @@ export class TauriProjectGateway implements ProjectGateway {
|
||||
|
||||
updateProjectSettings(
|
||||
projectId: string,
|
||||
settings: ProjectSettings,
|
||||
settings: Partial<ProjectSettings>,
|
||||
): Promise<ProjectSettings> {
|
||||
return invoke<ProjectSettings>("update_project_settings", {
|
||||
request: { projectId, themeColor: settings.themeColor },
|
||||
request: { projectId, ...settings },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
15
frontend/src/adapters/userSettings.test.ts
Normal file
15
frontend/src/adapters/userSettings.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) }));
|
||||
|
||||
import { TauriUserSettingsGateway } from "./userSettings";
|
||||
|
||||
describe("TauriUserSettingsGateway", () => {
|
||||
beforeEach(() => invoke.mockReset().mockResolvedValue({ defaultAgentTerminalInterface: null, language: "en" }));
|
||||
|
||||
it("persists language without overwriting unrelated user settings", async () => {
|
||||
await new TauriUserSettingsGateway().updateUserSettings({ language: "en" });
|
||||
expect(invoke).toHaveBeenCalledWith("update_user_settings", { request: { language: "en" } });
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@ export class TauriUserSettingsGateway implements UserSettingsGateway {
|
||||
return invoke<UserSettings>("read_user_settings");
|
||||
}
|
||||
|
||||
updateUserSettings(settings: UserSettings): Promise<UserSettings> {
|
||||
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings> {
|
||||
return invoke<UserSettings>("update_user_settings", { request: settings });
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,10 @@ import { AppExitConfirmDialog } from "@/features/appExit";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||
import { useGateways, shouldUseMock } from "./di";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function App() {
|
||||
const { tr } = useI18n();
|
||||
const { system, profile } = useGateways();
|
||||
const [health, setHealth] = useState<HealthReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -78,14 +80,14 @@ export function App() {
|
||||
<span className="text-xs text-faint">
|
||||
{health ? (
|
||||
<>
|
||||
backend <span className="text-success">●</span> v{health.version} ·{" "}
|
||||
{events.length} events
|
||||
{tr("backend", "backend")} <span className="text-success">●</span> v{health.version} ·{" "}
|
||||
{events.length} {tr("événements", "events")}
|
||||
</>
|
||||
) : error ? (
|
||||
<span className="text-danger">backend unavailable</span>
|
||||
<span className="text-danger">{tr("backend indisponible", "backend unavailable")}</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner size={12} /> checking…
|
||||
<Spinner size={12} /> {tr("vérification…", "checking…")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@ -96,7 +98,7 @@ export function App() {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{error && (
|
||||
<Panel className="mx-4 mt-4 border-danger/40">
|
||||
<p className="text-sm text-danger">Error: {error}</p>
|
||||
<p className="text-sm text-danger">{tr("Erreur :", "Error:")} {error}</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
|
||||
@ -261,7 +261,7 @@ describe("ViewWindow (#47)", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("search tickets")).toBeNull();
|
||||
expect(screen.queryByLabelText("Rechercher des tickets")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the view for the focused project and follows focus changes", async () => {
|
||||
@ -299,7 +299,7 @@ describe("ViewWindow (#47)", () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("Rechercher des tickets")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// Focus cleared (project closed) → back to the shell, view unmounts.
|
||||
@ -309,7 +309,7 @@ describe("ViewWindow (#47)", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("search tickets")).toBeNull();
|
||||
expect(screen.queryByLabelText("Rechercher des tickets")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the current focus at mount (restored window with a live focus)", async () => {
|
||||
@ -339,7 +339,7 @@ describe("ViewWindow (#47)", () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("Rechercher des tickets")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
type PluginRuntimeContextValue,
|
||||
} from "@/features/plugins";
|
||||
import { useGateways } from "./di";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** Panels that may be shown in a detached window (everything but "projects"). */
|
||||
const DETACHABLE = new Set<string>(
|
||||
@ -233,6 +234,7 @@ export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowP
|
||||
}
|
||||
|
||||
function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) {
|
||||
const { tr } = useI18n();
|
||||
const { focusedProject } = useGateways();
|
||||
const { registry, loading } = usePluginRuntime();
|
||||
const [focus, setFocus] = useState<FocusedProject | null | undefined>(undefined);
|
||||
@ -298,12 +300,12 @@ function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) {
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Ouvrez un projet dans la fenêtre principale pour afficher ce layout.
|
||||
{tr("Ouvrez un projet dans la fenêtre principale pour afficher ce layout.", "Open a project in the main window to display this layout.")}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{loading && (
|
||||
<p className="mt-3 text-xs text-muted">Chargement des plugins...</p>
|
||||
<p className="mt-3 text-xs text-muted">{tr("Chargement des plugins…", "Loading plugins…")}</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@ import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWin
|
||||
import { WebApp, WebSurfaceWindow, parseWebSurfaceWindowParams } from "@/features/web";
|
||||
import { DIProvider, resolveTransport } from "./di";
|
||||
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||
import { I18nProvider } from "@/i18n";
|
||||
import "@/shared/styles/theme.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
@ -40,6 +41,7 @@ ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<RootErrorBoundary>
|
||||
<DIProvider>
|
||||
<I18nProvider>
|
||||
{isWeb && webSurfaceParams ? (
|
||||
<WebSurfaceWindow params={webSurfaceParams} />
|
||||
) : isWeb ? (
|
||||
@ -51,6 +53,7 @@ ReactDOM.createRoot(root).render(
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</I18nProvider>
|
||||
</DIProvider>
|
||||
</RootErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
|
||||
@ -124,7 +124,7 @@ describe("AgentLimitBadge (render)", () => {
|
||||
const expected = `limité jusqu'à ${formatResetTime(at)}`;
|
||||
expect(screen.getByText(expected)).toBeTruthy();
|
||||
// No countdown / cancel button without an armed resume.
|
||||
expect(screen.queryByLabelText("cancel resume")).toBeNull();
|
||||
expect(screen.queryByLabelText("annuler la reprise")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows plain 'limité' when no reset time is known", () => {
|
||||
@ -145,8 +145,8 @@ describe("AgentLimitBadge (render)", () => {
|
||||
expect(screen.getByText("limité")).toBeTruthy();
|
||||
expect(screen.getByText(/heure inconnue/)).toBeTruthy();
|
||||
// The human-net form: a resume-time input + a "schedule resume" button.
|
||||
expect(screen.getByLabelText("resume time")).toBeTruthy();
|
||||
expect(screen.getByLabelText("schedule resume")).toBeTruthy();
|
||||
expect(screen.getByLabelText("heure de reprise")).toBeTruthy();
|
||||
expect(screen.getByLabelText("programmer la reprise")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show the resume-time form when a suspected limit has a time", () => {
|
||||
@ -160,7 +160,7 @@ describe("AgentLimitBadge (render)", () => {
|
||||
);
|
||||
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
|
||||
expect(screen.queryByText(/heure inconnue/)).toBeNull();
|
||||
expect(screen.queryByLabelText("resume time")).toBeNull();
|
||||
expect(screen.queryByLabelText("heure de reprise")).toBeNull();
|
||||
});
|
||||
|
||||
it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => {
|
||||
@ -172,10 +172,10 @@ describe("AgentLimitBadge (render)", () => {
|
||||
onSetResumeAt={onSetResumeAt}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("resume time"), {
|
||||
fireEvent.change(screen.getByLabelText("heure de reprise"), {
|
||||
target: { value: "23:45" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("schedule resume"));
|
||||
fireEvent.click(screen.getByLabelText("programmer la reprise"));
|
||||
expect(onSetResumeAt).toHaveBeenCalledTimes(1);
|
||||
const ms = onSetResumeAt.mock.calls[0][0] as number;
|
||||
const d = new Date(ms);
|
||||
@ -193,7 +193,7 @@ describe("AgentLimitBadge (render)", () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("schedule resume") as HTMLButtonElement).disabled,
|
||||
(screen.getByLabelText("programmer la reprise") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(onSetResumeAt).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -209,11 +209,11 @@ describe("AgentLimitBadge (render)", () => {
|
||||
/>,
|
||||
);
|
||||
// Countdown label present (≈ "reprise dans 1m 30s").
|
||||
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
|
||||
expect(screen.getByLabelText("compte à rebours de reprise").textContent).toMatch(
|
||||
/reprise dans/,
|
||||
);
|
||||
|
||||
const btn = screen.getByLabelText("cancel resume");
|
||||
const btn = screen.getByLabelText("annuler la reprise");
|
||||
fireEvent.click(btn);
|
||||
expect(onCancelResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@ -228,7 +228,7 @@ describe("AgentLimitBadge (render)", () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
|
||||
(screen.getByLabelText("annuler la reprise") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { AgentLimitState } from "./useAgents";
|
||||
|
||||
/**
|
||||
@ -75,6 +76,7 @@ export function AgentLimitBadge({
|
||||
onSetResumeAt,
|
||||
busy = false,
|
||||
}: AgentLimitBadgeProps) {
|
||||
const { tr } = useI18n();
|
||||
const { limitedUntil, resumeFireAt, suspected } = state;
|
||||
|
||||
// Tick a local clock once per second only while a resume countdown is armed,
|
||||
@ -106,17 +108,17 @@ export function AgentLimitBadge({
|
||||
|
||||
const limitLabel =
|
||||
limitedUntil !== undefined
|
||||
? `limité jusqu'à ${formatResetTime(limitedUntil)}`
|
||||
: "limité";
|
||||
? tr(`limité jusqu'à ${formatResetTime(limitedUntil)}`, `limited until ${formatResetTime(limitedUntil)}`)
|
||||
: tr("limité", "limited");
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
aria-label="session limit"
|
||||
aria-label={tr("limite de session", "session limit")}
|
||||
title={
|
||||
suspected
|
||||
? "Limite de session détectée sans heure fiable"
|
||||
: "Limite de session atteinte"
|
||||
? tr("Limite de session détectée sans heure fiable", "Session limit detected without a reliable time")
|
||||
: tr("Limite de session atteinte", "Session limit reached")
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
@ -134,14 +136,14 @@ export function AgentLimitBadge({
|
||||
<form
|
||||
onSubmit={handleSubmitResumeTime}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="resume time form"
|
||||
aria-label={tr("formulaire d'heure de reprise", "resume time form")}
|
||||
>
|
||||
<span className="text-xs text-muted" role="note">
|
||||
heure inconnue
|
||||
{tr("heure inconnue", "unknown time")}
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
aria-label="resume time"
|
||||
aria-label={tr("heure de reprise", "resume time")}
|
||||
value={resumeTime}
|
||||
disabled={busy}
|
||||
onChange={(e) => setResumeTime(e.target.value)}
|
||||
@ -151,27 +153,27 @@ export function AgentLimitBadge({
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label="schedule resume"
|
||||
aria-label={tr("programmer la reprise", "schedule resume")}
|
||||
disabled={busy || resumeTime.trim() === ""}
|
||||
>
|
||||
Programmer la reprise
|
||||
{tr("Programmer la reprise", "Schedule resume")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{resumeFireAt !== undefined && (
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-label="resume countdown" className="text-xs text-muted">
|
||||
reprise dans {formatCountdown(resumeFireAt - now)}
|
||||
<span aria-label={tr("compte à rebours de reprise", "resume countdown")} className="text-xs text-muted">
|
||||
{tr("reprise dans", "resuming in")} {formatCountdown(resumeFireAt - now)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel resume"
|
||||
aria-label={tr("annuler la reprise", "cancel resume")}
|
||||
disabled={busy}
|
||||
onClick={onCancelResume}
|
||||
>
|
||||
Annuler la reprise
|
||||
{tr("Annuler la reprise", "Cancel resume")}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
import { AnnouncementsPreview } from "@/features/announcements";
|
||||
import { useDrift } from "@/features/templates/useDrift";
|
||||
@ -45,6 +46,7 @@ export interface AgentsPanelProps {
|
||||
}
|
||||
|
||||
export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useAgents(projectId);
|
||||
const drift = useDrift(projectId);
|
||||
const gateways = useGateways();
|
||||
@ -260,7 +262,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel title="Agents" className="flex flex-col gap-0">
|
||||
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
@ -280,12 +282,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
htmlFor="agent-name-input"
|
||||
className="text-xs font-medium text-muted"
|
||||
>
|
||||
Name
|
||||
{tr("Nom", "Name")}
|
||||
</label>
|
||||
<Input
|
||||
id="agent-name-input"
|
||||
aria-label="agent name"
|
||||
placeholder="My agent"
|
||||
aria-label={tr("Nom de l’agent", "Agent name")}
|
||||
placeholder={tr("Mon agent", "My agent")}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full"
|
||||
@ -298,39 +300,38 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
htmlFor="agent-template-select"
|
||||
className="text-xs font-medium text-muted"
|
||||
>
|
||||
Template
|
||||
{tr("Modèle", "Template")}
|
||||
</label>
|
||||
<SmallDropdown
|
||||
id="agent-template-select"
|
||||
aria-label="agent template"
|
||||
aria-label={tr("Modèle d’agent", "Agent template")}
|
||||
value={newTemplateId}
|
||||
onChange={handleTemplateChange}
|
||||
className="w-full"
|
||||
disabled={vm.busy}
|
||||
size="md"
|
||||
options={[
|
||||
{ value: "", label: "(none / from scratch)" },
|
||||
{ value: "", label: tr("(aucun / partir de zéro)", "(none / from scratch)") },
|
||||
...templates.map((t) => ({ value: t.id, label: t.name })),
|
||||
]}
|
||||
/>
|
||||
{selectedTemplate && (
|
||||
<div className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted">
|
||||
<div>
|
||||
Nom initial:{" "}
|
||||
{tr("Nom initial", "Initial name")}: {" "}
|
||||
<span className="text-content">
|
||||
{selectedTemplate.defaultAgentName || selectedTemplate.name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Modèle appliqué:{" "}
|
||||
{tr("Modèle appliqué", "Applied model")}: {" "}
|
||||
<span className="text-content">
|
||||
{templateProfileLabel(selectedTemplate, vm.profiles)}
|
||||
{templateProfileLabel(selectedTemplate, vm.profiles, tr)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedTemplateMissingProfile && (
|
||||
<p role="alert" className="mt-1 text-danger">
|
||||
Ce template n'a pas de modèle AI par défaut. Choisis un autre
|
||||
template ou édite-le avant de créer l'agent.
|
||||
{tr("Ce modèle n’a pas de profil IA par défaut. Choisissez un autre modèle ou modifiez-le avant de créer l’agent.", "This template has no default AI profile. Choose another template or edit it before creating the agent.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -344,21 +345,21 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
htmlFor="agent-profile-select"
|
||||
className="text-xs font-medium text-muted"
|
||||
>
|
||||
Profile
|
||||
{tr("Profil", "Profile")}
|
||||
{vm.profiles.length === 0 && (
|
||||
<span className="ml-1 text-faint">(no profiles configured — you can still enter an id)</span>
|
||||
<span className="ml-1 text-faint">{tr("(aucun profil configuré — vous pouvez saisir un identifiant)", "(no profiles configured — you can still enter an id)")}</span>
|
||||
)}
|
||||
</label>
|
||||
{vm.profiles.length > 0 ? (
|
||||
<SmallDropdown
|
||||
id="agent-profile-select"
|
||||
aria-label="agent profile"
|
||||
aria-label={tr("Profil de l’agent", "Agent profile")}
|
||||
value={newProfileId}
|
||||
onChange={setNewProfileId}
|
||||
className="w-full"
|
||||
size="md"
|
||||
options={[
|
||||
{ value: "", label: "— select profile —" },
|
||||
{ value: "", label: tr("— sélectionner un profil —", "— select profile —") },
|
||||
...vm.profiles.map((p) => ({
|
||||
value: p.id,
|
||||
label: profileLabel(p),
|
||||
@ -368,8 +369,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
) : (
|
||||
<Input
|
||||
id="agent-profile-select"
|
||||
aria-label="agent profile"
|
||||
placeholder="profile-id (optional)"
|
||||
aria-label={tr("Profil de l’agent", "Agent profile")}
|
||||
placeholder={tr("identifiant-profil (facultatif)", "profile-id (optional)")}
|
||||
value={newProfileId}
|
||||
onChange={(e) => setNewProfileId(e.target.value)}
|
||||
className="min-w-32"
|
||||
@ -381,19 +382,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
aria-label="create agent"
|
||||
aria-label={tr("Créer l’agent", "Create agent")}
|
||||
disabled={!canCreate}
|
||||
loading={vm.busy}
|
||||
className="self-end"
|
||||
>
|
||||
Create
|
||||
{tr("Créer", "Create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* ── Agent list ── */}
|
||||
<div className="p-4">
|
||||
{vm.agents.length === 0 ? (
|
||||
<p className="text-sm text-muted">No agents yet.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun agent pour le moment.", "No agents yet.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.agents.map((a) => {
|
||||
@ -438,27 +439,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<span className="font-medium text-content">{a.name}</span>
|
||||
{a.isOrchestrator && (
|
||||
<span
|
||||
aria-label="Writer contexte"
|
||||
aria-label={tr("Responsable du contexte", "Context writer")}
|
||||
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
|
||||
>
|
||||
Writer contexte
|
||||
{tr("Responsable du contexte", "Context writer")}
|
||||
</span>
|
||||
)}
|
||||
{agentDrift && (
|
||||
<span
|
||||
aria-label="update available"
|
||||
aria-label={tr("Mise à jour disponible", "Update available")}
|
||||
className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
update available
|
||||
{tr("mise à jour disponible", "update available")}
|
||||
</span>
|
||||
)}
|
||||
{delegationSource && (
|
||||
<span
|
||||
aria-label={`delegation source ${delegationSource}`}
|
||||
aria-label={tr(`source de délégation ${delegationSource}`, `delegation source ${delegationSource}`)}
|
||||
title={
|
||||
delegationSource === "mcp"
|
||||
? "Last delegation arrived via the MCP server"
|
||||
: "Last delegation arrived via .ideai/requests"
|
||||
? tr("La dernière délégation est arrivée via le serveur MCP", "Last delegation arrived via the MCP server")
|
||||
: tr("La dernière délégation est arrivée via .ideai/requests", "Last delegation arrived via .ideai/requests")
|
||||
}
|
||||
className="rounded-full bg-raised px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted"
|
||||
>
|
||||
@ -470,36 +471,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<span className="text-xs text-muted">{profileName}</span>
|
||||
{live && (
|
||||
<span className="block w-full min-w-0 truncate text-xs text-primary">
|
||||
running in IdeA · {live.sessionId ?? live.nodeId}
|
||||
{tr("actif dans IdeA", "running in IdeA")} · {live.sessionId ?? live.nodeId}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSelected && (
|
||||
<section
|
||||
aria-label={`Rôle dans le projet — ${a.name}`}
|
||||
aria-label={`${tr("Rôle dans le projet", "Role in project")} — ${a.name}`}
|
||||
className="rounded-md border border-border bg-surface p-3"
|
||||
>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Rôle dans le projet
|
||||
{tr("Rôle dans le projet", "Role in project")}
|
||||
</h4>
|
||||
<label className="mt-2 flex cursor-pointer items-start gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="project-context-writer"
|
||||
aria-label={`Writer du contexte projet global pour ${a.name}`}
|
||||
aria-label={tr(`Responsable du contexte global du projet pour ${a.name}`, `Global project context writer for ${a.name}`)}
|
||||
checked={a.isOrchestrator === true}
|
||||
disabled={vm.busy}
|
||||
onChange={() => requestWriterChange(a)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Writer du contexte projet global
|
||||
{tr("Responsable du contexte global du projet", "Global project context writer")}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 pl-5 text-xs text-muted">
|
||||
Peut modifier directement le contexte partagé du projet.
|
||||
Un seul agent peut avoir ce rôle.
|
||||
{tr("Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.", "Can directly edit the project’s shared context. Only one agent can have this role.")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
@ -531,7 +531,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
engine abandons the conversation history → confirmation. */}
|
||||
{vm.profiles.length > 0 && (
|
||||
<SmallDropdown
|
||||
aria-label={`profile for ${a.name}`}
|
||||
aria-label={tr(`Profil de ${a.name}`, `Profile for ${a.name}`)}
|
||||
value={a.profileId}
|
||||
disabled={vm.busy}
|
||||
onChange={(profileId) => {
|
||||
@ -562,13 +562,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label={`sync ${a.name}`}
|
||||
aria-label={tr(`Synchroniser ${a.name}`, `Sync ${a.name}`)}
|
||||
disabled={vm.busy || drift.driftBusy}
|
||||
onClick={() =>
|
||||
void drift.syncAgent(a.id, vm.refresh)
|
||||
}
|
||||
>
|
||||
Sync
|
||||
{tr("Synchroniser", "Sync")}
|
||||
</Button>
|
||||
)}
|
||||
{isRunning || live ? (
|
||||
@ -577,41 +577,41 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
variant="ghost"
|
||||
aria-label={
|
||||
stopState === "stopping"
|
||||
? `Arrêt en cours… — ${a.name}`
|
||||
? tr(`Arrêt en cours… — ${a.name}`, `Stopping… — ${a.name}`)
|
||||
: stopState === "incomplete"
|
||||
? `Réessayer l’arrêt — ${a.name}`
|
||||
: `stop ${a.name}`
|
||||
? tr(`Réessayer l’arrêt — ${a.name}`, `Retry stopping — ${a.name}`)
|
||||
: tr(`Arrêter ${a.name}`, `Stop ${a.name}`)
|
||||
}
|
||||
disabled={stopState === "stopping"}
|
||||
onClick={() => void handleStop(a.id)}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
{stopState === "stopping"
|
||||
? "Arrêt en cours…"
|
||||
? tr("Arrêt en cours…", "Stopping…")
|
||||
: stopState === "incomplete"
|
||||
? "Réessayer l’arrêt"
|
||||
: "Stop"}
|
||||
? tr("Réessayer l’arrêt", "Retry stopping")
|
||||
: tr("Arrêter", "Stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label={`launch ${a.name}`}
|
||||
aria-label={tr(`Lancer ${a.name}`, `Launch ${a.name}`)}
|
||||
disabled={vm.busy || activeAgentId !== null}
|
||||
onClick={() => handleLaunch(a.id)}
|
||||
>
|
||||
Launch
|
||||
{tr("Lancer", "Launch")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`delete ${a.name}`}
|
||||
aria-label={tr(`Supprimer ${a.name}`, `Delete ${a.name}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.deleteAgent(a.id)}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
Delete
|
||||
{tr("Supprimer", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
@ -653,12 +653,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Context — {selectedAgent.name}
|
||||
{tr("Contexte", "Context")} — {selectedAgent.name}
|
||||
</h4>
|
||||
<code className="text-xs text-muted">{selectedAgent.contextPath}</code>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="agent context"
|
||||
aria-label={tr("Contexte de l’agent", "Agent context")}
|
||||
value={editedContext}
|
||||
onChange={(e) => setEditedContext(e.target.value)}
|
||||
rows={10}
|
||||
@ -677,7 +677,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.saveContext(editedContext)}
|
||||
>
|
||||
Save
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -688,34 +688,33 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Confirm profile change"
|
||||
aria-label={tr("Confirmer le changement de profil", "Confirm profile change")}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Changer le moteur IA
|
||||
{tr("Changer le moteur IA", "Change AI engine")}
|
||||
</h4>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Changer le moteur abandonne l'historique de conversation (le
|
||||
contexte et la mémoire sont conservés). Continuer ?
|
||||
{tr("Changer le moteur abandonne l’historique de conversation (le contexte et la mémoire sont conservés). Continuer ?", "Changing the engine discards conversation history (context and memory are preserved). Continue?")}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="cancel profile change"
|
||||
aria-label={tr("Annuler le changement de profil", "Cancel profile change")}
|
||||
disabled={vm.busy}
|
||||
onClick={() => setPendingProfileChange(null)}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="confirm profile change"
|
||||
aria-label={tr("Confirmer le changement de profil", "Confirm profile change")}
|
||||
loading={vm.busy}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void confirmProfileChange()}
|
||||
>
|
||||
Continuer
|
||||
{tr("Continuer", "Continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -726,34 +725,33 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Confirmer le changement de writer contexte"
|
||||
aria-label={tr("Confirmer le changement de responsable du contexte", "Confirm context writer change")}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Changer le writer du contexte
|
||||
{tr("Changer le responsable du contexte", "Change context writer")}
|
||||
</h4>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
|
||||
attribué à {pendingWriterChange.agentName}. Continuer ?
|
||||
{tr(`Le rôle sera retiré de ${pendingWriterChange.currentWriterName} et attribué à ${pendingWriterChange.agentName}. Continuer ?`, `The role will be removed from ${pendingWriterChange.currentWriterName} and assigned to ${pendingWriterChange.agentName}. Continue?`)}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Annuler le changement de writer contexte"
|
||||
aria-label={tr("Annuler le changement de responsable du contexte", "Cancel context writer change")}
|
||||
disabled={vm.busy}
|
||||
onClick={() => setPendingWriterChange(null)}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="Confirmer le changement de writer contexte"
|
||||
aria-label={tr("Confirmer le changement de responsable du contexte", "Confirm context writer change")}
|
||||
loading={vm.busy}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void confirmWriterChange()}
|
||||
>
|
||||
Continuer
|
||||
{tr("Continuer", "Continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -766,23 +764,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const EFFORT_DEFAULT_VALUE = "__profile_default__";
|
||||
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
|
||||
|
||||
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
|
||||
{
|
||||
value: "low",
|
||||
label: "Rapide (par défaut)",
|
||||
hint: "Fallback générique léger.",
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: "Standard (par défaut)",
|
||||
hint: "Fallback générique équilibré.",
|
||||
},
|
||||
{
|
||||
value: "high",
|
||||
label: "Approfondi (par défaut)",
|
||||
hint: "Fallback générique profond.",
|
||||
},
|
||||
];
|
||||
function genericEffortOptions(tr: (fr: string, en: string) => string): EffortOption[] {
|
||||
return [
|
||||
{ value: "low", label: tr("Rapide (par défaut)", "Fast (default)"), hint: tr("Réglage générique léger.", "Light generic fallback.") },
|
||||
{ value: "medium", label: tr("Standard (par défaut)", "Standard (default)"), hint: tr("Réglage générique équilibré.", "Balanced generic fallback.") },
|
||||
{ value: "high", label: tr("Approfondi (par défaut)", "Thorough (default)"), hint: tr("Réglage générique approfondi.", "Deep generic fallback.") },
|
||||
];
|
||||
}
|
||||
|
||||
function rawEffortValue(selection: EffortSelection | undefined): string {
|
||||
return selection?.value ?? "";
|
||||
@ -799,11 +787,12 @@ function EffortSelector({
|
||||
busy: boolean;
|
||||
onChange: (effort: EffortSelection | null) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const nativeOptions = profile?.effortOptions ?? [];
|
||||
const hasNativeOptions = nativeOptions.length > 0;
|
||||
const displayedOptions = hasNativeOptions
|
||||
? nativeOptions
|
||||
: GENERIC_EFFORT_OPTIONS;
|
||||
: genericEffortOptions(tr);
|
||||
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
|
||||
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
|
||||
|
||||
@ -843,7 +832,7 @@ function EffortSelector({
|
||||
return (
|
||||
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
|
||||
<SmallDropdown
|
||||
aria-label={`effort for ${agent.name}`}
|
||||
aria-label={tr(`Effort pour ${agent.name}`, `Effort for ${agent.name}`)}
|
||||
value={selectedOption}
|
||||
disabled={busy}
|
||||
onChange={(value) => {
|
||||
@ -868,20 +857,20 @@ function EffortSelector({
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
|
||||
{ value: EFFORT_DEFAULT_VALUE, label: tr("Effort : profil", "Effort: profile") },
|
||||
...displayedOptions.map((option) => ({
|
||||
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
|
||||
label: option.label,
|
||||
})),
|
||||
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
|
||||
{ value: EFFORT_CUSTOM_VALUE, label: tr("Personnalisé", "Custom") },
|
||||
]}
|
||||
/>
|
||||
{customVisible && (
|
||||
<Input
|
||||
aria-label={`custom effort for ${agent.name}`}
|
||||
aria-label={tr(`Effort personnalisé pour ${agent.name}`, `Custom effort for ${agent.name}`)}
|
||||
value={customText}
|
||||
disabled={busy}
|
||||
placeholder="valeur brute"
|
||||
placeholder={tr("valeur brute", "raw value")}
|
||||
onChange={(event) => setCustomText(event.target.value)}
|
||||
onBlur={commitCustom}
|
||||
onKeyDown={(event) => {
|
||||
@ -903,21 +892,22 @@ function NetworkPermissionBadge({
|
||||
}: {
|
||||
state: ResolvedAgentSystemPermissions;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const locked = state.runtimeLock.state === "locked";
|
||||
const label = locked
|
||||
? "Verrouillé"
|
||||
? tr("Verrouillé", "Locked")
|
||||
: state.effective === "allow"
|
||||
? "Autorisé"
|
||||
? tr("Autorisé", "Allowed")
|
||||
: state.effective === "deny"
|
||||
? "Interdit"
|
||||
: "Demande";
|
||||
? tr("Interdit", "Denied")
|
||||
: tr("Demande", "Ask");
|
||||
return (
|
||||
<span
|
||||
aria-label={`network ${label}`}
|
||||
aria-label={`${tr("Réseau", "Network")} ${label}`}
|
||||
title={
|
||||
locked
|
||||
? (state.runtimeLock.reason ?? "Réseau verrouillé par le runtime")
|
||||
: `État effectif : ${label}`
|
||||
? (state.runtimeLock.reason ?? tr("Réseau verrouillé par l’exécution", "Network locked by runtime"))
|
||||
: `${tr("État effectif", "Effective state")} : ${label}`
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
@ -930,7 +920,7 @@ function NetworkPermissionBadge({
|
||||
: "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
Réseau: {label}
|
||||
{tr("Réseau", "Network")}: {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
__resetProjectCliInteractionActivityForTests,
|
||||
hasProjectCliInteraction,
|
||||
} from "@/features/projects/projectCliInteractionActivity";
|
||||
import { publishProjectIconChanged } from "@/features/projects/projectIcons";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
@ -197,6 +198,23 @@ describe("CustomAgentChatView", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("renders the project icon as a non-interactive custom-CLI watermark and updates it immediately", async () => {
|
||||
const agent = {
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||||
sendAgentChat: vi.fn(), cancelAgentChat: vi.fn(), closeAgentChat: vi.fn(),
|
||||
};
|
||||
const project = { readProjectSettings: vi.fn(async () => ({ themeColor: null, icon: { set: "devicon" as const, name: "python" } })) };
|
||||
render(<DIProvider gateways={{ agent, project, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId="chat-1" conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||||
</DIProvider>);
|
||||
|
||||
const watermark = await screen.findByTestId("project-icon-watermark");
|
||||
expect(watermark.style.pointerEvents).toBe("none");
|
||||
expect(watermark.textContent).toBe("Py");
|
||||
publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } });
|
||||
await waitFor(() => expect(watermark.textContent).toBe("★"));
|
||||
});
|
||||
|
||||
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
@ -246,7 +264,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(
|
||||
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Son transcript n’est plus disponible après la reprise.")).toBeTruthy();
|
||||
expect(screen.getByText("Sa transcription n’est plus disponible après la reprise.")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
||||
).toBeNull();
|
||||
@ -370,10 +388,10 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("------- redemarrage de l'agent -------"),
|
||||
await screen.findByText("------- redémarrage de l’agent -------"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText("------- redemarrage de l'agent termine -------"),
|
||||
screen.getByText("------- redémarrage de l’agent terminé -------"),
|
||||
).not.toBeNull();
|
||||
expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2);
|
||||
|
||||
@ -403,7 +421,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.getByText("------- redemarrage de l'agent echoue -------"),
|
||||
screen.getByText("------- redémarrage de l’agent échoué -------"),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText("runtime unavailable")).not.toBeNull();
|
||||
});
|
||||
@ -676,7 +694,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(composer, { target: { value: "first prompt" } });
|
||||
@ -712,7 +730,7 @@ describe("CustomAgentChatView", () => {
|
||||
it("recalls history from the exact end of a multiline draft and restores it verbatim", async () => {
|
||||
const agent = renderHistoryChat();
|
||||
const composer = (await screen.findByLabelText(
|
||||
/message CLI custom/,
|
||||
/Message CLI personnalisée/,
|
||||
)) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||||
await sendHistoryPrompt(composer, agent, "historic prompt");
|
||||
@ -742,7 +760,7 @@ describe("CustomAgentChatView", () => {
|
||||
it("keeps ArrowUp native away from a simple end caret or during modified/IME input", async () => {
|
||||
const agent = renderHistoryChat();
|
||||
const composer = (await screen.findByLabelText(
|
||||
/message CLI custom/,
|
||||
/Message CLI personnalisée/,
|
||||
)) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||||
await sendHistoryPrompt(composer, agent, "historic prompt");
|
||||
@ -778,7 +796,7 @@ describe("CustomAgentChatView", () => {
|
||||
it("does not consume ArrowUp with empty history and never mutates recalled entries", async () => {
|
||||
const agent = renderHistoryChat();
|
||||
const composer = (await screen.findByLabelText(
|
||||
/message CLI custom/,
|
||||
/Message CLI personnalisée/,
|
||||
)) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||||
|
||||
@ -838,7 +856,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
let composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
let composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "remember me" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||
@ -859,7 +877,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||||
await waitFor(() => expect(composer.value).toBe("remember me"));
|
||||
});
|
||||
@ -897,7 +915,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/message CLI custom/);
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
fireEvent.change(composer, { target: { value: "persist before send" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
@ -911,7 +929,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
try {
|
||||
const agent = renderHistoryChat();
|
||||
const composer = await screen.findByLabelText(/message CLI custom/);
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
fireEvent.change(composer, { target: { value: "still send" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
@ -1020,7 +1038,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "Initial prompt" },
|
||||
});
|
||||
nowMs = userReceivedAtMs;
|
||||
@ -1100,7 +1118,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "Initial prompt" },
|
||||
});
|
||||
nowMs = userReceivedAtMs;
|
||||
@ -1235,9 +1253,9 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
try {
|
||||
render(<Harness />);
|
||||
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
const handle = screen.getByRole("button", {
|
||||
name: "redimensionner le composer node-1",
|
||||
name: "Redimensionner l’éditeur node-1",
|
||||
});
|
||||
expect(composer.style.height).toBe("64px");
|
||||
expect(handle.style.cursor).toBe("ns-resize");
|
||||
@ -1401,7 +1419,7 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
expect(screen.getByText("Initial reply")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
|
||||
@ -1411,7 +1429,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByText("Partial answer while away")).toBeTruthy();
|
||||
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => {
|
||||
@ -1594,7 +1612,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first turn" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -1619,12 +1637,12 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
expect(agent.cancelAgentChat).toHaveBeenCalledTimes(1);
|
||||
resolveCancel();
|
||||
await waitFor(() => expect(screen.getByText("Request stopped.")).toBeTruthy());
|
||||
await waitFor(() => expect(screen.getByText("Demande arrêtée.")).toBeTruthy());
|
||||
expect(hasProjectCliInteraction("project-1")).toBe(false);
|
||||
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
||||
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
|
||||
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
||||
const composer = screen.getByLabelText(/message CLI custom/);
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/);
|
||||
expect(composer).toHaveProperty("disabled", false);
|
||||
|
||||
fireEvent.change(composer, {
|
||||
@ -1696,7 +1714,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/message CLI custom/);
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
|
||||
@ -1791,7 +1809,7 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty("disabled", true);
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
|
||||
@ -1895,7 +1913,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "hello agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -1974,13 +1992,13 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await screen.findByText("final first request");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "second request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2117,16 +2135,16 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "ship progress UI" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
await screen.findByText("Progress");
|
||||
await screen.findByText("Progression");
|
||||
expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2);
|
||||
expect(screen.getByText("turn.started")).toBeTruthy();
|
||||
expect(screen.getByText("turn.completed")).toBeTruthy();
|
||||
expect(screen.getByText("Progress").closest("[data-testid='chat-progress-status']")).toBeTruthy();
|
||||
expect(screen.getByText("Progression").closest("[data-testid='chat-progress-status']")).toBeTruthy();
|
||||
expect(screen.getByText("Analyse")).toBeTruthy();
|
||||
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
|
||||
expect(screen.getByText("MCP")).toBeTruthy();
|
||||
@ -2203,13 +2221,13 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await screen.findByText("done first");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "second" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2251,7 +2269,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2271,7 +2289,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
});
|
||||
expect(screen.getByText("Running first request")).toBeTruthy();
|
||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
|
||||
expect(screen.getByText("Outil").parentElement?.textContent).toContain("En cours");
|
||||
|
||||
act(() => {
|
||||
harness.emit(0, {
|
||||
@ -2289,7 +2307,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
await screen.findByText("final first request");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "second request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2308,7 +2326,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
});
|
||||
expect(screen.getByText("Second request context loaded.")).toBeTruthy();
|
||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
|
||||
expect(screen.getByText("Progression").parentElement?.textContent).toContain("En cours");
|
||||
|
||||
act(() => {
|
||||
harness.emit(1, {
|
||||
@ -2379,7 +2397,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2412,7 +2430,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
await screen.findByText("done first request");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "second request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2487,7 +2505,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2522,7 +2540,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
await screen.findByText("done first request");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "second request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2599,7 +2617,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "first request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2636,7 +2654,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
expect(screen.queryByText("Late first request completion")).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "second request" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2727,7 +2745,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "delegate after intent" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2831,7 +2849,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "delegate once" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -2882,7 +2900,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getAllByText("validate")).toHaveLength(2);
|
||||
expect(screen.queryByText("Appel inter-agent")).toBeNull();
|
||||
expect(screen.queryByText("reply delivered")).toBeNull();
|
||||
expect(screen.queryByText("Tool")).toBeNull();
|
||||
expect(screen.queryByText("Outil")).toBeNull();
|
||||
expect(screen.queryByText("started")).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
|
||||
@ -2939,7 +2957,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "read many tickets" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3032,7 +3050,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "read ticket" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3108,7 +3126,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "download model" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3134,7 +3152,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
expect(screen.queryByText("Downloading 10%")).toBeNull();
|
||||
expect(screen.getByText("Downloading 75%")).toBeTruthy();
|
||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
|
||||
expect(screen.getByText("Outil").parentElement?.textContent).toContain("En cours");
|
||||
});
|
||||
|
||||
it("expands only truncated MCP progress details and preserves expansion across updates", async () => {
|
||||
@ -3187,7 +3205,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "inspect mcp payloads" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3308,7 +3326,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "stream progress" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3382,7 +3400,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "run tools" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3473,7 +3491,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "call tool" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3499,7 +3517,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
expect(screen.queryByText("Searching...")).toBeNull();
|
||||
expect(screen.getByText("Found results")).toBeTruthy();
|
||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("Terminé");
|
||||
expect(screen.getByText("Outil").parentElement?.textContent).toContain("Terminé");
|
||||
|
||||
act(() => {
|
||||
emitChunk?.({ kind: "final", content: "done" });
|
||||
@ -3560,7 +3578,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "ship progress UI" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3580,7 +3598,7 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
|
||||
expect(screen.getByText("Progression").parentElement?.textContent).toContain("En cours");
|
||||
|
||||
act(() => {
|
||||
emitChunk?.({ kind: "final", content: "done" });
|
||||
@ -3591,7 +3609,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
|
||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("Terminé");
|
||||
expect(screen.getByText("Progression").parentElement?.textContent).toContain("Terminé");
|
||||
});
|
||||
|
||||
it("ignores late progress chunks after the final answer without reopening the turn", async () => {
|
||||
@ -3644,7 +3662,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "ship ordered stream" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -3734,7 +3752,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "/c" } });
|
||||
|
||||
const menu = await screen.findByRole("listbox", {
|
||||
@ -3804,7 +3822,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "hello /c" } });
|
||||
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
|
||||
expect(agent.listSlashCommands).not.toHaveBeenCalled();
|
||||
@ -3889,7 +3907,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "/explain focus this" } });
|
||||
fireEvent.keyDown(composer, { key: "Enter" });
|
||||
|
||||
@ -3958,7 +3976,7 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
expect(await screen.findByText("old conversation")).toBeTruthy();
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "/clean" } });
|
||||
fireEvent.keyDown(composer, { key: "Enter" });
|
||||
|
||||
@ -4053,7 +4071,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "/help" } });
|
||||
fireEvent.keyDown(composer, { key: "Enter" });
|
||||
|
||||
@ -4135,7 +4153,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/);
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/);
|
||||
fireEvent.change(composer, { target: { value: "/explain" } });
|
||||
fireEvent.keyDown(composer, { key: "Enter" });
|
||||
|
||||
@ -4219,7 +4237,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "/profile" } });
|
||||
fireEvent.keyDown(composer, { key: "Enter" });
|
||||
|
||||
@ -4232,14 +4250,14 @@ describe("CustomAgentChatView", () => {
|
||||
expect(agent.sendAgentChat).not.toHaveBeenCalled();
|
||||
|
||||
const confirmButton = screen.getByRole("button", {
|
||||
name: "Reset et changer le profil",
|
||||
name: "Réinitialiser et changer le profil",
|
||||
}) as HTMLButtonElement;
|
||||
expect(confirmButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("profil cible"), {
|
||||
fireEvent.change(screen.getByLabelText("Profil cible"), {
|
||||
target: { value: "codex-high" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera reset."));
|
||||
fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera réinitialisée."));
|
||||
expect(confirmButton.disabled).toBe(false);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
@ -4314,7 +4332,7 @@ describe("CustomAgentChatView", () => {
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||
});
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.paste(composer, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
@ -4332,7 +4350,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
|
||||
await waitFor(() => expect(composer.value).toBe("[Image 1]"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer clipboard.png" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
|
||||
@ -4402,7 +4420,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
clipboardData: {
|
||||
items: [],
|
||||
files: [],
|
||||
@ -4492,7 +4510,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
clipboardData: {
|
||||
items: [],
|
||||
files: [],
|
||||
@ -4564,7 +4582,7 @@ describe("CustomAgentChatView", () => {
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||
});
|
||||
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{
|
||||
@ -4645,7 +4663,7 @@ describe("CustomAgentChatView", () => {
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer),
|
||||
});
|
||||
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
clipboardData: {
|
||||
items: [],
|
||||
files: [file],
|
||||
@ -4716,7 +4734,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "prefix suffix" } });
|
||||
composer.setSelectionRange(7, 13);
|
||||
|
||||
@ -4802,7 +4820,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "voir maintenant" } });
|
||||
composer.setSelectionRange(5, 5);
|
||||
const file = new File(["ignored"], "cursor.png", { type: "image/png" });
|
||||
@ -4904,7 +4922,7 @@ describe("CustomAgentChatView", () => {
|
||||
files: [file],
|
||||
getData: () => "",
|
||||
};
|
||||
const composer = screen.getByLabelText(/message CLI custom/);
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/);
|
||||
|
||||
fireEvent.paste(composer, { clipboardData });
|
||||
await screen.findByText("Fichier joint: duplicate.pdf");
|
||||
@ -4994,7 +5012,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
|
||||
expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe(""),
|
||||
);
|
||||
|
||||
rerender(
|
||||
@ -5028,7 +5046,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
|
||||
expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe(""),
|
||||
);
|
||||
});
|
||||
|
||||
@ -5083,7 +5101,7 @@ describe("CustomAgentChatView", () => {
|
||||
await waitFor(() =>
|
||||
expect(agent.readChatDraft).toHaveBeenCalledWith(scope),
|
||||
);
|
||||
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "draft with file" } });
|
||||
|
||||
const file = new File(["ignored"], "not-persisted.png", { type: "image/png" });
|
||||
@ -5159,7 +5177,7 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
|
||||
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
|
||||
expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("clears the persisted draft when a slash command is consumed", async () => {
|
||||
@ -5224,7 +5242,7 @@ describe("CustomAgentChatView", () => {
|
||||
}),
|
||||
);
|
||||
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
|
||||
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
|
||||
expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
|
||||
@ -5457,7 +5475,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "please recover" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -5531,7 +5549,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "please recover" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -5595,7 +5613,7 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "long turn" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
@ -5603,7 +5621,7 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
|
||||
expect(await screen.findByText("Request already stopped.")).toBeTruthy();
|
||||
expect(await screen.findByText("Demande déjà arrêtée.")).toBeTruthy();
|
||||
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
||||
expect(agent.cancelAgentChat).toHaveBeenCalledWith("gone-session");
|
||||
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
|
||||
|
||||
@ -31,6 +31,7 @@ import type {
|
||||
SlashCommand,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
AnnouncementsPreview,
|
||||
shortRestartFailure,
|
||||
@ -43,10 +44,12 @@ import {
|
||||
markProjectCliInteractionStopped,
|
||||
} from "@/features/projects/projectCliInteractionActivity";
|
||||
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
|
||||
import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
|
||||
import type {
|
||||
ChatAttachmentInput,
|
||||
ChatDraftScope,
|
||||
ConversationTranscriptUnavailableReason,
|
||||
ProjectIconRef,
|
||||
} from "@/ports";
|
||||
|
||||
export interface CustomAgentChatViewProps {
|
||||
@ -947,7 +950,8 @@ export function CustomAgentChatView({
|
||||
onSessionId,
|
||||
onConversationId,
|
||||
}: CustomAgentChatViewProps) {
|
||||
const { agent, profile: profileGateway, system, workState } = useGateways();
|
||||
const { agent, profile: profileGateway, project, system, workState } = useGateways();
|
||||
const { t, tr } = useI18n();
|
||||
const pluginRuntime = usePluginRuntime();
|
||||
const initialConversationCacheKey = conversationCacheKey(
|
||||
projectId,
|
||||
@ -1006,6 +1010,7 @@ export function CustomAgentChatView({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -1048,6 +1053,17 @@ export function CustomAgentChatView({
|
||||
onConversationIdRef.current = onConversationId;
|
||||
const domId = nodeId ?? `agent-${agentId}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void project?.readProjectSettings(projectId).then((settings) => {
|
||||
if (!cancelled) setProjectIcon(settings.icon ?? null);
|
||||
}).catch(() => undefined);
|
||||
const unsubscribe = onProjectIconChanged((change) => {
|
||||
if (change.projectId === projectId) setProjectIcon(change.icon);
|
||||
});
|
||||
return () => { cancelled = true; unsubscribe(); };
|
||||
}, [project, projectId]);
|
||||
|
||||
const clampComposerHeight = useCallback(
|
||||
(heightPx: number): number => {
|
||||
const visibleHeight =
|
||||
@ -1173,7 +1189,7 @@ export function CustomAgentChatView({
|
||||
delegationActive;
|
||||
const delegationFallbackText = requesterDelegation.delegationTargetName
|
||||
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
|
||||
: "Délégation en cours.";
|
||||
: tr("Délégation en cours.", "Delegation in progress.");
|
||||
|
||||
const refreshRequesterDelegation = useCallback(async () => {
|
||||
if (!workState) {
|
||||
@ -1391,21 +1407,21 @@ export function CustomAgentChatView({
|
||||
nextTurns.push({
|
||||
role: "restart",
|
||||
key,
|
||||
text: "------- redemarrage de l'agent -------",
|
||||
text: tr("------- redémarrage de l’agent -------", "------- restarting agent -------"),
|
||||
});
|
||||
} else if (event.type === "agentRestartCompleted") {
|
||||
changed = true;
|
||||
nextTurns.push({
|
||||
role: "restart",
|
||||
key,
|
||||
text: "------- redemarrage de l'agent termine -------",
|
||||
text: tr("------- redémarrage de l’agent terminé -------", "------- agent restart completed -------"),
|
||||
});
|
||||
} else {
|
||||
changed = true;
|
||||
nextTurns.push({
|
||||
role: "restart",
|
||||
key,
|
||||
text: "------- redemarrage de l'agent echoue -------",
|
||||
text: tr("------- redémarrage de l’agent échoué -------", "------- agent restart failed -------"),
|
||||
message: shortRestartFailure(event.message),
|
||||
failed: true,
|
||||
});
|
||||
@ -2028,7 +2044,7 @@ export function CustomAgentChatView({
|
||||
if (!profileDialog) return;
|
||||
if (!agent.changeAgentProfile) {
|
||||
setProfileDialog((prev) =>
|
||||
prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
|
||||
prev ? { ...prev, error: tr("Changement de profil indisponible.", "Profile change unavailable.") } : prev,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -2077,7 +2093,7 @@ export function CustomAgentChatView({
|
||||
turns: [
|
||||
{
|
||||
role: "tool",
|
||||
label: `Profil changé vers ${selected.name}.`,
|
||||
label: tr(`Profil changé vers ${selected.name}.`, `Profile changed to ${selected.name}.`),
|
||||
receivedAtMs: null,
|
||||
},
|
||||
],
|
||||
@ -2095,7 +2111,7 @@ export function CustomAgentChatView({
|
||||
async function executeSlashCommand(invocation: SlashCommandInvocation) {
|
||||
if (!agent.executeSlashCommand) {
|
||||
setError({
|
||||
message: `Commande ${invocation.name} indisponible dans ce runtime.`,
|
||||
message: tr(`Commande ${invocation.name} indisponible dans ce runtime.`, `Command ${invocation.name} is unavailable in this runtime.`),
|
||||
openFailure: false,
|
||||
});
|
||||
return;
|
||||
@ -2144,7 +2160,7 @@ export function CustomAgentChatView({
|
||||
...prev.turns,
|
||||
{
|
||||
role: "tool",
|
||||
label: `Commande plugin ${result.command.name} exécutée.`,
|
||||
label: tr(`Commande plugin ${result.command.name} exécutée.`, `Plugin command ${result.command.name} executed.`),
|
||||
receivedAtMs: null,
|
||||
},
|
||||
],
|
||||
@ -2152,7 +2168,7 @@ export function CustomAgentChatView({
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
||||
const message = `${tr(`Commande ${invocation.name} échouée :`, `Command ${invocation.name} failed:`)} ${describe(e)}`;
|
||||
setError({ message, openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
@ -2253,7 +2269,7 @@ export function CustomAgentChatView({
|
||||
const persisted = writeComposerHistory(composerHistoryKey, nextHistory);
|
||||
setComposerHistory(nextHistory);
|
||||
setComposerHistoryWarning(
|
||||
persisted ? null : "Historique des prompts non sauvegardé.",
|
||||
persisted ? null : tr("Historique des prompts non sauvegardé.", "Prompt history was not saved."),
|
||||
);
|
||||
setComposerHistoryIndex(null);
|
||||
draftBeforeHistoryNavigationRef.current = "";
|
||||
@ -2364,7 +2380,7 @@ export function CustomAgentChatView({
|
||||
...completed.turns,
|
||||
{
|
||||
role: "tool",
|
||||
label: "Request stopped.",
|
||||
label: tr("Demande arrêtée.", "Request stopped."),
|
||||
receivedAtMs: null,
|
||||
},
|
||||
],
|
||||
@ -2384,7 +2400,7 @@ export function CustomAgentChatView({
|
||||
...completed,
|
||||
turns: [
|
||||
...completed.turns,
|
||||
{ role: "tool", label: "Request already stopped.", receivedAtMs: null },
|
||||
{ role: "tool", label: tr("Demande déjà arrêtée.", "Request already stopped."), receivedAtMs: null },
|
||||
],
|
||||
};
|
||||
});
|
||||
@ -2397,7 +2413,7 @@ export function CustomAgentChatView({
|
||||
...prev.turns,
|
||||
{
|
||||
role: "error",
|
||||
text: `Arrêt de la demande échoué: ${describe(e)}`,
|
||||
text: `${tr("Échec de l’arrêt de la demande", "Failed to stop request")}: ${describe(e)}`,
|
||||
receivedAtMs: null,
|
||||
},
|
||||
],
|
||||
@ -2413,22 +2429,27 @@ export function CustomAgentChatView({
|
||||
data-testid="custom-agent-chat-view"
|
||||
className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
|
||||
>
|
||||
{projectIcon && (
|
||||
<div data-testid="project-icon-watermark" aria-hidden className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]" style={{ pointerEvents: "none" }}>
|
||||
<ProjectIcon icon={projectIcon} size={260} decorative />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="custom agent chat actions"
|
||||
aria-label={t("custom.actions")}
|
||||
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="truncate text-sm font-medium">{agentName}</div>
|
||||
<div className="truncate text-xs text-muted">
|
||||
CLI custom · {profile.name}
|
||||
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!supported && (
|
||||
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
|
||||
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
|
||||
{t("custom.unsupported")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
@ -2438,10 +2459,12 @@ export function CustomAgentChatView({
|
||||
>
|
||||
{error.openFailure ? (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="font-medium">Le CLI custom n'a pas pu démarrer.</div>
|
||||
<div className="break-words">
|
||||
Cause: {error.message}
|
||||
</div>
|
||||
<div className="font-medium">{t("custom.startFailure")}</div>
|
||||
{(!mobile || !/invalid node id/i.test(error.message)) && (
|
||||
<div className="break-words">
|
||||
{tr("Cause", "Cause")}: {error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -2449,7 +2472,7 @@ export function CustomAgentChatView({
|
||||
disabled={opening}
|
||||
onClick={() => setOpenRetryNonce((value) => value + 1)}
|
||||
>
|
||||
Réessayer
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
{onOpenNativeTui && (
|
||||
<Button
|
||||
@ -2457,7 +2480,7 @@ export function CustomAgentChatView({
|
||||
variant="secondary"
|
||||
onClick={onOpenNativeTui}
|
||||
>
|
||||
Ouvrir en TUI native
|
||||
{t("custom.openTui")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -2485,10 +2508,10 @@ export function CustomAgentChatView({
|
||||
id={`profile-command-title-${domId}`}
|
||||
className="text-sm font-semibold text-content"
|
||||
>
|
||||
Changer de profil
|
||||
{t("custom.profile.change")}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
|
||||
{t("custom.profile.warning")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -2497,14 +2520,14 @@ export function CustomAgentChatView({
|
||||
disabled={profileDialog.busy}
|
||||
onClick={() => setProfileDialog(null)}
|
||||
>
|
||||
Fermer
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 flex-col gap-2">
|
||||
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
|
||||
Profil
|
||||
{t("custom.profile")}
|
||||
<select
|
||||
aria-label="profil cible"
|
||||
aria-label={tr("Profil cible", "Target profile")}
|
||||
value={profileDialog.selectedProfileId}
|
||||
disabled={profileDialog.busy}
|
||||
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
|
||||
@ -2538,7 +2561,7 @@ export function CustomAgentChatView({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>Je confirme que la session courante sera reset.</span>
|
||||
<span>{tr("Je confirme que la session courante sera réinitialisée.", "I confirm that the current session will be reset.")}</span>
|
||||
</label>
|
||||
{profileDialog.error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
@ -2552,7 +2575,7 @@ export function CustomAgentChatView({
|
||||
disabled={profileDialog.busy}
|
||||
onClick={() => setProfileDialog(null)}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -2561,7 +2584,7 @@ export function CustomAgentChatView({
|
||||
loading={profileDialog.busy}
|
||||
onClick={() => void confirmProfileCommandChange()}
|
||||
>
|
||||
Reset et changer le profil
|
||||
{tr("Réinitialiser et changer le profil", "Reset and change profile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -2584,18 +2607,18 @@ export function CustomAgentChatView({
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>
|
||||
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||
{externalSessionId ? t("custom.resume") : t("custom.opening")}
|
||||
</span>
|
||||
</div>
|
||||
) : unavailableLinkedTranscript ? (
|
||||
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
|
||||
<p className="text-sm font-medium text-content">
|
||||
La conversation liée n’a pas pu être restaurée.
|
||||
{tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{unavailableLinkedTranscript === "conversationNotFound"
|
||||
? "Cette conversation est introuvable dans le stockage disponible."
|
||||
: "Son transcript n’est plus disponible après la reprise."}
|
||||
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
|
||||
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
@ -2604,16 +2627,16 @@ export function CustomAgentChatView({
|
||||
disabled={opening}
|
||||
onClick={retryUnavailableTranscript}
|
||||
>
|
||||
Réessayer
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
|
||||
Délier la conversation
|
||||
{tr("Délier la conversation", "Unlink conversation")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : displayTurns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Envoyez un message pour démarrer la conversation structurée.
|
||||
{t("custom.empty")}
|
||||
</p>
|
||||
) : (
|
||||
displayTurns.map((turn, index) => (
|
||||
@ -2661,14 +2684,14 @@ export function CustomAgentChatView({
|
||||
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
|
||||
<span className="min-w-0 truncate">{t("custom.attachment")}: {item.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`retirer ${item.label}`}
|
||||
aria-label={tr(`Retirer ${item.label}`, `Remove ${item.label}`)}
|
||||
className="shrink-0 text-content"
|
||||
onClick={() => removeAttachment(item)}
|
||||
>
|
||||
Retirer
|
||||
{t("custom.removeAttachment")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@ -2678,7 +2701,7 @@ export function CustomAgentChatView({
|
||||
<div
|
||||
id={`slash-command-menu-${domId}`}
|
||||
role="listbox"
|
||||
aria-label="suggestions commandes slash"
|
||||
aria-label={t("custom.slashSuggestions")}
|
||||
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
|
||||
>
|
||||
{slashCommands.map((command, index) => {
|
||||
@ -2705,7 +2728,7 @@ export function CustomAgentChatView({
|
||||
{command.shortDescription}
|
||||
</span>
|
||||
{command.requiresConfirmation && (
|
||||
<span className="shrink-0 text-muted">confirm</span>
|
||||
<span className="shrink-0 text-muted">{tr("confirmation", "confirm")}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@ -2721,7 +2744,7 @@ export function CustomAgentChatView({
|
||||
<div className="relative flex min-w-0 flex-1">
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
aria-label={`message CLI custom ${domId}`}
|
||||
aria-label={`${tr("Message CLI personnalisée", "Custom CLI message")} ${domId}`}
|
||||
aria-controls={slashMenuOpen ? `slash-command-menu-${domId}` : undefined}
|
||||
aria-activedescendant={
|
||||
slashMenuOpen ? `slash-command-${domId}-${slashActiveIndex}` : undefined
|
||||
@ -2733,7 +2756,7 @@ export function CustomAgentChatView({
|
||||
rows={2}
|
||||
value={draft}
|
||||
disabled={!supported || opening || effectiveBusy}
|
||||
placeholder="Message à l'agent…"
|
||||
placeholder={t("custom.message")}
|
||||
style={{ height: clampComposerHeight(composerHeightPx) }}
|
||||
onChange={(e) => editDraft(e.target.value)}
|
||||
onPaste={(e) => void pasteClipboardFiles(e)}
|
||||
@ -2772,8 +2795,8 @@ export function CustomAgentChatView({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`redimensionner le composer ${domId}`}
|
||||
title="Redimensionner le composer"
|
||||
aria-label={`${tr("Redimensionner l’éditeur", "Resize composer")} ${domId}`}
|
||||
title={t("custom.resize")}
|
||||
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm border border-border bg-raised text-[9px] leading-3 text-muted hover:text-content focus:border-primary focus:outline-none"
|
||||
style={{ cursor: "ns-resize" }}
|
||||
onMouseDown={startComposerResize}
|
||||
@ -2793,7 +2816,7 @@ export function CustomAgentChatView({
|
||||
disabled={!supported || opening || effectiveBusy}
|
||||
onClick={() => void pickAttachment()}
|
||||
>
|
||||
Joindre
|
||||
{t("custom.attach")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -2805,11 +2828,11 @@ export function CustomAgentChatView({
|
||||
: !canSend
|
||||
}
|
||||
loading={stopping}
|
||||
title={effectiveBusy ? "Arrêter la demande en cours" : undefined}
|
||||
aria-label={effectiveBusy ? "Stop" : undefined}
|
||||
title={effectiveBusy ? t("custom.stop") : undefined}
|
||||
aria-label={effectiveBusy ? t("custom.stop") : undefined}
|
||||
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
|
||||
>
|
||||
{stopping ? "Stopping..." : effectiveBusy ? "Stop" : "Envoyer"}
|
||||
{stopping ? t("custom.stopping") : effectiveBusy ? t("custom.stop") : t("custom.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -2830,6 +2853,7 @@ function ChatBubble({
|
||||
expandedProgressKeys: Set<string>;
|
||||
onToggleProgressExpanded: (key: string) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
if (turn.role === "restart") {
|
||||
return (
|
||||
<div
|
||||
@ -2851,7 +2875,7 @@ function ChatBubble({
|
||||
return (
|
||||
<div className="flex min-w-0 justify-center">
|
||||
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
|
||||
<span className="truncate">{turn.label}</span>
|
||||
<span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span>
|
||||
<EntryTimestamp atMs={turn.receivedAtMs} />
|
||||
</span>
|
||||
</div>
|
||||
@ -2874,7 +2898,7 @@ function ChatBubble({
|
||||
<div className="flex min-w-0 justify-end">
|
||||
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
|
||||
<ChatMessageMeta
|
||||
label="Terminé"
|
||||
label={tr("Terminé", "Completed")}
|
||||
receivedAtMs={turn.receivedAtMs}
|
||||
labelClassName="font-semibold text-success"
|
||||
/>
|
||||
@ -2889,7 +2913,7 @@ function ChatBubble({
|
||||
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
{turn.role === "error" && (
|
||||
<ChatMessageMeta
|
||||
label="Échec"
|
||||
label={tr("Échec", "Failed")}
|
||||
receivedAtMs={turn.receivedAtMs}
|
||||
labelClassName="font-semibold text-danger"
|
||||
/>
|
||||
@ -2897,8 +2921,8 @@ function ChatBubble({
|
||||
{turn.role === "error" && sessionLabel && (
|
||||
<p className="mb-1 font-medium text-danger/85">{sessionLabel}</p>
|
||||
)}
|
||||
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
|
||||
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
||||
{turn.role === "unknown" ? tr("Fragment inconnu reçu : ", "Unknown chunk received: ") : ""}
|
||||
<span className="whitespace-pre-wrap break-words">{turn.text === "Erreur agent" ? tr("Erreur agent", "Agent error") : turn.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -2914,7 +2938,7 @@ function ChatBubble({
|
||||
)}
|
||||
>
|
||||
<ChatMessageMeta
|
||||
label={user ? "Vous" : requesterName}
|
||||
label={user ? tr("Vous", "You") : requesterName}
|
||||
receivedAtMs={turn.receivedAtMs}
|
||||
/>
|
||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||
@ -2922,7 +2946,7 @@ function ChatBubble({
|
||||
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
||||
{turn.attachments.map((attachment) => (
|
||||
<p key={attachment} className="truncate">
|
||||
Fichier: {attachment}
|
||||
{tr("Fichier", "File")}: {attachment}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@ -2930,7 +2954,7 @@ function ChatBubble({
|
||||
{!user && turn.pending && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
|
||||
<Spinner size={12} />
|
||||
Réponse en cours
|
||||
{tr("Réponse en cours", "Reply in progress")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -2957,28 +2981,30 @@ function ChatMessageMeta({
|
||||
);
|
||||
}
|
||||
|
||||
function progressTitle(progress: ReplyProgress): string {
|
||||
if (isDelegationProgress(progress)) return "Délégation";
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
function progressTitle(progress: ReplyProgress, tr: Tr): string {
|
||||
if (isDelegationProgress(progress)) return tr("Délégation", "Delegation");
|
||||
if (progress.kind === "mcp") return "MCP";
|
||||
if (progress.kind === "tool") return "Tool";
|
||||
if (progress.kind === "message") return "Progress";
|
||||
if (progress.kind === "turn") return "Tour";
|
||||
return "Activité";
|
||||
if (progress.kind === "tool") return tr("Outil", "Tool");
|
||||
if (progress.kind === "message") return tr("Progression", "Progress");
|
||||
if (progress.kind === "turn") return tr("Tour", "Turn");
|
||||
return tr("Activité", "Activity");
|
||||
}
|
||||
|
||||
function progressSourceLabel(progress: ReplyProgress): string {
|
||||
function progressSourceLabel(progress: ReplyProgress, tr: Tr): string {
|
||||
if (progress.source === "ideaLocal") return "IdeA";
|
||||
return progress.provider ?? "Provider";
|
||||
return progress.provider ?? tr("Fournisseur", "Provider");
|
||||
}
|
||||
|
||||
function progressStageLabel(stage: ReplyProgressStage): string {
|
||||
function progressStageLabel(stage: ReplyProgressStage, tr: Tr): string {
|
||||
switch (stage) {
|
||||
case "started":
|
||||
return "En cours";
|
||||
return tr("En cours", "In progress");
|
||||
case "delta":
|
||||
return "En cours";
|
||||
return tr("En cours", "In progress");
|
||||
case "completed":
|
||||
return "Terminé";
|
||||
return tr("Terminé", "Completed");
|
||||
case "info":
|
||||
return "info";
|
||||
}
|
||||
@ -3125,6 +3151,7 @@ function ExpandableProgressDetail({
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const contentId = useId();
|
||||
const detailRef = useRef<HTMLParagraphElement>(null);
|
||||
const truncated = useIsTextTruncated(detailRef, content, expanded);
|
||||
@ -3155,7 +3182,7 @@ function ExpandableProgressDetail({
|
||||
aria-controls={contentId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? "Afficher moins" : "Afficher plus"}
|
||||
{expanded ? tr("Afficher moins", "Show less") : tr("Afficher plus", "Show more")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -3163,8 +3190,9 @@ function ExpandableProgressDetail({
|
||||
}
|
||||
|
||||
function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
||||
const stage = progressStageLabel(progress.stage);
|
||||
const label = progress.label.trim() || "Tour";
|
||||
const { tr } = useI18n();
|
||||
const stage = progressStageLabel(progress.stage, tr);
|
||||
const label = progress.label.trim() || tr("Tour", "Turn");
|
||||
return (
|
||||
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
|
||||
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
|
||||
@ -3193,7 +3221,8 @@ function ToolProgressCard({
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}) {
|
||||
const stage = progressStageLabel(progress.stage);
|
||||
const { tr } = useI18n();
|
||||
const stage = progressStageLabel(progress.stage, tr);
|
||||
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
||||
const detail = delegation
|
||||
? delegation.detail
|
||||
@ -3203,8 +3232,8 @@ function ToolProgressCard({
|
||||
? `${delegation.requester ?? requesterName} ${
|
||||
progress.stage === "completed" ? "←" : "→"
|
||||
} ${delegation.target}`
|
||||
: "Appel inter-agent"
|
||||
: progressTitle(progress);
|
||||
: tr("Appel inter-agent", "Inter-agent call")
|
||||
: progressTitle(progress, tr);
|
||||
return (
|
||||
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
|
||||
<div
|
||||
@ -3233,9 +3262,9 @@ function ToolProgressCard({
|
||||
)}
|
||||
{(progress.source || progress.nativeEvent || progress.provider) && (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||
<span>{progressSourceLabel(progress)}</span>
|
||||
<span>{progressSourceLabel(progress, tr)}</span>
|
||||
{progress.nativeEvent && (
|
||||
<span className="truncate">event: {progress.nativeEvent}</span>
|
||||
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -3266,6 +3295,7 @@ function ProgressBubble({
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
||||
if (isCenteredToolProgress(progress)) {
|
||||
return (
|
||||
@ -3278,9 +3308,9 @@ function ProgressBubble({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const title = progressTitle(progress);
|
||||
const stage = progressStageLabel(progress.stage);
|
||||
const source = progressSourceLabel(progress);
|
||||
const title = progressTitle(progress, tr);
|
||||
const stage = progressStageLabel(progress.stage, tr);
|
||||
const source = progressSourceLabel(progress, tr);
|
||||
const detail = progress.text?.trim() || null;
|
||||
return (
|
||||
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
||||
@ -3299,7 +3329,7 @@ function ProgressBubble({
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||
<span>{source}</span>
|
||||
{progress.nativeEvent && (
|
||||
<span className="truncate">event: {progress.nativeEvent}</span>
|
||||
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
|
||||
)}
|
||||
</div>
|
||||
{detail && (
|
||||
|
||||
@ -27,11 +27,11 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||
const { rerender } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "probing" }} />,
|
||||
);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
|
||||
/vérification/i,
|
||||
);
|
||||
rerender(<ModelServerLaunchBadge status={{ state: "starting" }} />);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
|
||||
/démarrage/i,
|
||||
);
|
||||
});
|
||||
@ -40,13 +40,13 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||
const { rerender } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "ready", reused: true }} />,
|
||||
);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
|
||||
/réutilisé/i,
|
||||
);
|
||||
rerender(
|
||||
<ModelServerLaunchBadge status={{ state: "ready", reused: false }} />,
|
||||
);
|
||||
const label = screen.getByLabelText("model server status").textContent ?? "";
|
||||
const label = screen.getByLabelText("état du serveur de modèle").textContent ?? "";
|
||||
expect(label).toMatch(/prêt/i);
|
||||
expect(label).not.toMatch(/réutilisé/i);
|
||||
});
|
||||
@ -64,7 +64,7 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toMatch(/did not become ready on :8080/);
|
||||
// The code is not exposed by default (logs/detail stay hidden).
|
||||
expect(screen.queryByLabelText("launch failure detail")).toBeNull();
|
||||
expect(screen.queryByLabelText("détail de l'échec du lancement")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals the stable code/cause only when Détails is toggled", () => {
|
||||
@ -73,8 +73,8 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||
failure={{ cause: "model_server", code: "SPAWN", message: "boom" }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("toggle launch failure detail"));
|
||||
const detail = screen.getByLabelText("launch failure detail");
|
||||
fireEvent.click(screen.getByLabelText("afficher ou masquer le détail de l'échec"));
|
||||
const detail = screen.getByLabelText("détail de l'échec du lancement");
|
||||
expect(detail.textContent).toMatch(/SPAWN/);
|
||||
expect(detail.textContent).toMatch(/model_server/);
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { ModelServerStatus } from "@/domain";
|
||||
import type { AgentLaunchFailure } from "./useAgents";
|
||||
|
||||
@ -31,20 +32,21 @@ export interface ModelServerLaunchBadgeProps {
|
||||
/** Human label + tone for a non-failed lifecycle state. */
|
||||
function describeStatus(
|
||||
status: ModelServerStatus,
|
||||
tr: (french: string, english: string) => string,
|
||||
): { label: string; tone: string; role?: "status" } | null {
|
||||
switch (status.state) {
|
||||
case "notConfigured":
|
||||
return null; // Nothing to surface for an unmanaged endpoint.
|
||||
case "probing":
|
||||
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
|
||||
return { label: tr("vérification du serveur…", "checking server…"), tone: "bg-muted/20 text-muted", role: "status" };
|
||||
case "starting":
|
||||
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||
return { label: tr("démarrage du serveur…", "starting server…"), tone: "bg-warning/20 text-warning", role: "status" };
|
||||
case "downloading":
|
||||
return { label: "téléchargement du modèle…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||
return { label: tr("téléchargement du modèle…", "downloading model…"), tone: "bg-warning/20 text-warning", role: "status" };
|
||||
case "ready":
|
||||
return status.reused
|
||||
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
|
||||
: { label: "serveur prêt", tone: "bg-success/20 text-success" };
|
||||
? { label: tr("serveur prêt (réutilisé)", "server ready (reused)"), tone: "bg-success/20 text-success" }
|
||||
: { label: tr("serveur prêt", "server ready"), tone: "bg-success/20 text-success" };
|
||||
case "failed":
|
||||
// Handled by the failure branch below (richer, actionable).
|
||||
return null;
|
||||
@ -55,6 +57,7 @@ export function ModelServerLaunchBadge({
|
||||
status,
|
||||
failure,
|
||||
}: ModelServerLaunchBadgeProps) {
|
||||
const { tr } = useI18n();
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
// A launch failure (from `agentLaunchFailed`) or a `failed` status both mean
|
||||
@ -72,26 +75,26 @@ export function ModelServerLaunchBadge({
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
role="alert"
|
||||
aria-label="model server launch failed"
|
||||
aria-label={tr("échec du lancement du serveur de modèle", "model server launch failed")}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
"bg-danger/20 text-danger",
|
||||
)}
|
||||
>
|
||||
Échec du lancement : {failed.message}
|
||||
{tr("Échec du lancement :", "Launch failed:")} {failed.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle launch failure detail"
|
||||
aria-label={tr("afficher ou masquer le détail de l'échec", "toggle launch failure detail")}
|
||||
onClick={() => setShowDetail((v) => !v)}
|
||||
className="text-xs text-muted underline hover:text-content"
|
||||
>
|
||||
Détails
|
||||
{tr("Détails", "Details")}
|
||||
</button>
|
||||
{showDetail && (
|
||||
<span aria-label="launch failure detail" className="text-xs text-faint">
|
||||
code {failed.code}
|
||||
{failed.cause ? ` · cause ${failed.cause}` : ""}
|
||||
<span aria-label={tr("détail de l'échec du lancement", "launch failure detail")} className="text-xs text-faint">
|
||||
{tr("code", "code")} {failed.code}
|
||||
{failed.cause ? ` · ${tr("cause", "cause")} ${failed.cause}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@ -99,13 +102,13 @@ export function ModelServerLaunchBadge({
|
||||
}
|
||||
|
||||
if (!status) return null;
|
||||
const described = describeStatus(status);
|
||||
const described = describeStatus(status, tr);
|
||||
if (!described) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
role={described.role}
|
||||
aria-label="model server status"
|
||||
aria-label={tr("état du serveur de modèle", "model server status")}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
described.tone,
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { Button, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { useResumeProject } from "./useResumeProject";
|
||||
|
||||
export interface ResumeProjectPanelProps {
|
||||
@ -27,6 +28,7 @@ export interface ResumeProjectPanelProps {
|
||||
}
|
||||
|
||||
export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useResumeProject(projectId, cwd);
|
||||
|
||||
// No resumable agents ⇒ no panel at all.
|
||||
@ -36,17 +38,16 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Reprise des agents"
|
||||
aria-label={tr("Reprise des agents", "Resume agents")}
|
||||
data-testid="resume-project-panel"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||||
>
|
||||
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Reprendre les agents ?
|
||||
{tr("Reprendre les agents ?", "Resume agents?")}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Ces agents tournaient à la dernière fermeture du projet. Choisissez
|
||||
ceux à reprendre.
|
||||
{tr("Ces agents tournaient à la dernière fermeture du projet. Choisissez ceux à reprendre.", "These agents were running when the project was last closed. Choose which ones to resume.")}
|
||||
</p>
|
||||
|
||||
{vm.error && (
|
||||
@ -61,7 +62,7 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
|
||||
{/* ── Per-agent rows ── */}
|
||||
<ul className="mt-4 flex flex-col gap-2 overflow-auto">
|
||||
{vm.pending.map((a) => {
|
||||
const statusLabel = a.wasRunning ? "en cours" : "clôt";
|
||||
const statusLabel = a.wasRunning ? tr("en cours", "running") : tr("clôt", "closed");
|
||||
return (
|
||||
<li
|
||||
key={a.agentId}
|
||||
@ -90,7 +91,7 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
|
||||
data-testid={`resume-fresh-note-${a.agentId}`}
|
||||
className="text-xs text-muted"
|
||||
>
|
||||
Historique non disponible pour ce moteur — relance à neuf.
|
||||
{tr("Historique non disponible pour ce moteur — relance à neuf.", "History is unavailable for this engine — starting fresh.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -98,28 +99,28 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label={`resume ${a.name}`}
|
||||
aria-label={tr(`reprendre ${a.name}`, `resume ${a.name}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.resume(a)}
|
||||
>
|
||||
{a.resumeSupported ? "Reprendre" : "Relancer à neuf"}
|
||||
{a.resumeSupported ? tr("Reprendre", "Resume") : tr("Relancer à neuf", "Start fresh")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`new conversation ${a.name}`}
|
||||
aria-label={tr(`nouvelle conversation ${a.name}`, `new conversation ${a.name}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.newConversation(a)}
|
||||
>
|
||||
Nouvelle conversation
|
||||
{tr("Nouvelle conversation", "New conversation")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`ignore ${a.name}`}
|
||||
aria-label={tr(`ignorer ${a.name}`, `ignore ${a.name}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => vm.ignore(a.agentId)}
|
||||
>
|
||||
Ignorer
|
||||
{tr("Ignorer", "Ignore")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
@ -131,20 +132,20 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="ignore all agents"
|
||||
aria-label={tr("ignorer tous les agents", "ignore all agents")}
|
||||
disabled={vm.busy}
|
||||
onClick={() => vm.ignoreAll()}
|
||||
>
|
||||
Tout ignorer
|
||||
{tr("Tout ignorer", "Ignore all")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="resume all agents"
|
||||
aria-label={tr("reprendre tous les agents", "resume all agents")}
|
||||
loading={vm.busy}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.resumeAll()}
|
||||
>
|
||||
Tout reprendre
|
||||
{tr("Tout reprendre", "Resume all")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -77,7 +77,7 @@ async function waitForIdle() {
|
||||
// for the agent list to be rendered.
|
||||
await waitFor(() => {
|
||||
// Panel has finished its initial load when the form is accessible.
|
||||
expect(screen.getByLabelText("agent name")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Nom de l’agent")).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,18 +96,18 @@ function chooseDropdownOption(label: string | RegExp, optionName: string | RegEx
|
||||
async function createAgent(name: string, profileId?: string) {
|
||||
await waitForIdle();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("agent name"), {
|
||||
fireEvent.change(screen.getByLabelText("Nom de l’agent"), {
|
||||
target: { value: name },
|
||||
});
|
||||
|
||||
if (profileId) {
|
||||
const profileInput = screen.queryByLabelText("agent profile");
|
||||
const profileInput = screen.queryByLabelText("Profil de l’agent");
|
||||
if (profileInput) {
|
||||
chooseDropdownOption("agent profile", profileId);
|
||||
chooseDropdownOption("Profil de l’agent", profileId);
|
||||
}
|
||||
}
|
||||
|
||||
const createBtn = screen.getByRole("button", { name: "create agent" });
|
||||
const createBtn = screen.getByRole("button", { name: "Créer l’agent" });
|
||||
expect((createBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(createBtn);
|
||||
}
|
||||
@ -120,7 +120,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
it("shows 'No agents yet.' when the project has no agents", async () => {
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
expect(screen.getByText("No agents yet.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun agent pour le moment.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating an agent adds it to the list", async () => {
|
||||
@ -162,7 +162,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
|
||||
renderPanel(agent);
|
||||
|
||||
expect(await screen.findByText("Writer contexte")).toBeTruthy();
|
||||
expect(await screen.findByText("Responsable du contexte")).toBeTruthy();
|
||||
const rowButton = screen
|
||||
.getAllByRole("button", { name: /main/i })
|
||||
.find((button) => button.hasAttribute("aria-pressed"))!;
|
||||
@ -176,7 +176,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("radio", {
|
||||
name: "Writer du contexte projet global pour Main",
|
||||
name: "Responsable du contexte global du projet pour Main",
|
||||
}) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
});
|
||||
@ -201,7 +201,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
fireEvent.click(rowButton);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("radio", {
|
||||
name: "Writer du contexte projet global pour Architect",
|
||||
name: "Responsable du contexte global du projet pour Architect",
|
||||
}),
|
||||
);
|
||||
|
||||
@ -212,7 +212,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Confirmer le changement de writer contexte",
|
||||
name: "Confirmer le changement de responsable du contexte",
|
||||
}),
|
||||
);
|
||||
|
||||
@ -221,7 +221,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("radio", {
|
||||
name: "Writer du contexte projet global pour Architect",
|
||||
name: "Responsable du contexte global du projet pour Architect",
|
||||
}) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
});
|
||||
@ -254,12 +254,12 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
fireEvent.click(rowButton);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("radio", {
|
||||
name: "Writer du contexte projet global pour QA",
|
||||
name: "Responsable du contexte global du projet pour QA",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Confirmer le changement de writer contexte",
|
||||
name: "Confirmer le changement de responsable du contexte",
|
||||
}),
|
||||
);
|
||||
|
||||
@ -272,7 +272,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
// Name field is empty by default → disabled
|
||||
const btn = screen.getByRole("button", { name: "create agent" });
|
||||
const btn = screen.getByRole("button", { name: "Créer l’agent" });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
@ -304,7 +304,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
openDropdown("agent profile");
|
||||
openDropdown("Profil de l’agent");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
@ -326,14 +326,14 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
await waitForIdle();
|
||||
|
||||
// Click on the agent row button (aria-pressed) to select it — not the
|
||||
// "launch Alpha" action button.
|
||||
// "Lancer Alpha" action button.
|
||||
const buttons = screen.getAllByRole("button", { name: /alpha/i });
|
||||
const rowBtn = buttons.find(
|
||||
(b) => b.hasAttribute("aria-pressed"),
|
||||
)!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
const textarea = await screen.findByLabelText("agent context");
|
||||
const textarea = await screen.findByLabelText("Contexte de l’agent");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context");
|
||||
});
|
||||
|
||||
@ -352,10 +352,10 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
const textarea = await screen.findByLabelText("agent context");
|
||||
const textarea = await screen.findByLabelText("Contexte de l’agent");
|
||||
fireEvent.change(textarea, { target: { value: "updated content" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
// After save the gateway should hold the new content.
|
||||
await waitFor(async () => {
|
||||
@ -379,9 +379,9 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
let rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
let textarea = await screen.findByLabelText("agent context");
|
||||
let textarea = await screen.findByLabelText("Contexte de l’agent");
|
||||
fireEvent.change(textarea, { target: { value: "persisted after reopen" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const agents = await agent.listAgents(PROJECT_ID);
|
||||
@ -398,7 +398,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
textarea = await screen.findByLabelText("agent context");
|
||||
textarea = await screen.findByLabelText("Contexte de l’agent");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("persisted after reopen");
|
||||
});
|
||||
|
||||
@ -413,7 +413,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
|
||||
await screen.findByText("Gamma");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "delete Gamma" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Supprimer Gamma" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Gamma")).toBeNull();
|
||||
@ -437,7 +437,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "launch Delta" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Lancer Delta" }));
|
||||
|
||||
// The terminal container div (data-testid="terminal-view") should be mounted.
|
||||
await waitFor(() => {
|
||||
@ -466,13 +466,13 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
await waitForIdle();
|
||||
|
||||
// Launch
|
||||
fireEvent.click(screen.getByRole("button", { name: "launch Echo" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Lancer Echo" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Stop
|
||||
fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Arrêter Echo" }));
|
||||
const stopping = screen.getByRole("button", { name: "Arrêt en cours… — Echo" });
|
||||
expect((stopping as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
@ -498,9 +498,9 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "launch Main" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Lancer Main" }));
|
||||
await screen.findByTestId("terminal-view");
|
||||
fireEvent.click(screen.getByRole("button", { name: "stop Main" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Arrêter Main" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Arrêt incomplet — Certains travaux sont encore actifs."),
|
||||
@ -524,9 +524,9 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
const stop = vi.spyOn(agent, "stopLiveAgent");
|
||||
|
||||
renderPanel(agent);
|
||||
await screen.findByRole("button", { name: "stop Headless" });
|
||||
await screen.findByRole("button", { name: "Arrêter Headless" });
|
||||
expect(screen.queryByTestId("terminal-view")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "stop Headless" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Arrêter Headless" }));
|
||||
|
||||
await waitFor(() => expect(stop).toHaveBeenCalled());
|
||||
await expect(agent.listLiveAgents(PROJECT_ID)).resolves.toEqual([]);
|
||||
@ -548,15 +548,15 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
await waitForIdle();
|
||||
|
||||
// Select the template in the dropdown
|
||||
chooseDropdownOption("agent template", "My Template");
|
||||
chooseDropdownOption("Modèle d’agent", "My Template");
|
||||
|
||||
// Fill agent name
|
||||
fireEvent.change(screen.getByLabelText("agent name"), {
|
||||
fireEvent.change(screen.getByLabelText("Nom de l’agent"), {
|
||||
target: { value: "From Template" },
|
||||
});
|
||||
|
||||
// Click Create
|
||||
fireEvent.click(screen.getByRole("button", { name: "create agent" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer l’agent" }));
|
||||
|
||||
// createAgentFromTemplate should have been called with the correct args
|
||||
await waitFor(() => {
|
||||
@ -585,12 +585,12 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
|
||||
await waitForIdle();
|
||||
|
||||
chooseDropdownOption("agent template", "Main template");
|
||||
expect((screen.getByLabelText("agent name") as HTMLInputElement).value).toBe("Main");
|
||||
chooseDropdownOption("Modèle d’agent", "Main template");
|
||||
expect((screen.getByLabelText("Nom de l’agent") as HTMLInputElement).value).toBe("Main");
|
||||
expect(screen.getByText(/Modèle appliqué:/)).toBeTruthy();
|
||||
expect(screen.getByText("Profil profile-main")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "create agent" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer l’agent" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createFromTemplateSpy).toHaveBeenCalledWith(PROJECT_ID, t.id, {
|
||||
@ -619,7 +619,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
openDropdown("agent profile");
|
||||
openDropdown("Profil de l’agent");
|
||||
const options = screen.getAllByRole("option").map((o) => o.textContent);
|
||||
expect(options).toContain("Claude Code");
|
||||
});
|
||||
@ -634,7 +634,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
openDropdown("agent profile");
|
||||
openDropdown("Profil de l’agent");
|
||||
const options = screen.getAllByRole("option").map((o) => o.textContent ?? "");
|
||||
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
|
||||
});
|
||||
@ -770,7 +770,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
chooseDropdownOption("Profil de Swap", /Codex CLI/);
|
||||
|
||||
// No gateway call yet — only the dialog appears.
|
||||
expect(swapSpy).not.toHaveBeenCalled();
|
||||
@ -786,9 +786,9 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
chooseDropdownOption("Profil de Swap", /Codex CLI/);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "cancel profile change" }),
|
||||
screen.getByRole("button", { name: "Annuler le changement de profil" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
@ -807,9 +807,9 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
chooseDropdownOption("Profil de Swap", /Codex CLI/);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "confirm profile change" }),
|
||||
screen.getByRole("button", { name: "Confirmer le changement de profil" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@ -830,13 +830,13 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
chooseDropdownOption("Profil de Swap", /Codex CLI/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const text = dialog.textContent ?? "";
|
||||
// Spec §15.1: « Changer le moteur abandonne l'historique de conversation
|
||||
// (le contexte et la mémoire sont conservés). Continuer ? » — must be FRENCH.
|
||||
expect(text).toMatch(/abandonne l'historique de conversation/i);
|
||||
expect(text).toMatch(/abandonne l.historique de conversation/i);
|
||||
expect(text).toMatch(/contexte et la mémoire sont conservés/i);
|
||||
expect(text).toMatch(/Continuer\s*\?/);
|
||||
});
|
||||
@ -852,13 +852,13 @@ describe("AgentsPanel effort selection (#131)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
openDropdown("effort for Thinker");
|
||||
openDropdown("Effort pour Thinker");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Effort : profil",
|
||||
"Léger",
|
||||
"Standard",
|
||||
"Profond",
|
||||
@ -878,7 +878,7 @@ describe("AgentsPanel effort selection (#131)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
chooseDropdownOption("effort for Thinker", "Profond");
|
||||
chooseDropdownOption("Effort pour Thinker", "Profond");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
@ -911,14 +911,14 @@ describe("AgentsPanel effort selection (#131)", () => {
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Fallback");
|
||||
await screen.findByText("Effort : profil");
|
||||
|
||||
openDropdown("effort for Fallback");
|
||||
openDropdown("Effort pour Fallback");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Effort : profil",
|
||||
"Rapide (par défaut)",
|
||||
"Standard (par défaut)",
|
||||
"Approfondi (par défaut)",
|
||||
@ -943,10 +943,10 @@ describe("AgentsPanel effort selection (#131)", () => {
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Custom");
|
||||
await screen.findByText("Effort : profil");
|
||||
|
||||
chooseDropdownOption("effort for Custom", "Personnalisé");
|
||||
const input = screen.getByLabelText("custom effort for Custom");
|
||||
chooseDropdownOption("Effort pour Custom", "Personnalisé");
|
||||
const input = screen.getByLabelText("Effort personnalisé pour Custom");
|
||||
fireEvent.change(input, { target: { value: "x-provider-deep" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
@ -974,7 +974,7 @@ describe("AgentsPanel live refresh on domain events", () => {
|
||||
);
|
||||
|
||||
// Initially empty.
|
||||
await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy());
|
||||
await waitFor(() => expect(screen.getByText("Aucun agent pour le moment.")).toBeTruthy());
|
||||
|
||||
// Simulate the orchestrator creating an agent directly in the store
|
||||
// (bypassing the UI's createAgent path), then emitting `agentLaunched`.
|
||||
@ -1029,7 +1029,7 @@ describe("AgentsPanel live refresh on domain events", () => {
|
||||
// subscription effect must no-op rather than throw.
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
expect(screen.getByText("No agents yet.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun agent pour le moment.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1071,7 +1071,7 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
|
||||
source: "mcp",
|
||||
});
|
||||
|
||||
const badge = await screen.findByLabelText("delegation source mcp");
|
||||
const badge = await screen.findByLabelText("source de délégation mcp");
|
||||
expect(badge.textContent?.toLowerCase()).toContain("mcp");
|
||||
});
|
||||
|
||||
@ -1086,7 +1086,7 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
|
||||
source: "file",
|
||||
});
|
||||
|
||||
const badge = await screen.findByLabelText("delegation source file");
|
||||
const badge = await screen.findByLabelText("source de délégation file");
|
||||
expect(badge.textContent?.toLowerCase()).toContain("file");
|
||||
});
|
||||
|
||||
@ -1103,8 +1103,8 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
|
||||
|
||||
// Give the effect a tick; the map must stay empty → no badge for any source.
|
||||
await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("delegation source mcp")).toBeNull();
|
||||
expect(screen.queryByLabelText("delegation source file")).toBeNull();
|
||||
expect(screen.queryByLabelText("source de délégation mcp")).toBeNull();
|
||||
expect(screen.queryByLabelText("source de délégation file")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not badge an agent whose id never matched a processed requester", async () => {
|
||||
@ -1120,6 +1120,6 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("delegation source mcp")).toBeNull();
|
||||
expect(screen.queryByLabelText("source de délégation mcp")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -168,7 +168,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("resume Architect"));
|
||||
fireEvent.click(screen.getByLabelText("reprendre Architect"));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
const [projectId, agentId, options] = launchSpy.mock.calls[0];
|
||||
@ -197,7 +197,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
|
||||
"relance à neuf",
|
||||
);
|
||||
// And the primary button reads "Relancer à neuf" (FR), not "Reprendre".
|
||||
const btn = screen.getByLabelText("resume Legacy");
|
||||
const btn = screen.getByLabelText("reprendre Legacy");
|
||||
expect(btn.textContent).toBe("Relancer à neuf");
|
||||
|
||||
fireEvent.click(btn);
|
||||
@ -215,7 +215,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("new conversation Architect"));
|
||||
fireEvent.click(screen.getByLabelText("nouvelle conversation Architect"));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
// setCellConversation cleared BEFORE the launch.
|
||||
@ -238,7 +238,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("ignore Doomed"));
|
||||
fireEvent.click(screen.getByLabelText("ignorer Doomed"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("resume-row-a4")).toBeNull(),
|
||||
@ -255,7 +255,7 @@ describe("ResumeProjectPanel — global actions", () => {
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("resume all agents"));
|
||||
fireEvent.click(screen.getByLabelText("reprendre tous les agents"));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(2));
|
||||
const launchedAgentIds = launchSpy.mock.calls.map((c) => c[1]).sort();
|
||||
@ -273,7 +273,7 @@ describe("ResumeProjectPanel — global actions", () => {
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("ignore all agents"));
|
||||
fireEvent.click(screen.getByLabelText("ignorer tous les agents"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("resume-project-panel")).toBeNull(),
|
||||
|
||||
@ -23,39 +23,42 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { AppExitWorkGuardDetail, AppExitWorkGuardState } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, zIndex } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
type Translator = (french: string, english: string) => string;
|
||||
|
||||
const MAX_DETAIL_LINES = 5;
|
||||
|
||||
/** The narrative body sentence, per carnet #83's exact wording rules. */
|
||||
function bodyText(state: AppExitWorkGuardState): string {
|
||||
function bodyText(state: AppExitWorkGuardState, tr: Translator): string {
|
||||
if (state.totalWorkCount === 1) {
|
||||
return "1 travail actif sera interrompu si vous quittez IdeA maintenant.";
|
||||
return tr("1 travail actif sera interrompu si vous quittez IdeA maintenant.", "1 active job will be interrupted if you quit IdeA now.");
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (state.busyAgentCount > 0) {
|
||||
parts.push(
|
||||
state.busyAgentCount === 1
|
||||
? "1 agent travaille encore"
|
||||
: `${state.busyAgentCount} agents travaillent encore`,
|
||||
? tr("1 agent travaille encore", "1 agent is still working")
|
||||
: tr(`${state.busyAgentCount} agents travaillent encore`, `${state.busyAgentCount} agents are still working`),
|
||||
);
|
||||
}
|
||||
if (state.activeBackgroundTaskCount > 0) {
|
||||
parts.push(
|
||||
state.activeBackgroundTaskCount === 1
|
||||
? "1 tâche de fond est encore active"
|
||||
: `${state.activeBackgroundTaskCount} tâches de fond sont encore actives`,
|
||||
? tr("1 tâche de fond est encore active", "1 background task is still active")
|
||||
: tr(`${state.activeBackgroundTaskCount} tâches de fond sont encore actives`, `${state.activeBackgroundTaskCount} background tasks are still active`),
|
||||
);
|
||||
}
|
||||
return `${parts.join(" et ")}. Ces travaux seront interrompus si vous quittez IdeA maintenant.`;
|
||||
return tr(`${parts.join(" et ")}. Ces travaux seront interrompus si vous quittez IdeA maintenant.`, `${parts.join(" and ")}. This work will be interrupted if you quit IdeA now.`);
|
||||
}
|
||||
|
||||
/** One compact detail line, per carnet #83's exact line format. */
|
||||
function detailLine(detail: AppExitWorkGuardDetail): string {
|
||||
function detailLine(detail: AppExitWorkGuardDetail, tr: Translator): string {
|
||||
if (detail.kind === "busyAgent") {
|
||||
return `Agent ${detail.agentName} — ${detail.projectName}`;
|
||||
return `${tr("Agent", "Agent")} ${detail.agentName} — ${detail.projectName}`;
|
||||
}
|
||||
const shortTaskId = detail.taskId.slice(0, 8);
|
||||
return `Tâche de fond ${shortTaskId} — ${detail.projectName}`;
|
||||
return `${tr("Tâche de fond", "Background task")} ${shortTaskId} — ${detail.projectName}`;
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
@ -69,6 +72,7 @@ const TITLE_ID = "app-exit-confirm-title";
|
||||
const DESC_ID = "app-exit-confirm-desc";
|
||||
|
||||
export function AppExitConfirmDialog() {
|
||||
const { tr } = useI18n();
|
||||
const { system } = useGateways();
|
||||
const [guard, setGuard] = useState<AppExitWorkGuardState | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
@ -178,33 +182,33 @@ export function AppExitConfirmDialog() {
|
||||
className="flex w-full max-w-[520px] min-w-[440px] flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
|
||||
>
|
||||
<h3 id={TITLE_ID} className="text-sm font-semibold text-content">
|
||||
Du travail est encore en cours
|
||||
{tr("Du travail est encore en cours", "Work is still in progress")}
|
||||
</h3>
|
||||
<p id={DESC_ID} className="text-sm text-content">
|
||||
{bodyText(guard)}
|
||||
{bodyText(guard, tr)}
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Annulez la fermeture pour laisser les agents et les tâches se terminer.
|
||||
{tr("Annulez la fermeture pour laisser les agents et les tâches se terminer.", "Cancel closing to let agents and tasks finish.")}
|
||||
</p>
|
||||
|
||||
{shown.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 rounded-md bg-canvas p-2 text-xs text-muted">
|
||||
{shown.map((detail, i) => (
|
||||
<li key={i}>{`• ${detailLine(detail)}`}</li>
|
||||
<li key={i}>{`• ${detailLine(detail, tr)}`}</li>
|
||||
))}
|
||||
{remaining > 0 && <li>+ {remaining} autre{remaining > 1 ? "s" : ""}</li>}
|
||||
{remaining > 0 && <li>{tr(`+ ${remaining} autre${remaining > 1 ? "s" : ""}`, `+ ${remaining} more`)}</li>}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs.
|
||||
{tr("IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs.", "IdeA could not quit cleanly. Try again or check the logs.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button ref={cancelRef} size="sm" variant="ghost" disabled={closing} onClick={cancel}>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -213,7 +217,7 @@ export function AppExitConfirmDialog() {
|
||||
loading={closing}
|
||||
onClick={() => void confirmExit()}
|
||||
>
|
||||
{closing ? "Fermeture…" : "Quitter quand même"}
|
||||
{closing ? tr("Fermeture…", "Closing…") : tr("Quitter quand même", "Quit anyway")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -82,7 +82,7 @@ describe("ConversationViewer", () => {
|
||||
expect(screen.getByText("deuxième")).toBeTruthy();
|
||||
// Role labels are rendered.
|
||||
expect(screen.getAllByText(/· Prompt/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/· Response/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/· Réponse/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("titles the header User ↔ Agent and resolves the agent name from work-state", async () => {
|
||||
@ -95,7 +95,7 @@ describe("ConversationViewer", () => {
|
||||
});
|
||||
renderViewer({ conversation: gw, workState });
|
||||
|
||||
expect(await screen.findByText("User ↔ Architecte")).toBeTruthy();
|
||||
expect(await screen.findByText("Utilisateur ↔ Architecte")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("titles the header Agent ↔ Agent for a delegation thread", async () => {
|
||||
@ -119,7 +119,7 @@ describe("ConversationViewer", () => {
|
||||
]);
|
||||
renderViewer({ conversation: gw });
|
||||
|
||||
const toggle = await screen.findByRole("button", { name: /Tool activity/ });
|
||||
const toggle = await screen.findByRole("button", { name: /Activité d'outil/ });
|
||||
// Collapsed by default ⇒ the tool text is hidden.
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByText("détails outillés secrets")).toBeNull();
|
||||
|
||||
@ -21,6 +21,9 @@ import type {
|
||||
import { Button, EntryTimestamp, Spinner, cn } from "@/shared";
|
||||
import { useProjectWorkState } from "@/features/workstate";
|
||||
import { useConversationThread } from "./useConversationThread";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
type Translator = (french: string, english: string) => string;
|
||||
|
||||
export interface ConversationViewerProps {
|
||||
projectId: string;
|
||||
@ -38,14 +41,14 @@ function partyKey(source: TurnSource): string {
|
||||
return source.kind === "human" ? "human" : `agent:${source.agentId}`;
|
||||
}
|
||||
|
||||
function roleLabel(role: TurnView["role"]): string {
|
||||
function roleLabel(role: TurnView["role"], tr: Translator): string {
|
||||
switch (role) {
|
||||
case "prompt":
|
||||
return "Prompt";
|
||||
return tr("Prompt", "Prompt");
|
||||
case "response":
|
||||
return "Response";
|
||||
return tr("Réponse", "Response");
|
||||
case "toolActivity":
|
||||
return "Tool";
|
||||
return tr("Outil", "Tool");
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,13 +83,13 @@ function summaryHasVisibleContent(summary: ConversationWorkSummary): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function activeConversations(state: ProjectWorkState | null): ConversationActivity[] {
|
||||
function activeConversations(state: ProjectWorkState | null, tr: Translator): ConversationActivity[] {
|
||||
const out: ConversationActivity[] = [];
|
||||
for (const agent of state?.agents ?? []) {
|
||||
for (const ticket of agent.tickets ?? []) {
|
||||
out.push({
|
||||
conversationId: ticket.conversationId,
|
||||
label: `${agentLabel(agent)} · ${ticket.taskPreview || "ticket actif"}`,
|
||||
label: `${agentLabel(agent)} · ${ticket.taskPreview || tr("ticket actif", "active ticket")}`,
|
||||
priority: ticket.status === "inProgress" ? 0 : 1,
|
||||
});
|
||||
}
|
||||
@ -109,27 +112,26 @@ function activeConversations(state: ProjectWorkState | null): ConversationActivi
|
||||
function emptyThreadState(
|
||||
conversationId: string,
|
||||
state: ProjectWorkState | null,
|
||||
tr: Translator,
|
||||
): EmptyThreadState {
|
||||
const summaries = state?.conversations ?? [];
|
||||
const currentSummary = summaries.find(
|
||||
(summary) => summary.conversationId === conversationId,
|
||||
);
|
||||
const activities = activeConversations(state);
|
||||
const activities = activeConversations(state, tr);
|
||||
const currentActivity = activities.find(
|
||||
(activity) => activity.conversationId === conversationId,
|
||||
);
|
||||
if (currentActivity) {
|
||||
return {
|
||||
message: "Agent actif, aucun message visible pour le moment.",
|
||||
detail:
|
||||
"Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.",
|
||||
message: tr("Agent actif, aucun message visible pour le moment.", "Agent active, no messages visible yet."),
|
||||
detail: tr("Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.", "This is the active thread, but its transcript has not provided a displayable turn yet."),
|
||||
};
|
||||
}
|
||||
if (currentSummary && summaryHasVisibleContent(currentSummary)) {
|
||||
return {
|
||||
message: "Activité détectée, transcript pas encore synchronisé.",
|
||||
detail:
|
||||
"Le résumé Work state connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.",
|
||||
message: tr("Activité détectée, transcript pas encore synchronisé.", "Activity detected, transcript not synchronized yet."),
|
||||
detail: tr("Le résumé Travail connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.", "The Work summary knows this thread, but paginated reading does not return any turns yet."),
|
||||
};
|
||||
}
|
||||
|
||||
@ -138,11 +140,11 @@ function emptyThreadState(
|
||||
);
|
||||
if (otherActivity) {
|
||||
return {
|
||||
message: "Aucun tour sur ce fil.",
|
||||
detail: `L'activité en cours semble être sur un autre fil: ${otherActivity.label}.`,
|
||||
message: tr("Aucun tour sur ce fil.", "No turns in this thread."),
|
||||
detail: tr(`L'activité en cours semble être sur un autre fil : ${otherActivity.label}.`, `The current activity seems to be in another thread: ${otherActivity.label}.`),
|
||||
action: {
|
||||
conversationId: otherActivity.conversationId,
|
||||
label: "Ouvrir le fil actif",
|
||||
label: tr("Ouvrir le fil actif", "Open active thread"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -152,17 +154,17 @@ function emptyThreadState(
|
||||
);
|
||||
if (busyAgent) {
|
||||
return {
|
||||
message: "Agent actif sans message visible.",
|
||||
detail:
|
||||
"Work state signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.",
|
||||
message: tr("Agent actif sans message visible.", "Agent active with no visible message."),
|
||||
detail: tr("L'état de travail signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.", "Work state reports activity, but no active ticket or thread is associated with the current snapshot yet."),
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "Aucun tour pour le moment." };
|
||||
return { message: tr("Aucun tour pour le moment.", "No turns yet.") };
|
||||
}
|
||||
|
||||
/** One collapsible tool-activity turn (dimmed, folded by default). */
|
||||
function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
|
||||
const { tr } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className={cn("flex flex-col", sideClass)}>
|
||||
@ -174,8 +176,8 @@ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span aria-hidden>{open ? "▾" : "▸"}</span>
|
||||
Tool activity
|
||||
<span className="text-faint">· {turn.textLen} chars</span>
|
||||
{tr("Activité d'outil", "Tool activity")}
|
||||
<span className="text-faint">· {turn.textLen} {tr("car.", "chars")}</span>
|
||||
<EntryTimestamp atMs={turn.atMs} />
|
||||
</button>
|
||||
{open && (
|
||||
@ -201,6 +203,7 @@ export function ConversationViewer({
|
||||
onClose,
|
||||
onOpenConversation,
|
||||
}: ConversationViewerProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useConversationThread(projectId, conversationId);
|
||||
// Reuse the already-loaded work-state inventory to resolve agent names.
|
||||
const work = useProjectWorkState(projectId);
|
||||
@ -223,25 +226,25 @@ export function ConversationViewer({
|
||||
const side: PartyMeta["side"] = order.length === 0 ? "left" : "right";
|
||||
const label =
|
||||
turn.source.kind === "human"
|
||||
? "User"
|
||||
? tr("Utilisateur", "User")
|
||||
: agentNames.get(turn.source.agentId) ??
|
||||
`Agent ${shortId(turn.source.agentId)}`;
|
||||
meta.set(key, { label, side });
|
||||
order.push(key);
|
||||
}
|
||||
return { order, meta };
|
||||
}, [vm.turns, agentNames]);
|
||||
}, [vm.turns, agentNames, tr]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
const labels = parties.order.map((k) => parties.meta.get(k)?.label ?? "?");
|
||||
if (labels.length >= 2) return `${labels[0]} ↔ ${labels[1]}`;
|
||||
if (labels.length === 1) return labels[0];
|
||||
return "Conversation";
|
||||
}, [parties]);
|
||||
return tr("Conversation", "Conversation");
|
||||
}, [parties, tr]);
|
||||
|
||||
const emptyState = useMemo(
|
||||
() => emptyThreadState(conversationId, work.state),
|
||||
[conversationId, work.state],
|
||||
() => emptyThreadState(conversationId, work.state, tr),
|
||||
[conversationId, work.state, tr],
|
||||
);
|
||||
|
||||
function sideOf(source: TurnSource): "left" | "right" {
|
||||
@ -270,10 +273,10 @@ export function ConversationViewer({
|
||||
onClick={() => void vm.refreshTail()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
{tr("Rafraîchir", "Refresh")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
← Retour aux terminaux
|
||||
{tr("← Retour aux terminaux", "← Back to terminals")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -284,14 +287,14 @@ export function ConversationViewer({
|
||||
role="alert"
|
||||
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
<span>Impossible de recharger la conversation.</span>
|
||||
<span>{tr("Impossible de recharger la conversation.", "Unable to reload the conversation.")}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.refreshTail()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Réessayer
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -306,7 +309,7 @@ export function ConversationViewer({
|
||||
onClick={() => void vm.loadOlder()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Charger les tours précédents
|
||||
{tr("Charger les tours précédents", "Load previous turns")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -314,7 +317,7 @@ export function ConversationViewer({
|
||||
{vm.busy && vm.turns.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Chargement du fil…</span>
|
||||
<span>{tr("Chargement du fil…", "Loading thread…")}</span>
|
||||
</div>
|
||||
) : vm.turns.length === 0 ? (
|
||||
<div className="max-w-xl text-sm text-muted">
|
||||
@ -359,7 +362,7 @@ export function ConversationViewer({
|
||||
<span className="font-medium text-content">
|
||||
{party?.label ?? "?"}
|
||||
</span>
|
||||
<span>· {roleLabel(turn.role)}</span>
|
||||
<span>· {roleLabel(turn.role, tr)}</span>
|
||||
<EntryTimestamp atMs={turn.atMs} />
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button, zIndex } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
@ -31,6 +32,7 @@ export function ConfirmDialog({
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const { tr } = useI18n();
|
||||
|
||||
// Mount-only: a confirmation opens focused on the safe choice. Depending on
|
||||
// `onCancel` here would re-steal focus whenever the parent re-renders (#17).
|
||||
@ -63,7 +65,7 @@ export function ConfirmDialog({
|
||||
<p className="text-sm text-muted">{body}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@ -24,6 +24,7 @@ import { formatLastSeen, formatPairedAt } from "./formatActivity";
|
||||
import { PairingCodePanel } from "./PairingCodePanel";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { DEVICE_NAME_MAX } from "@/features/web/deviceName";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface DevicesScreenProps {
|
||||
/**
|
||||
@ -35,6 +36,7 @@ interface DevicesScreenProps {
|
||||
}
|
||||
|
||||
export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useDevices();
|
||||
const [confirmRevoke, setConfirmRevoke] = useState<PairedDevice | null>(null);
|
||||
const [confirmRevokeAll, setConfirmRevokeAll] = useState(false);
|
||||
@ -50,13 +52,13 @@ export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) {
|
||||
<section data-testid="devices-screen" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h2 className="text-base font-semibold tracking-tight">Appareils</h2>
|
||||
<h2 className="text-base font-semibold tracking-tight">{tr("Appareils", "Devices")}</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Les appareils autorisés à accéder à cette instance IdeA.
|
||||
{tr("Les appareils autorisés à accéder à cette instance IdeA.", "Devices allowed to access this IdeA instance.")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void vm.generateCode()} loading={vm.generating}>
|
||||
Appairer
|
||||
{tr("Appairer", "Pair")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -78,10 +80,10 @@ export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) {
|
||||
|
||||
{devices === null ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||
<Spinner size={12} /> Chargement des appareils…
|
||||
<Spinner size={12} /> {tr("Chargement des appareils…", "Loading devices…")}
|
||||
</span>
|
||||
) : devices.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun appareil appairé.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun appareil appairé.", "No paired devices.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border" data-testid="device-list">
|
||||
{devices.map((device) => (
|
||||
@ -105,20 +107,20 @@ export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) {
|
||||
{devices !== null && devices.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmRevokeAll(true)}>
|
||||
Révoquer tous les appareils
|
||||
{tr("Révoquer tous les appareils", "Revoke all devices")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmRevoke && (
|
||||
<ConfirmDialog
|
||||
title="Révoquer cet appareil ?"
|
||||
title={tr("Révoquer cet appareil ?", "Revoke this device?")}
|
||||
body={
|
||||
confirmRevoke.isCurrentDevice
|
||||
? "Il devra être appairé à nouveau pour accéder à IdeA. Vous serez déconnecté immédiatement."
|
||||
: "Il devra être appairé à nouveau pour accéder à IdeA."
|
||||
? tr("Il devra être appairé à nouveau pour accéder à IdeA. Vous serez déconnecté immédiatement.", "It must be paired again to access IdeA. You will be signed out immediately.")
|
||||
: tr("Il devra être appairé à nouveau pour accéder à IdeA.", "It must be paired again to access IdeA.")
|
||||
}
|
||||
confirmLabel="Révoquer"
|
||||
confirmLabel={tr("Révoquer", "Revoke")}
|
||||
busy={vm.busy}
|
||||
onConfirm={async () => {
|
||||
const target = confirmRevoke;
|
||||
@ -131,11 +133,11 @@ export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) {
|
||||
|
||||
{confirmRevokeAll && (
|
||||
<ConfirmDialog
|
||||
title="Révoquer tous les appareils ?"
|
||||
title={tr("Révoquer tous les appareils ?", "Revoke all devices?")}
|
||||
// Stated without needing the list: the point of this action is the lost
|
||||
// phone you cannot enumerate calmly.
|
||||
body="Tous les appareils, y compris celui-ci, devront être appairés à nouveau. Vous serez déconnecté immédiatement."
|
||||
confirmLabel="Tout révoquer"
|
||||
body={tr("Tous les appareils, y compris celui-ci, devront être appairés à nouveau. Vous serez déconnecté immédiatement.", "All devices, including this one, must be paired again. You will be signed out immediately.")}
|
||||
confirmLabel={tr("Tout révoquer", "Revoke all")}
|
||||
danger
|
||||
busy={vm.busy}
|
||||
onConfirm={async () => {
|
||||
@ -167,6 +169,7 @@ function DeviceRow({
|
||||
onRename: (name: string) => Promise<void>;
|
||||
onRevoke: () => void;
|
||||
}) {
|
||||
const { tr, language } = useI18n();
|
||||
return (
|
||||
<li
|
||||
data-testid="device-row"
|
||||
@ -185,12 +188,12 @@ function DeviceRow({
|
||||
<span className="truncate text-sm font-medium text-content">{device.name}</span>
|
||||
{device.isCurrentDevice && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
|
||||
Cet appareil
|
||||
{tr("Cet appareil", "This device")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{formatLastSeen(device.lastSeenAtMs)}</span>
|
||||
<span className="text-[0.7rem] text-faint">{formatPairedAt(device.pairedAtMs)}</span>
|
||||
<span className="text-xs text-muted">{formatLastSeen(device.lastSeenAtMs, new Date(), language)}</span>
|
||||
<span className="text-[0.7rem] text-faint">{formatPairedAt(device.pairedAtMs, new Date(), language)}</span>
|
||||
</div>
|
||||
<RowMenu deviceName={device.name} onRename={onStartRename} onRevoke={onRevoke} />
|
||||
</div>
|
||||
@ -209,6 +212,7 @@ function RenameForm({
|
||||
onSubmit: (name: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [name, setName] = useState(device.name);
|
||||
const trimmed = name.trim();
|
||||
|
||||
@ -220,7 +224,7 @@ function RenameForm({
|
||||
if (trimmed.length > 0) void onSubmit(trimmed);
|
||||
}}
|
||||
>
|
||||
<Field label="Nom de l'appareil" hideLabel>
|
||||
<Field label={tr("Nom de l'appareil", "Device name")} hideLabel>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
@ -236,10 +240,10 @@ function RenameForm({
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={trimmed.length === 0}>
|
||||
Renommer
|
||||
{tr("Renommer", "Rename")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -260,6 +264,7 @@ function RowMenu({
|
||||
onRename: () => void;
|
||||
onRevoke: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -277,7 +282,7 @@ function RowMenu({
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`Actions pour ${deviceName}`}
|
||||
aria-label={tr(`Actions pour ${deviceName}`, `Actions for ${deviceName}`)}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-raised hover:text-content"
|
||||
>
|
||||
@ -297,7 +302,7 @@ function RowMenu({
|
||||
}}
|
||||
className="px-3 py-1.5 text-left text-sm text-content transition-colors hover:bg-canvas"
|
||||
>
|
||||
Renommer
|
||||
{tr("Renommer", "Rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -308,7 +313,7 @@ function RowMenu({
|
||||
}}
|
||||
className="px-3 py-1.5 text-left text-sm text-danger transition-colors hover:bg-canvas"
|
||||
>
|
||||
Révoquer
|
||||
{tr("Révoquer", "Revoke")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
|
||||
import type { PairingCode } from "@/domain";
|
||||
import { Button, Panel } from "@/shared";
|
||||
import { formatExpiresIn } from "./formatActivity";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface PairingCodePanelProps {
|
||||
code: PairingCode;
|
||||
@ -46,14 +47,15 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodePanelProps) {
|
||||
const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs));
|
||||
const { tr, language } = useI18n();
|
||||
const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs, new Date(), language));
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(formatExpiresIn(code.expiresAtMs));
|
||||
const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs)), 1000);
|
||||
setRemaining(formatExpiresIn(code.expiresAtMs, new Date(), language));
|
||||
const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs, new Date(), language)), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [code.expiresAtMs]);
|
||||
}, [code.expiresAtMs, language]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopied(false);
|
||||
@ -71,16 +73,16 @@ export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodeP
|
||||
<Panel data-testid="pairing-code-panel" className="flex flex-col gap-3">
|
||||
{expired ? (
|
||||
<>
|
||||
<p className="text-sm text-muted">Ce code a expiré.</p>
|
||||
<p className="text-sm text-muted">{tr("Ce code a expiré.", "This code has expired.")}</p>
|
||||
<div>
|
||||
<Button size="sm" onClick={onRegenerate}>
|
||||
Générer un nouveau code
|
||||
{tr("Générer un nouveau code", "Generate a new code")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted">Saisissez ce code sur le nouvel appareil.</p>
|
||||
<p className="text-sm text-muted">{tr("Saisissez ce code sur le nouvel appareil.", "Enter this code on the new device.")}</p>
|
||||
<p
|
||||
data-testid="pairing-code-value"
|
||||
// The accessible name is the plain code: a screen reader must hear
|
||||
@ -102,13 +104,13 @@ export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodeP
|
||||
void copyToClipboard(code.code).then((ok) => setCopied(ok))
|
||||
}
|
||||
>
|
||||
{copied ? "Copié" : "Copier"}
|
||||
{copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
|
||||
</Button>
|
||||
<span className="text-xs text-faint" data-testid="pairing-code-countdown">
|
||||
{remaining}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" className="ml-auto" onClick={onDismiss}>
|
||||
Fermer
|
||||
{tr("Fermer", "Close")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -18,15 +18,15 @@ function startOfDay(d: Date): number {
|
||||
}
|
||||
|
||||
/** `14:32` in 24-hour form. */
|
||||
function timeOfDay(d: Date): string {
|
||||
return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
function timeOfDay(d: Date, language: "fr" | "en" = "fr"): string {
|
||||
return d.toLocaleTimeString(language === "fr" ? "fr-FR" : "en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** `12 juil.`, with the year appended once it is no longer the current one. */
|
||||
export function shortDate(ms: number, now: Date = new Date()): string {
|
||||
export function shortDate(ms: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string {
|
||||
const d = new Date(ms);
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return d.toLocaleDateString("fr-FR", {
|
||||
return d.toLocaleDateString(language === "fr" ? "fr-FR" : "en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
@ -38,22 +38,22 @@ export function shortDate(ms: number, now: Date = new Date()): string {
|
||||
* a short date. Days are compared as calendar days, not 24-hour spans — 23:59
|
||||
* yesterday reads "Hier", not "Aujourd'hui".
|
||||
*/
|
||||
export function formatLastSeen(ms: number, now: Date = new Date()): string {
|
||||
export function formatLastSeen(ms: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string {
|
||||
const delta = now.getTime() - ms;
|
||||
// A clock skew that puts the server slightly ahead reads as "now", not as a
|
||||
// date in the future.
|
||||
if (delta < JUST_NOW_MS) return "Actif à l'instant";
|
||||
if (delta < JUST_NOW_MS) return language === "fr" ? "Actif à l'instant" : "Active just now";
|
||||
|
||||
const today = startOfDay(now);
|
||||
const day = startOfDay(new Date(ms));
|
||||
if (day === today) return `Aujourd'hui à ${timeOfDay(new Date(ms))}`;
|
||||
if (day === today - 86_400_000) return "Hier";
|
||||
return shortDate(ms, now);
|
||||
if (day === today) return language === "fr" ? `Aujourd'hui à ${timeOfDay(new Date(ms), language)}` : `Today at ${timeOfDay(new Date(ms), language)}`;
|
||||
if (day === today - 86_400_000) return language === "fr" ? "Hier" : "Yesterday";
|
||||
return shortDate(ms, now, language);
|
||||
}
|
||||
|
||||
/** Secondary line: `Appairé le 12 juil.` */
|
||||
export function formatPairedAt(ms: number, now: Date = new Date()): string {
|
||||
return `Appairé le ${shortDate(ms, now)}`;
|
||||
export function formatPairedAt(ms: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string {
|
||||
return language === "fr" ? `Appairé le ${shortDate(ms, now, language)}` : `Paired on ${shortDate(ms, now, language)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -61,10 +61,10 @@ export function formatPairedAt(ms: number, now: Date = new Date()): string {
|
||||
* the last minute so the panel stays honest as it runs out. `null` once expired
|
||||
* — the caller shows the expiry state instead.
|
||||
*/
|
||||
export function formatExpiresIn(expiresAtMs: number, now: Date = new Date()): string | null {
|
||||
export function formatExpiresIn(expiresAtMs: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string | null {
|
||||
const remainingMs = expiresAtMs - now.getTime();
|
||||
if (remainingMs <= 0) return null;
|
||||
const seconds = Math.ceil(remainingMs / 1000);
|
||||
if (seconds < 60) return `Expire dans ${seconds} s`;
|
||||
return `Expire dans ${Math.ceil(seconds / 60)} min`;
|
||||
if (seconds < 60) return language === "fr" ? `Expire dans ${seconds} s` : `Expires in ${seconds}s`;
|
||||
return language === "fr" ? `Expire dans ${Math.ceil(seconds / 60)} min` : `Expires in ${Math.ceil(seconds / 60)} min`;
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { Button, Field, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { useEmbedder } from "./useEmbedder";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** Default endpoint placeholder for a local embedding server (Ollama). */
|
||||
const LOCAL_SERVER_PLACEHOLDER = "http://localhost:11434/api/embeddings";
|
||||
@ -146,6 +147,7 @@ function strategyEnabled(
|
||||
}
|
||||
|
||||
export function EmbedderSettings() {
|
||||
const { tr } = useI18n();
|
||||
const vm = useEmbedder();
|
||||
|
||||
// Selection cursor; pre-selected on the recommended ONNX engine. Writing
|
||||
@ -192,17 +194,17 @@ export function EmbedderSettings() {
|
||||
const dimensionNum = Number.parseInt(draft.dimension, 10);
|
||||
const validation = useMemo(() => {
|
||||
if (selectedStrategy === "none") return { ok: true as const };
|
||||
if (!draft.id.trim()) return { ok: false as const, msg: "Id is required." };
|
||||
if (!draft.id.trim()) return { ok: false as const, msg: tr("L'identifiant est requis.", "Id is required.") };
|
||||
if (!draft.name.trim())
|
||||
return { ok: false as const, msg: "Name is required." };
|
||||
return { ok: false as const, msg: tr("Le nom est requis.", "Name is required.") };
|
||||
if (!Number.isFinite(dimensionNum) || dimensionNum <= 0)
|
||||
return { ok: false as const, msg: "Dimension must be a positive number." };
|
||||
return { ok: false as const, msg: tr("La dimension doit être un nombre positif.", "Dimension must be a positive number.") };
|
||||
if (selectedStrategy === "localServer" || selectedStrategy === "api") {
|
||||
if (!draft.endpoint.trim())
|
||||
return { ok: false as const, msg: "Endpoint is required." };
|
||||
return { ok: false as const, msg: tr("Le point de terminaison est requis.", "Endpoint is required.") };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}, [selectedStrategy, draft, dimensionNum]);
|
||||
}, [selectedStrategy, draft, dimensionNum, tr]);
|
||||
|
||||
function toProfile(): EmbedderProfile {
|
||||
return {
|
||||
@ -244,7 +246,7 @@ export function EmbedderSettings() {
|
||||
const hasActive = vm.active !== null;
|
||||
|
||||
return (
|
||||
<Panel title="Memory / Embedder" className="flex flex-col gap-0">
|
||||
<Panel title={tr("Mémoire / Embedder", "Memory / Embedder")} className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
@ -260,26 +262,26 @@ export function EmbedderSettings() {
|
||||
className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted"
|
||||
data-testid="embedder-restart-note"
|
||||
>
|
||||
The embedder change takes effect at the next app start.
|
||||
{tr("Le changement d'embedder prendra effet au prochain démarrage de l'application.", "The embedder change takes effect at the next app start.")}
|
||||
</p>
|
||||
|
||||
{/* ── Active tier (current) ── */}
|
||||
<p className="text-xs text-faint" data-testid="embedder-active">
|
||||
Current:{" "}
|
||||
{tr("Actuel :", "Current:")}{" "}
|
||||
{hasActive ? (
|
||||
<span className="text-content">
|
||||
Vector — {vm.active!.strategy}
|
||||
{vm.active!.model ? ` ${vm.active!.model}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content">Naïve (None)</span>
|
||||
<span className="text-content">{tr("Naïve (aucun)", "Naive (none)")}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ── Strategy chooser ── */}
|
||||
<fieldset className="flex flex-col gap-2" aria-label="embedder strategy">
|
||||
<fieldset className="flex flex-col gap-2" aria-label={tr("stratégie d'embedder", "embedder strategy")}>
|
||||
<legend className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Strategy
|
||||
{tr("Stratégie", "Strategy")}
|
||||
</legend>
|
||||
{STRATEGY_OPTIONS.map((opt) => {
|
||||
const enabled = strategyEnabled(opt.strategy, flags);
|
||||
@ -303,17 +305,20 @@ export function EmbedderSettings() {
|
||||
checked={selectedKey === opt.key}
|
||||
disabled={!enabled}
|
||||
onChange={() => setSelectedKey(opt.key)}
|
||||
aria-label={opt.label}
|
||||
aria-label={tr(
|
||||
opt.key === "none" ? "Aucun (rappel naïf)" : opt.key === "localServer" ? "Ollama (serveur local)" : opt.key === "custom" ? "Personnalisé" : opt.label,
|
||||
opt.label,
|
||||
)}
|
||||
/>
|
||||
<span className="text-content">{opt.label}</span>
|
||||
<span className="text-content">{tr(opt.key === "none" ? "Aucun (rappel naïf)" : opt.key === "localServer" ? "Ollama (serveur local)" : opt.key === "custom" ? "Personnalisé" : opt.label, opt.label)}</span>
|
||||
{recommended && (
|
||||
<span className="rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
Recommended
|
||||
{tr("Recommandé", "Recommended")}
|
||||
</span>
|
||||
)}
|
||||
{!enabled && (
|
||||
<span className="text-[11px] text-faint">
|
||||
not available in this build
|
||||
{tr("non disponible dans cette version", "not available in this build")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
@ -326,11 +331,11 @@ export function EmbedderSettings() {
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* localOnnx → model picker + auto dimension */}
|
||||
{selectedStrategy === "localOnnx" && (
|
||||
<Field label="ONNX model">
|
||||
<Field label={tr("Modèle ONNX", "ONNX model")}>
|
||||
{({ id }) => (
|
||||
<select
|
||||
id={id}
|
||||
aria-label="onnx model"
|
||||
aria-label={tr("modèle ONNX", "ONNX model")}
|
||||
className={selectClass}
|
||||
value={draft.onnxEngineId}
|
||||
disabled={vm.busy}
|
||||
@ -339,7 +344,7 @@ export function EmbedderSettings() {
|
||||
{recommendedOnnx.map((eng) => (
|
||||
<option key={eng.id} value={eng.id}>
|
||||
{eng.displayName}
|
||||
{eng.recommended ? " — recommended" : ""} (dim{" "}
|
||||
{eng.recommended ? tr(" — recommandé", " — recommended") : ""} (dim{" "}
|
||||
{eng.dimension}, ~{eng.approxSizeMb} MB)
|
||||
</option>
|
||||
))}
|
||||
@ -351,11 +356,11 @@ export function EmbedderSettings() {
|
||||
{/* localServer / api → endpoint */}
|
||||
{(selectedStrategy === "localServer" ||
|
||||
selectedStrategy === "api") && (
|
||||
<Field label="Endpoint">
|
||||
<Field label={tr("Point de terminaison", "Endpoint")}>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="endpoint"
|
||||
aria-label={tr("point de terminaison", "endpoint")}
|
||||
placeholder={
|
||||
selectedStrategy === "localServer"
|
||||
? LOCAL_SERVER_PLACEHOLDER
|
||||
@ -372,13 +377,13 @@ export function EmbedderSettings() {
|
||||
{/* api → apiKeyEnv (variable NAME, never the key) */}
|
||||
{selectedStrategy === "api" && (
|
||||
<Field
|
||||
label="API key environment variable"
|
||||
hint="Name of the env var holding the key — never the key itself."
|
||||
label={tr("Variable d'environnement de la clé API", "API key environment variable")}
|
||||
hint={tr("Nom de la variable contenant la clé — jamais la clé elle-même.", "Name of the env var holding the key — never the key itself.")}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="api key env var"
|
||||
aria-label={tr("variable d'environnement de la clé API", "API key environment variable")}
|
||||
placeholder="OPENAI_API_KEY"
|
||||
aria-describedby={describedBy}
|
||||
value={draft.apiKeyEnv}
|
||||
@ -391,12 +396,12 @@ export function EmbedderSettings() {
|
||||
|
||||
{/* model (localServer / api / custom) */}
|
||||
{selectedStrategy !== "localOnnx" && (
|
||||
<Field label="Model">
|
||||
<Field label={tr("Modèle", "Model")}>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="model"
|
||||
placeholder="model name"
|
||||
aria-label={tr("modèle", "model")}
|
||||
placeholder={tr("nom du modèle", "model name")}
|
||||
value={draft.model}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ model: e.target.value })}
|
||||
@ -407,13 +412,13 @@ export function EmbedderSettings() {
|
||||
|
||||
{/* dimension — auto-filled for ONNX, editable everywhere */}
|
||||
<Field
|
||||
label="Dimension"
|
||||
label={tr("Dimension", "Dimension")}
|
||||
error={!validation.ok ? validation.msg : undefined}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="dimension"
|
||||
aria-label={tr("dimension", "dimension")}
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.dimension}
|
||||
@ -425,11 +430,11 @@ export function EmbedderSettings() {
|
||||
</Field>
|
||||
|
||||
{/* id / name (custom & advanced) */}
|
||||
<Field label="Profile id">
|
||||
<Field label={tr("Identifiant du profil", "Profile id")}>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="profile id"
|
||||
aria-label={tr("identifiant du profil", "profile id")}
|
||||
placeholder="my-embedder"
|
||||
value={draft.id}
|
||||
disabled={vm.busy}
|
||||
@ -437,12 +442,12 @@ export function EmbedderSettings() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Profile name">
|
||||
<Field label={tr("Nom du profil", "Profile name")}>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="profile name"
|
||||
placeholder="My embedder"
|
||||
aria-label={tr("nom du profil", "profile name")}
|
||||
placeholder={tr("Mon embedder", "My embedder")}
|
||||
value={draft.name}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
@ -459,20 +464,20 @@ export function EmbedderSettings() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
aria-label="save embedder"
|
||||
aria-label={tr("enregistrer l'embedder", "save embedder")}
|
||||
disabled={vm.busy || !validation.ok}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
Save
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="back to none"
|
||||
aria-label={tr("revenir à aucun", "back to none")}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void handleBackToNone()}
|
||||
>
|
||||
Back to None
|
||||
{tr("Revenir à aucun", "Back to none")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -45,7 +45,7 @@ function renderEmbedder(
|
||||
async function waitForEmbedderIdle() {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "back to none" }) as HTMLButtonElement)
|
||||
(screen.getByRole("button", { name: "revenir à aucun" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
@ -64,11 +64,11 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
expect(screen.getByTestId("embedder-strategy-custom")).toBeTruthy();
|
||||
|
||||
// ONNX carries the "Recommended" badge.
|
||||
expect(screen.getByText("Recommended")).toBeTruthy();
|
||||
expect(screen.getByText("Recommandé")).toBeTruthy();
|
||||
|
||||
// The change-takes-effect notice is visible.
|
||||
expect(screen.getByTestId("embedder-restart-note").textContent).toMatch(
|
||||
/next app start/i,
|
||||
/prochain démarrage/i,
|
||||
);
|
||||
});
|
||||
|
||||
@ -78,7 +78,7 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
|
||||
// ONNX is the default cursor → its model picker is shown.
|
||||
const modelSelect = (await screen.findByLabelText(
|
||||
"onnx model",
|
||||
"modèle ONNX",
|
||||
)) as HTMLSelectElement;
|
||||
expect(modelSelect.value).toBe("e5-small");
|
||||
|
||||
@ -94,31 +94,31 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
|
||||
// localServer → endpoint, no apiKeyEnv.
|
||||
fireEvent.click(
|
||||
screen.getByRole("radio", { name: "Ollama (local server)" }),
|
||||
screen.getByRole("radio", { name: "Ollama (serveur local)" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByLabelText("endpoint")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("api key env var")).toBeNull();
|
||||
await waitFor(() => expect(screen.getByLabelText("point de terminaison")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("variable d'environnement de la clé API")).toBeNull();
|
||||
|
||||
// api → endpoint + apiKeyEnv with an explicit "env var" label.
|
||||
fireEvent.click(screen.getByRole("radio", { name: "API" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("variable d'environnement de la clé API")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText(/environment variable/i)).toBeTruthy();
|
||||
expect(screen.getByText(/never the key itself/i)).toBeTruthy();
|
||||
expect(screen.getByText("Nom de la variable contenant la clé — jamais la clé elle-même.")).toBeTruthy();
|
||||
expect(screen.getByText(/jamais la clé elle-même/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders no conditional fields for None", async () => {
|
||||
renderEmbedder();
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "None (naïve recall)" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Aucun (rappel naïf)" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("dimension")).toBeNull(),
|
||||
);
|
||||
expect(screen.queryByLabelText("onnx model")).toBeNull();
|
||||
expect(screen.queryByLabelText("modèle ONNX")).toBeNull();
|
||||
// No Save button in None mode (only "Back to None").
|
||||
expect(screen.queryByRole("button", { name: "save embedder" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "enregistrer l'embedder" })).toBeNull();
|
||||
});
|
||||
|
||||
it("disables strategies whose build flag is false with a 'not available' note", async () => {
|
||||
@ -129,11 +129,11 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
name: "Local ONNX",
|
||||
}) as HTMLInputElement;
|
||||
const ollamaRadio = screen.getByRole("radio", {
|
||||
name: "Ollama (local server)",
|
||||
name: "Ollama (serveur local)",
|
||||
}) as HTMLInputElement;
|
||||
const apiRadio = screen.getByRole("radio", { name: "API" }) as HTMLInputElement;
|
||||
const noneRadio = screen.getByRole("radio", {
|
||||
name: "None (naïve recall)",
|
||||
name: "Aucun (rappel naïf)",
|
||||
}) as HTMLInputElement;
|
||||
|
||||
expect(onnxRadio.disabled).toBe(true);
|
||||
@ -144,7 +144,7 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
|
||||
// The honesty note appears.
|
||||
expect(
|
||||
screen.getAllByText("not available in this build").length,
|
||||
screen.getAllByText("non disponible dans cette version").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@ -153,7 +153,7 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "enregistrer l'embedder" }));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
|
||||
const saved = spy.mock.calls[0][0];
|
||||
@ -174,12 +174,12 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "API" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("variable d'environnement de la clé API")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("api key env var"), {
|
||||
fireEvent.change(screen.getByLabelText("variable d'environnement de la clé API"), {
|
||||
target: { value: "MY_KEY_VAR" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "enregistrer l'embedder" }));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
const saved = spy.mock.calls.at(-1)![0];
|
||||
@ -196,7 +196,7 @@ describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
const spy = vi.spyOn(embedder, "deleteEmbedderProfile");
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "back to none" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "revenir à aucun" }));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("onnx-local"));
|
||||
expect(await embedder.listEmbedderProfiles()).toHaveLength(0);
|
||||
@ -220,10 +220,10 @@ describe("MemoryPanel active tier (transparency)", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("shows 'Naïve (None)' when no embedder is configured", async () => {
|
||||
it("shows 'Naïve (aucun)' when no embedder is configured", async () => {
|
||||
renderPanel(new MockEmbedderGateway());
|
||||
const tier = await screen.findByTestId("memory-tier");
|
||||
await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(None\)/));
|
||||
await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(aucun\)/));
|
||||
});
|
||||
|
||||
it("shows 'Vector — <strategy> <model>' when an embedder is configured", async () => {
|
||||
|
||||
@ -49,7 +49,7 @@ function renderWizard(
|
||||
}
|
||||
|
||||
async function waitForLoaded() {
|
||||
await screen.findByLabelText("first run setup");
|
||||
await screen.findByLabelText("Configuration initiale");
|
||||
}
|
||||
|
||||
describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
@ -64,11 +64,11 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
// Gemini/Aider are no longer proposed for selection.
|
||||
expect(screen.queryByText("Gemini CLI")).toBeNull();
|
||||
expect(screen.queryByText("Aider")).toBeNull();
|
||||
expect(screen.queryByLabelText("Aider command")).toBeNull();
|
||||
expect(screen.queryByLabelText("Commande de Aider")).toBeNull();
|
||||
|
||||
// Commands are editable inputs, pre-filled.
|
||||
const claudeCmd = screen.getByLabelText(
|
||||
"Claude Code command",
|
||||
"Commande de Claude Code",
|
||||
) as HTMLInputElement;
|
||||
expect(claudeCmd.value).toBe("claude");
|
||||
});
|
||||
@ -77,7 +77,7 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
const cmd = screen.getByLabelText(
|
||||
"OpenAI Codex CLI command",
|
||||
"Commande de OpenAI Codex CLI",
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(cmd, { target: { value: "codex-2" } });
|
||||
expect(cmd.value).toBe("codex-2");
|
||||
@ -88,18 +88,18 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
const args = screen.getByLabelText("Claude Code args") as HTMLInputElement;
|
||||
const args = screen.getByLabelText("Arguments de Claude Code") as HTMLInputElement;
|
||||
fireEvent.change(args, {
|
||||
target: { value: "--model claude-sonnet-4-5 --verbose " },
|
||||
});
|
||||
expect(args.value).toBe("--model claude-sonnet-4-5 --verbose ");
|
||||
|
||||
fireEvent.blur(args);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const [saved] = await profile.listProfiles();
|
||||
@ -111,16 +111,16 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Detect installed CLIs" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Détecter les CLI installées" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
).toMatch(/installed/);
|
||||
screen.getByLabelText("Disponibilité de Claude Code").textContent,
|
||||
).toMatch(/installée/);
|
||||
});
|
||||
expect(
|
||||
screen.getByLabelText("OpenAI Codex CLI availability").textContent,
|
||||
).toMatch(/not found/);
|
||||
screen.getByLabelText("Disponibilité de OpenAI Codex CLI").textContent,
|
||||
).toMatch(/introuvable/);
|
||||
});
|
||||
|
||||
// §17.3/D7 — anti-regression guard. The custom-profile block was removed
|
||||
@ -135,8 +135,8 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Add custom profile" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByLabelText("custom name")).toBeNull();
|
||||
expect(screen.queryByLabelText("custom command")).toBeNull();
|
||||
expect(screen.queryByLabelText("Nom de custom")).toBeNull();
|
||||
expect(screen.queryByLabelText("Commande de custom")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-detects on open and pre-checks only installed CLIs", async () => {
|
||||
@ -146,16 +146,16 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
// The mock reports only `claude` as installed → only it is pre-checked.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser OpenAI Codex CLI") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
// Availability was filled automatically, without clicking the button.
|
||||
expect(
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
).toMatch(/installed/);
|
||||
screen.getByLabelText("Disponibilité de Claude Code").textContent,
|
||||
).toMatch(/installée/);
|
||||
});
|
||||
|
||||
it("finishing persists the auto-selected (installed) profiles and closes the first run", async () => {
|
||||
@ -163,15 +163,15 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
||||
expect(screen.queryByLabelText("Configuration initiale")).toBeNull(),
|
||||
);
|
||||
|
||||
// Only the installed CLI was pre-selected, so only it is persisted.
|
||||
@ -185,13 +185,13 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
// Keep Codex too (not installed, unchecked by default).
|
||||
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByLabelText("Utiliser OpenAI Codex CLI"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -206,7 +206,7 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
|
||||
// No wizard section ever appears.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
||||
expect(screen.queryByLabelText("Configuration initiale")).toBeNull(),
|
||||
);
|
||||
expect(container.querySelector("section")).toBeNull();
|
||||
});
|
||||
@ -218,24 +218,24 @@ describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", ()
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
|
||||
fireEvent.click(screen.getByLabelText("Utiliser OpenAI Codex CLI"));
|
||||
|
||||
const row = within(
|
||||
screen.getByLabelText("use OpenAI Codex CLI").closest("li")!,
|
||||
screen.getByLabelText("Utiliser OpenAI Codex CLI").closest("li")!,
|
||||
);
|
||||
expect(row.queryByLabelText("OpenAI Codex CLI provider")).toBeNull();
|
||||
expect(row.queryByLabelText("Fournisseur de OpenAI Codex CLI")).toBeNull();
|
||||
expect(row.queryByLabelText("OpenAI Codex CLI provider search")).toBeNull();
|
||||
expect(row.queryByLabelText("OpenAI Codex CLI api key")).toBeNull();
|
||||
expect(row.queryByLabelText("Clé API de OpenAI Codex CLI")).toBeNull();
|
||||
|
||||
fireEvent.change(row.getByLabelText("OpenAI Codex CLI model"), {
|
||||
fireEvent.change(row.getByLabelText("Modèle de OpenAI Codex CLI"), {
|
||||
target: { value: "gpt-5-codex" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -251,20 +251,20 @@ describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", ()
|
||||
await waitForLoaded();
|
||||
|
||||
const claudeToggle = screen.getByLabelText(
|
||||
"use Claude Code",
|
||||
"Utiliser Claude Code",
|
||||
) as HTMLInputElement;
|
||||
if (!claudeToggle.checked) fireEvent.click(claudeToggle);
|
||||
|
||||
const row = within(claudeToggle.closest("li")!);
|
||||
expect(row.queryByLabelText("Claude Code provider")).toBeNull();
|
||||
expect(row.queryByLabelText("Fournisseur de Claude Code")).toBeNull();
|
||||
expect(row.queryByLabelText("Claude Code provider search")).toBeNull();
|
||||
expect(row.queryByLabelText("Claude Code api key")).toBeNull();
|
||||
expect(row.queryByLabelText("Clé API de Claude Code")).toBeNull();
|
||||
|
||||
fireEvent.change(row.getByLabelText("Claude Code model"), {
|
||||
fireEvent.change(row.getByLabelText("Modèle de Claude Code"), {
|
||||
target: { value: "claude-sonnet-4-5" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -302,18 +302,18 @@ describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", ()
|
||||
await waitForLoaded();
|
||||
|
||||
const row = within(
|
||||
screen.getByLabelText("use Codex configured").closest("li")!,
|
||||
screen.getByLabelText("Utiliser Codex configured").closest("li")!,
|
||||
);
|
||||
expect(row.queryByLabelText("Codex configured provider")).toBeNull();
|
||||
expect(row.queryByLabelText("Codex configured api key")).toBeNull();
|
||||
expect(row.queryByLabelText("Fournisseur de Codex configured")).toBeNull();
|
||||
expect(row.queryByLabelText("Clé API de Codex configured")).toBeNull();
|
||||
expect(
|
||||
(row.getByLabelText("Codex configured model") as HTMLInputElement).value,
|
||||
(row.getByLabelText("Modèle de Codex configured") as HTMLInputElement).value,
|
||||
).toBe("gpt-5-mini");
|
||||
|
||||
fireEvent.change(row.getByLabelText("Codex configured model"), {
|
||||
fireEvent.change(row.getByLabelText("Modèle de Codex configured"), {
|
||||
target: { value: "gpt-5" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const [saved] = await profile.listProfiles();
|
||||
@ -334,20 +334,20 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
expect(screen.getByText(OPENCODE)).toBeTruthy();
|
||||
// The OpenCode fields are rendered, pre-filled from the reference.
|
||||
expect(
|
||||
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
|
||||
(screen.getByLabelText(`URL de base de ${OPENCODE}`) as HTMLInputElement).value,
|
||||
).toBe("http://localhost:8080/v1");
|
||||
expect(
|
||||
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
|
||||
(screen.getByLabelText(`Modèle de ${OPENCODE}`) as HTMLInputElement).value,
|
||||
).toBe("qwen3-coder-30b");
|
||||
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
|
||||
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
|
||||
expect(screen.queryByLabelText("URL de base de Claude Code")).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
|
||||
const apiKey = screen.getByLabelText(`Clé API de ${OPENCODE}`) as HTMLInputElement;
|
||||
expect(apiKey.value).toBe("sk-no-key");
|
||||
|
||||
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
|
||||
@ -363,7 +363,7 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
|
||||
const baseUrl = screen.getByLabelText(`URL de base de ${OPENCODE}`);
|
||||
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
|
||||
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
||||
});
|
||||
@ -372,7 +372,7 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
|
||||
target: { value: "" },
|
||||
});
|
||||
expect(screen.getByText(/model is required/i)).toBeTruthy();
|
||||
@ -383,18 +383,18 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
// Select the local model and edit its base URL, model and API key.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
|
||||
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`URL de base de ${OPENCODE}`), {
|
||||
target: { value: "http://localhost:9090/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
|
||||
target: { value: "qwen3-coder-14b" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
|
||||
target: { value: "sk-local" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -410,12 +410,12 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -437,12 +437,12 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`URL de base de ${OPENCODE}`)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
|
||||
|
||||
expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull();
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
expect(screen.queryByLabelText(`URL de base de ${OPENCODE}`)).toBeNull();
|
||||
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
|
||||
});
|
||||
|
||||
it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => {
|
||||
@ -480,10 +480,10 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
// reference also renders (edit mode dedups by id only), with its own
|
||||
// Local/Cloud segmented control.
|
||||
const row = within(
|
||||
screen.getByLabelText("use Claude via OpenCode").closest("li")!,
|
||||
screen.getByLabelText("Utiliser Claude via OpenCode").closest("li")!,
|
||||
);
|
||||
expect(
|
||||
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
|
||||
row.getByRole("radio", { name: "Fournisseur cloud" }).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
@ -493,7 +493,7 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
),
|
||||
).toBe("false");
|
||||
expect(
|
||||
(row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement)
|
||||
(row.getByLabelText("Clé API de Claude via OpenCode") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("");
|
||||
});
|
||||
@ -501,13 +501,13 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
it("cascades provider ➜ model and disables Save until the API key is filled", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
|
||||
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
|
||||
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
|
||||
const modelSelect = screen.getByLabelText(`Modèle de ${OPENCODE}`) as HTMLButtonElement;
|
||||
expect(modelSelect.disabled).toBe(true);
|
||||
|
||||
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
||||
chooseDropdownOption(`Fournisseur de ${OPENCODE}`, "Anthropic");
|
||||
expect(modelSelect.disabled).toBe(false);
|
||||
|
||||
const saveButton = screen.getByRole("button", {
|
||||
@ -515,10 +515,10 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
|
||||
chooseDropdownOption(`Modèle de ${OPENCODE}`, "claude-sonnet-5");
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
|
||||
target: { value: "sk-ant-secret" },
|
||||
});
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
@ -527,13 +527,13 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
|
||||
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
||||
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
|
||||
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
|
||||
chooseDropdownOption(`Fournisseur de ${OPENCODE}`, "Anthropic");
|
||||
chooseDropdownOption(`Modèle de ${OPENCODE}`, "claude-sonnet-5");
|
||||
const apiKeyInput = screen.getByLabelText(
|
||||
`${OPENCODE} api key`,
|
||||
`Clé API de ${OPENCODE}`,
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } });
|
||||
|
||||
@ -553,17 +553,17 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
it("shows submit-time validation messages when provider/model/key are missing", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
|
||||
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
|
||||
|
||||
// Save stays disabled with no key, so drive validation via a filled key but
|
||||
// no provider/model to see the field-level messages fire on submit.
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
|
||||
target: { value: "sk-ant-secret" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("Le fournisseur est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -572,9 +572,9 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
const OPENCODE = "OpenCode + llama.cpp";
|
||||
|
||||
async function goToCloudCustomMode() {
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
|
||||
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
|
||||
chooseDropdownOption(`Fournisseur de ${OPENCODE}`, "Autre / personnalisé…");
|
||||
}
|
||||
|
||||
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
|
||||
@ -584,16 +584,16 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
|
||||
expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull();
|
||||
expect(
|
||||
screen.getByLabelText(`${OPENCODE} custom provider id`),
|
||||
screen.getByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement)
|
||||
(screen.getByLabelText(`Paquet npm personnalisé de ${OPENCODE}`) as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("@ai-sdk/openai-compatible");
|
||||
expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`URL de base personnalisée de ${OPENCODE}`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Modèle de ${OPENCODE}`)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByLabelText(`${OPENCODE} custom display name`),
|
||||
screen.getByLabelText(`Libellé personnalisé de ${OPENCODE}`),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -602,14 +602,14 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
await waitForLoaded();
|
||||
await goToCloudCustomMode();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
|
||||
target: { value: "sk-custom-secret" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("Le fournisseur est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("L’URL de base est obligatoire.")).toBeTruthy();
|
||||
// npm is pre-filled by default, so it doesn't fail validation here.
|
||||
expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull();
|
||||
});
|
||||
@ -619,23 +619,23 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
await waitForLoaded();
|
||||
await goToCloudCustomMode();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
|
||||
fireEvent.change(screen.getByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`), {
|
||||
target: { value: "mon-provider" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), {
|
||||
fireEvent.change(screen.getByLabelText(`Paquet npm personnalisé de ${OPENCODE}`), {
|
||||
target: { value: "@ai-sdk/openai-compatible" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
|
||||
fireEvent.change(screen.getByLabelText(`URL de base personnalisée de ${OPENCODE}`), {
|
||||
target: { value: "https://api.mon-provider.example/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
|
||||
target: { value: "mon-modele-1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), {
|
||||
fireEvent.change(screen.getByLabelText(`Libellé personnalisé de ${OPENCODE}`), {
|
||||
target: { value: "Mon Modèle" },
|
||||
});
|
||||
const apiKeyInput = screen.getByLabelText(
|
||||
`${OPENCODE} api key`,
|
||||
`Clé API de ${OPENCODE}`,
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(apiKeyInput, { target: { value: "sk-custom-secret" } });
|
||||
|
||||
@ -660,16 +660,16 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
await waitForLoaded();
|
||||
await goToCloudCustomMode();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
|
||||
fireEvent.change(screen.getByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`), {
|
||||
target: { value: "mon-provider" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
|
||||
fireEvent.change(screen.getByLabelText(`URL de base personnalisée de ${OPENCODE}`), {
|
||||
target: { value: "https://api.mon-provider.example/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
|
||||
target: { value: "mon-modele-1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
|
||||
target: { value: "sk-custom-secret" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
@ -681,17 +681,17 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
});
|
||||
});
|
||||
|
||||
it('"← Choisir un provider du catalogue" switches back to the cascade', async () => {
|
||||
it('"← Choisir un fournisseur du catalogue" switches back to the cascade', async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
await goToCloudCustomMode();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "← Choisir un provider du catalogue" }),
|
||||
screen.getByRole("button", { name: "← Choisir un fournisseur du catalogue" }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull();
|
||||
expect(screen.getByLabelText(`Fournisseur de ${OPENCODE}`)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => {
|
||||
@ -731,34 +731,34 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
await waitForLoaded();
|
||||
|
||||
const row = within(
|
||||
screen.getByLabelText("use Mon provider via OpenCode").closest("li")!,
|
||||
screen.getByLabelText("Utiliser Mon provider via OpenCode").closest("li")!,
|
||||
);
|
||||
expect(
|
||||
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
|
||||
row.getByRole("radio", { name: "Fournisseur cloud" }).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(
|
||||
(row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement)
|
||||
(row.getByLabelText("Identifiant du fournisseur personnalisé de Mon provider via OpenCode") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("mon-provider");
|
||||
expect(
|
||||
(row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement)
|
||||
(row.getByLabelText("Paquet npm personnalisé de Mon provider via OpenCode") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("@ai-sdk/openai-compatible");
|
||||
expect(
|
||||
(row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement)
|
||||
(row.getByLabelText("URL de base personnalisée de Mon provider via OpenCode") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("https://api.mon-provider.example/v1");
|
||||
expect(
|
||||
(row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value,
|
||||
(row.getByLabelText("Modèle de Mon provider via OpenCode") as HTMLInputElement).value,
|
||||
).toBe("mon-modele-1");
|
||||
expect(
|
||||
(row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement)
|
||||
(row.getByLabelText("Libellé personnalisé de Mon provider via OpenCode") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Mon Modèle");
|
||||
expect(
|
||||
(row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement)
|
||||
(row.getByLabelText("Clé API de Mon provider via OpenCode") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("");
|
||||
});
|
||||
@ -776,17 +776,17 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
expect(screen.getAllByText(OPENCODE).length).toBe(1);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
|
||||
);
|
||||
|
||||
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
|
||||
const added = await screen.findByLabelText(`use ${CLONE1}`);
|
||||
const added = await screen.findByLabelText(`Utiliser ${CLONE1}`);
|
||||
expect((added as HTMLInputElement).checked).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
|
||||
(screen.getByLabelText(`URL de base de ${CLONE1}`) as HTMLInputElement).value,
|
||||
).toBe("http://localhost:8080/v1");
|
||||
// Its name is editable per profile (identity is the id, not the name).
|
||||
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Nom de ${CLONE1}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists two distinct OpenCode profiles when both are selected", async () => {
|
||||
@ -794,21 +794,21 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
// Select the seed row and edit it.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
|
||||
target: { value: "qwen3-coder-14b" },
|
||||
});
|
||||
|
||||
// Add a second one and give it a different endpoint.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
|
||||
await screen.findByLabelText(`Utiliser ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`URL de base de ${CLONE1}`), {
|
||||
target: { value: "http://localhost:9191/v1" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -828,22 +828,22 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
// Edit the seed row, then duplicate it.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
|
||||
target: { value: "qwen3-coder-7b" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
|
||||
screen.getByRole("button", { name: `Dupliquer ${OPENCODE}` }),
|
||||
);
|
||||
|
||||
const dupName = `${OPENCODE} (copy)`;
|
||||
await screen.findByLabelText(`use ${dupName}`);
|
||||
const dupName = `${OPENCODE} (copie)`;
|
||||
await screen.findByLabelText(`Utiliser ${dupName}`);
|
||||
// The duplicate inherits the edited model.
|
||||
expect(
|
||||
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
|
||||
(screen.getByLabelText(`Modèle de ${dupName}`) as HTMLInputElement).value,
|
||||
).toBe("qwen3-coder-7b");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const models = saved
|
||||
@ -858,14 +858,14 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
|
||||
await screen.findByLabelText(`Utiliser ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`Nom de ${CLONE1}`), {
|
||||
target: { value: "Fast local model" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
@ -878,20 +878,20 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
await screen.findByLabelText(`Utiliser ${CLONE1}`);
|
||||
|
||||
// Reasoning defaults on (backend effective default true); turn it off.
|
||||
const reasoning = screen.getByLabelText(
|
||||
`${CLONE1} reasoning`,
|
||||
`Raisonnement de ${CLONE1}`,
|
||||
) as HTMLInputElement;
|
||||
expect(reasoning.checked).toBe(true);
|
||||
fireEvent.click(reasoning);
|
||||
// Attachments default off; turn them on.
|
||||
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
|
||||
fireEvent.click(screen.getByLabelText(`Pièces jointes de ${CLONE1}`));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
@ -922,18 +922,18 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
await screen.findByLabelText(`Utiliser ${CLONE1}`);
|
||||
|
||||
// The old free-text field is gone; a dropdown replaces it.
|
||||
expect(
|
||||
screen.queryByLabelText(`${CLONE1} local model server id`),
|
||||
).toBeNull();
|
||||
await screen.findByLabelText(`${CLONE1} local model server`);
|
||||
chooseDropdownOption(`${CLONE1} local model server`, /Local A/);
|
||||
await screen.findByLabelText(`Serveur de modèle local de ${CLONE1}`);
|
||||
chooseDropdownOption(`Serveur de modèle local de ${CLONE1}`, /Local A/);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
@ -960,13 +960,13 @@ describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
// The rows are rendered, so the blocking load is over: both actions are live.
|
||||
const save = screen.getByRole("button", { name: "Save and continue" });
|
||||
const detect = screen.getByRole("button", { name: "Detect installed CLIs" });
|
||||
const save = screen.getByRole("button", { name: "Enregistrer et continuer" });
|
||||
const detect = screen.getByRole("button", { name: "Détecter les CLI installées" });
|
||||
expect((save as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((detect as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// Detection is merely reported as in flight, never as a blocking state.
|
||||
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
|
||||
expect(screen.getByRole("status").textContent).toMatch(/détection/i);
|
||||
|
||||
// Re-probing by hand must not freeze them either.
|
||||
fireEvent.click(detect);
|
||||
@ -980,8 +980,8 @@ describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
|
||||
await waitForLoaded();
|
||||
|
||||
// Nothing got pre-checked (detection never answered): tick a profile by hand.
|
||||
fireEvent.click(screen.getByLabelText("use Claude Code"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByLabelText("Utiliser Claude Code"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
const saved = await profile.listProfiles();
|
||||
@ -993,7 +993,7 @@ describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
|
||||
try {
|
||||
renderWizard(new HangingDetectGateway());
|
||||
await waitForLoaded();
|
||||
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
|
||||
expect(screen.getByRole("status").textContent).toMatch(/détection/i);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1);
|
||||
@ -1023,7 +1023,7 @@ describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
||||
);
|
||||
// Give the async first-run state time to resolve to `false`.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
||||
expect(screen.queryByLabelText("Configuration initiale")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
@ -1035,7 +1035,7 @@ describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
||||
<FirstRunWizard forceOpen />
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
|
||||
expect(await screen.findByLabelText("Configuration initiale")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1090,13 +1090,13 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||
|
||||
// The configured OpenCode comes back checked, with its real endpoint/model.
|
||||
expect(
|
||||
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Local A") as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
|
||||
(screen.getByLabelText("URL de base de Local A") as HTMLInputElement).value,
|
||||
).toBe("http://localhost:9090/v1");
|
||||
expect(
|
||||
(screen.getByLabelText("Local A model") as HTMLInputElement).value,
|
||||
(screen.getByLabelText("Modèle de Local A") as HTMLInputElement).value,
|
||||
).toBe("qwen3-coder-14b");
|
||||
});
|
||||
|
||||
@ -1115,7 +1115,7 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||
expect(screen.getAllByText("OpenCode (edited)")).toHaveLength(1);
|
||||
expect(screen.queryByText("OpenCode + llama.cpp")).toBeNull();
|
||||
expect(
|
||||
(screen.getByLabelText("use OpenCode (edited)") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser OpenCode (edited)") as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@ -1131,16 +1131,16 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||
|
||||
// Both distinct profiles are present and pre-selected, none collapsed.
|
||||
expect(
|
||||
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Local A") as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText("use Local B") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Local B") as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
|
||||
(screen.getByLabelText("URL de base de Local A") as HTMLInputElement).value,
|
||||
).toBe("http://localhost:8080/v1");
|
||||
expect(
|
||||
(screen.getByLabelText("Local B base url") as HTMLInputElement).value,
|
||||
(screen.getByLabelText("URL de base de Local B") as HTMLInputElement).value,
|
||||
).toBe("http://localhost:9191/v1");
|
||||
});
|
||||
|
||||
@ -1154,7 +1154,7 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||
renderEdit(profile);
|
||||
await waitForLoaded();
|
||||
// Save straight away without touching anything.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -1176,8 +1176,8 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||
renderEdit(profile);
|
||||
await waitForLoaded();
|
||||
// Deselect Local B, then save — an explicit removal, not an omission.
|
||||
fireEvent.click(screen.getByLabelText("use Local B"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
fireEvent.click(screen.getByLabelText("Utiliser Local B"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
@ -1207,17 +1207,17 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||
// Wait until detection has filled availability (Claude reads as installed).
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
).toMatch(/installed/),
|
||||
screen.getByLabelText("Disponibilité de Claude Code").textContent,
|
||||
).toMatch(/installée/),
|
||||
);
|
||||
|
||||
// Selection is untouched by detection: the configured (uninstalled) Codex
|
||||
// stays checked; the installed Claude reference stays UNchecked.
|
||||
expect(
|
||||
(screen.getByLabelText("use Codex Configured") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Codex Configured") as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,6 +22,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
ModelServersPanel,
|
||||
useModelServers,
|
||||
@ -60,6 +61,7 @@ export function FirstRunWizard({
|
||||
/** Render the wizard even when it is no longer the first run. */
|
||||
forceOpen?: boolean;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
// Mode is explicit (ticket #44), driven by the entry point — never inferred
|
||||
// from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode,
|
||||
// which pre-loads and pre-selects the already-configured profiles.
|
||||
@ -77,14 +79,13 @@ export function FirstRunWizard({
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="first run setup"
|
||||
aria-label={tr("Configuration initiale", "First run setup")}
|
||||
className="border-primary/40"
|
||||
title={
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
|
||||
<h2 className="text-base font-semibold text-content">{tr("Bienvenue dans IdeA", "Welcome to IdeA")}</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Choose which AI CLIs to configure. Commands are pre-filled and
|
||||
editable.
|
||||
{tr("Choisissez les CLI d’IA à configurer. Les commandes sont préremplies et modifiables.", "Choose which AI CLIs to configure. Commands are pre-filled and editable.")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@ -104,11 +105,11 @@ export function FirstRunWizard({
|
||||
disabled={vm.busy}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Detect installed CLIs
|
||||
{tr("Détecter les CLI installées", "Detect installed CLIs")}
|
||||
</Button>
|
||||
{vm.detecting && (
|
||||
<span role="status" className="text-xs text-muted">
|
||||
Detecting…
|
||||
{tr("Détection…", "Detecting…")}
|
||||
</span>
|
||||
)}
|
||||
{/* F36: declare several local OpenCode profiles. Each click clones the
|
||||
@ -118,7 +119,7 @@ export function FirstRunWizard({
|
||||
disabled={vm.busy}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Add OpenCode profile
|
||||
{tr("Ajouter un profil OpenCode", "Add OpenCode profile")}
|
||||
</Button>
|
||||
</Toolbar>
|
||||
|
||||
@ -141,7 +142,7 @@ export function FirstRunWizard({
|
||||
entry.profile.structuredAdapter === "openCode"
|
||||
? () =>
|
||||
vm.addOpenCodeProfile({
|
||||
name: `${entry.profile.name} (copy)`,
|
||||
name: `${entry.profile.name} (${tr("copie", "copy")})`,
|
||||
opencode: entry.profile.opencode,
|
||||
})
|
||||
: undefined
|
||||
@ -152,7 +153,7 @@ export function FirstRunWizard({
|
||||
|
||||
<footer className="flex gap-2">
|
||||
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
|
||||
Save and continue
|
||||
{tr("Enregistrer et continuer", "Save and continue")}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
@ -181,6 +182,7 @@ function ProfileRow({
|
||||
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
|
||||
onDuplicate?: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const { profile, selected, available } = entry;
|
||||
const errors = validateProfile(profile);
|
||||
const isOpenCode = profile.structuredAdapter === "openCode";
|
||||
@ -203,13 +205,13 @@ function ProfileRow({
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
aria-label={`use ${profile.name}`}
|
||||
aria-label={tr(`Utiliser ${profile.name}`, `Use ${profile.name}`)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<strong className="text-sm text-content">{profile.name}</strong>
|
||||
</label>
|
||||
<span
|
||||
aria-label={`${profile.name} availability`}
|
||||
aria-label={tr(`Disponibilité de ${profile.name}`, `${profile.name} availability`)}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
available === null
|
||||
@ -219,21 +221,21 @@ function ProfileRow({
|
||||
: "text-danger",
|
||||
)}
|
||||
>
|
||||
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
|
||||
{available === null ? "—" : available ? tr("✓ installée", "✓ installed") : tr("✗ introuvable", "✗ not found")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onDuplicate && (
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`duplicate ${profile.name}`}
|
||||
aria-label={tr(`Dupliquer ${profile.name}`, `Duplicate ${profile.name}`)}
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
Duplicate
|
||||
{tr("Dupliquer", "Duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`remove ${profile.name}`}
|
||||
aria-label={tr(`Retirer ${profile.name}`, `Remove ${profile.name}`)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
@ -245,9 +247,9 @@ function ProfileRow({
|
||||
it is editable per profile (several local models coexist). */}
|
||||
{isOpenCode && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Name</Caption>
|
||||
<Caption>{tr("Nom", "Name")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} name`}
|
||||
aria-label={tr(`Nom de ${profile.name}`, `Name for ${profile.name}`)}
|
||||
value={profile.name}
|
||||
invalid={Boolean(errors.name)}
|
||||
onChange={(e) => onChange({ ...profile, name: e.target.value })}
|
||||
@ -257,9 +259,9 @@ function ProfileRow({
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Command</Caption>
|
||||
<Caption>{tr("Commande", "Command")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} command`}
|
||||
aria-label={tr(`Commande de ${profile.name}`, `Command for ${profile.name}`)}
|
||||
value={profile.command}
|
||||
invalid={Boolean(errors.command)}
|
||||
onChange={(e) => onChange({ ...profile, command: e.target.value })}
|
||||
@ -268,9 +270,9 @@ function ProfileRow({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Arguments</Caption>
|
||||
<Caption>{tr("Arguments", "Arguments")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} args`}
|
||||
aria-label={tr(`Arguments de ${profile.name}`, `Arguments for ${profile.name}`)}
|
||||
value={argsText}
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
onBlur={commitArgs}
|
||||
@ -307,13 +309,14 @@ function CliModelField({
|
||||
profile: AgentProfile;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={profile.model ?? ""}
|
||||
placeholder="Laisser vide pour le modèle par défaut de la CLI"
|
||||
placeholder={tr("Laisser vide pour le modèle par défaut de la CLI", "Leave empty for the CLI default model")}
|
||||
onChange={(e) => {
|
||||
const model = e.target.value.trim();
|
||||
onChange({
|
||||
@ -323,7 +326,7 @@ function CliModelField({
|
||||
}}
|
||||
/>
|
||||
<small className="text-xs text-faint">
|
||||
Optionnel. L'authentification Codex/Claude reste gérée par la CLI.
|
||||
{tr("Facultatif. L’authentification Codex/Claude reste gérée par la CLI.", "Optional. Codex/Claude authentication remains managed by the CLI.")}
|
||||
</small>
|
||||
</label>
|
||||
);
|
||||
@ -352,6 +355,7 @@ function HttpChatFields({
|
||||
errors: ProfileErrors;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const http = profile.chatHttp ?? defaultHttpChatConfig();
|
||||
const patch = (next: Partial<HttpChatConfig>) =>
|
||||
onChange({ ...profile, chatHttp: { ...http, ...next } });
|
||||
@ -359,13 +363,13 @@ function HttpChatFields({
|
||||
return (
|
||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Local / LAN model (OpenAI-compatible)
|
||||
{tr("Modèle local / réseau (compatible OpenAI)", "Local / LAN model (OpenAI-compatible)")}
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Endpoint</Caption>
|
||||
<Caption>{tr("Point d’accès", "Endpoint")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} endpoint`}
|
||||
aria-label={tr(`Point d’accès de ${profile.name}`, `Endpoint for ${profile.name}`)}
|
||||
value={http.endpoint}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
invalid={Boolean(errors.endpoint)}
|
||||
@ -377,9 +381,9 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Model</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={http.model}
|
||||
placeholder="qwen2.5-coder"
|
||||
invalid={Boolean(errors.model)}
|
||||
@ -389,11 +393,11 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>API key — environment variable name (never the key itself)</Caption>
|
||||
<Caption>{tr("Clé API — nom de la variable d’environnement (jamais la clé elle-même)", "API key — environment variable name (never the key itself)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} api key env`}
|
||||
aria-label={tr(`Variable de clé API de ${profile.name}`, `API key environment variable for ${profile.name}`)}
|
||||
value={http.apiKeyEnv ?? ""}
|
||||
placeholder="e.g. OPENAI_API_KEY (variable name, not the secret)"
|
||||
placeholder={tr("ex. OPENAI_API_KEY (nom de variable, pas le secret)", "e.g. OPENAI_API_KEY (variable name, not the secret)")}
|
||||
invalid={Boolean(errors.apiKeyEnv)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
@ -407,9 +411,9 @@ function HttpChatFields({
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Request timeout (ms)</Caption>
|
||||
<Caption>{tr("Délai de requête (ms)", "Request timeout (ms)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} request timeout`}
|
||||
aria-label={tr(`Délai de requête de ${profile.name}`, `Request timeout for ${profile.name}`)}
|
||||
value={http.requestTimeoutMs?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.requestTimeoutMs)}
|
||||
@ -421,9 +425,9 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Connect timeout (ms)</Caption>
|
||||
<Caption>{tr("Délai de connexion (ms)", "Connect timeout (ms)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} connect timeout`}
|
||||
aria-label={tr(`Délai de connexion de ${profile.name}`, `Connect timeout for ${profile.name}`)}
|
||||
value={http.connectTimeoutMs?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.connectTimeoutMs)}
|
||||
@ -435,9 +439,9 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Max tool iterations</Caption>
|
||||
<Caption>{tr("Nombre maximal d’itérations d’outils", "Max tool iterations")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} max tool iterations`}
|
||||
aria-label={tr(`Itérations d’outils maximales de ${profile.name}`, `Max tool iterations for ${profile.name}`)}
|
||||
value={http.maxToolIterations?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.maxToolIterations)}
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, IconButton, Input, SmallDropdown, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { ModelServerSelect } from "@/features/model-servers";
|
||||
import {
|
||||
defaultOpenCodeConfig,
|
||||
@ -84,6 +85,7 @@ export function OpenCodeModeFields({
|
||||
providerCatalog: OpenCodeProviderCatalog;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [mode, setMode] = useState<"local" | "cloud">(
|
||||
profile.opencodeProvider ? "cloud" : "local",
|
||||
);
|
||||
@ -103,13 +105,13 @@ export function OpenCodeModeFields({
|
||||
<div className="mt-1 flex flex-col gap-2">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="mode du profil OpenCode"
|
||||
aria-label={tr("Mode du profil OpenCode", "OpenCode profile mode")}
|
||||
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ id: "local", label: "Local (llama.cpp)" },
|
||||
{ id: "cloud", label: "Provider cloud" },
|
||||
{ id: "local", label: tr("Local (llama.cpp)", "Local (llama.cpp)") },
|
||||
{ id: "cloud", label: tr("Fournisseur cloud", "Cloud provider") },
|
||||
] as const
|
||||
).map((seg) => {
|
||||
const active = mode === seg.id;
|
||||
@ -177,6 +179,7 @@ function OpenCodeProviderFields({
|
||||
catalog: OpenCodeProviderCatalog;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const { profile: profileGateway } = useGateways();
|
||||
const existing = profile.opencodeProvider;
|
||||
const [mode, setMode] = useState<"catalog" | "custom">(
|
||||
@ -217,16 +220,16 @@ function OpenCodeProviderFields({
|
||||
async function save() {
|
||||
const errors: CloudFieldErrors = {};
|
||||
if (providerId.trim().length === 0) {
|
||||
errors.providerId = "Le provider est obligatoire.";
|
||||
errors.providerId = tr("Le fournisseur est obligatoire.", "Provider is required.");
|
||||
}
|
||||
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
|
||||
if (model.trim().length === 0) errors.model = tr("Le modèle est obligatoire.", "Model is required.");
|
||||
if (mode === "custom") {
|
||||
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
|
||||
if (customNpm.trim().length === 0) errors.npm = tr("Le paquet npm est obligatoire.", "npm package is required.");
|
||||
if (customBaseUrl.trim().length === 0) {
|
||||
errors.baseUrl = "L'URL de base est obligatoire.";
|
||||
errors.baseUrl = tr("L’URL de base est obligatoire.", "Base URL is required.");
|
||||
}
|
||||
}
|
||||
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
|
||||
if (apiKey.length === 0) errors.apiKey = tr("La clé API est obligatoire.", "API key is required.");
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
@ -263,7 +266,7 @@ function OpenCodeProviderFields({
|
||||
return (
|
||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Provider cloud (OpenCode)
|
||||
{tr("Fournisseur cloud (OpenCode)", "Cloud provider (OpenCode)")}
|
||||
</legend>
|
||||
|
||||
{saveError && (
|
||||
@ -273,15 +276,15 @@ function OpenCodeProviderFields({
|
||||
)}
|
||||
|
||||
{catalog.loading && (
|
||||
<p className="text-xs text-faint">Chargement des providers…</p>
|
||||
<p className="text-xs text-faint">{tr("Chargement des fournisseurs…", "Loading providers…")}</p>
|
||||
)}
|
||||
{catalog.error && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
Impossible de charger la liste des providers cloud.
|
||||
{tr("Impossible de charger la liste des fournisseurs cloud.", "Unable to load the cloud provider list.")}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
|
||||
Réessayer
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -289,19 +292,19 @@ function OpenCodeProviderFields({
|
||||
{mode === "catalog" && (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Provider</Caption>
|
||||
<Caption>{tr("Fournisseur", "Provider")}</Caption>
|
||||
{catalogReady && (
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`${profile.name} provider search`}
|
||||
placeholder="Rechercher un provider…"
|
||||
aria-label={tr(`Rechercher un fournisseur pour ${profile.name}`, `Search providers for ${profile.name}`)}
|
||||
placeholder={tr("Rechercher un fournisseur…", "Search providers…")}
|
||||
value={providerFilter}
|
||||
onChange={(e) => setProviderFilter(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
||||
/>
|
||||
)}
|
||||
<SmallDropdown
|
||||
aria-label={`${profile.name} provider`}
|
||||
aria-label={tr(`Fournisseur de ${profile.name}`, `Provider for ${profile.name}`)}
|
||||
value={providerId}
|
||||
disabled={!catalogReady}
|
||||
onChange={(v) => {
|
||||
@ -322,8 +325,8 @@ function OpenCodeProviderFields({
|
||||
{
|
||||
value: "",
|
||||
label: catalog.loading
|
||||
? "Chargement des providers…"
|
||||
: "Choisir un provider…",
|
||||
? tr("Chargement des fournisseurs…", "Loading providers…")
|
||||
: tr("Choisir un fournisseur…", "Choose a provider…"),
|
||||
disabled: true,
|
||||
},
|
||||
...filteredProviders.map((p) => ({
|
||||
@ -332,7 +335,7 @@ function OpenCodeProviderFields({
|
||||
})),
|
||||
{
|
||||
value: CUSTOM_PROVIDER_VALUE,
|
||||
label: "Autre / personnalisé…",
|
||||
label: tr("Autre / personnalisé…", "Other / custom…"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@ -342,9 +345,9 @@ function OpenCodeProviderFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<SmallDropdown
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={model}
|
||||
disabled={providerId.length === 0}
|
||||
onChange={(next) => {
|
||||
@ -357,7 +360,7 @@ function OpenCodeProviderFields({
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: providerId.length === 0 ? "—" : "Choisir un modèle…",
|
||||
label: providerId.length === 0 ? "—" : tr("Choisir un modèle…", "Choose a model…"),
|
||||
disabled: true,
|
||||
},
|
||||
...models.map((m) => ({ value: m, label: m })),
|
||||
@ -373,7 +376,7 @@ function OpenCodeProviderFields({
|
||||
{mode === "custom" && (
|
||||
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Provider personnalisé
|
||||
{tr("Fournisseur personnalisé", "Custom provider")}
|
||||
</legend>
|
||||
|
||||
<Button
|
||||
@ -387,15 +390,15 @@ function OpenCodeProviderFields({
|
||||
setFieldErrors({});
|
||||
}}
|
||||
>
|
||||
← Choisir un provider du catalogue
|
||||
← {tr("Choisir un fournisseur du catalogue", "Choose a catalog provider")}
|
||||
</Button>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Identifiant du provider</Caption>
|
||||
<Caption>{tr("Identifiant du fournisseur", "Provider ID")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} custom provider id`}
|
||||
aria-label={tr(`Identifiant du fournisseur personnalisé de ${profile.name}`, `Custom provider ID for ${profile.name}`)}
|
||||
value={providerId}
|
||||
placeholder="ex. mon-provider"
|
||||
placeholder={tr("ex. mon-fournisseur", "e.g. my-provider")}
|
||||
invalid={Boolean(fieldErrors.providerId)}
|
||||
onChange={(e) => {
|
||||
setProviderId(e.target.value);
|
||||
@ -408,9 +411,9 @@ function OpenCodeProviderFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Paquet npm</Caption>
|
||||
<Caption>{tr("Paquet npm", "npm package")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} custom npm package`}
|
||||
aria-label={tr(`Paquet npm personnalisé de ${profile.name}`, `Custom npm package for ${profile.name}`)}
|
||||
value={customNpm}
|
||||
placeholder={DEFAULT_CUSTOM_NPM}
|
||||
invalid={Boolean(fieldErrors.npm)}
|
||||
@ -425,11 +428,11 @@ function OpenCodeProviderFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>URL de base</Caption>
|
||||
<Caption>{tr("URL de base", "Base URL")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} custom base url`}
|
||||
aria-label={tr(`URL de base personnalisée de ${profile.name}`, `Custom base URL for ${profile.name}`)}
|
||||
value={customBaseUrl}
|
||||
placeholder="https://api.mon-provider.example/v1"
|
||||
placeholder="https://api.my-provider.example/v1"
|
||||
invalid={Boolean(fieldErrors.baseUrl)}
|
||||
onChange={(e) => {
|
||||
setCustomBaseUrl(e.target.value);
|
||||
@ -442,11 +445,11 @@ function OpenCodeProviderFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={model}
|
||||
placeholder="ex. mon-modele-1"
|
||||
placeholder={tr("ex. mon-modele-1", "e.g. my-model-1")}
|
||||
invalid={Boolean(fieldErrors.model)}
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
@ -459,11 +462,11 @@ function OpenCodeProviderFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Libellé du modèle (optionnel)</Caption>
|
||||
<Caption>{tr("Libellé du modèle (facultatif)", "Model label (optional)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} custom display name`}
|
||||
aria-label={tr(`Libellé personnalisé de ${profile.name}`, `Custom display name for ${profile.name}`)}
|
||||
value={customDisplayName}
|
||||
placeholder="ex. Mon modèle"
|
||||
placeholder={tr("ex. Mon modèle", "e.g. My model")}
|
||||
onChange={(e) => setCustomDisplayName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
@ -471,16 +474,16 @@ function OpenCodeProviderFields({
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Clé API</Caption>
|
||||
<Caption>{tr("Clé API", "API key")}</Caption>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
aria-label={`${profile.name} api key`}
|
||||
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
placeholder={
|
||||
isEditing
|
||||
? "Ressaisissez la clé API pour confirmer l'enregistrement"
|
||||
: "ex. sk-ant-…"
|
||||
? tr("Ressaisissez la clé API pour confirmer l’enregistrement", "Enter the API key again to confirm saving")
|
||||
: tr("ex. sk-ant-…", "e.g. sk-ant-…")
|
||||
}
|
||||
invalid={Boolean(fieldErrors.apiKey)}
|
||||
onChange={(e) => {
|
||||
@ -490,7 +493,7 @@ function OpenCodeProviderFields({
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label="afficher/masquer la clé API"
|
||||
aria-label={tr("Afficher ou masquer la clé API", "Show or hide API key")}
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
>
|
||||
{showKey ? "🙈" : "👁"}
|
||||
@ -500,13 +503,11 @@ function OpenCodeProviderFields({
|
||||
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
|
||||
)}
|
||||
<small className="text-xs text-faint">
|
||||
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
|
||||
chiffrée localement.
|
||||
{tr("Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée chiffrée localement.", "Never displayed or returned by IdeA after saving; stored locally in encrypted form.")}
|
||||
</small>
|
||||
{isEditing && (
|
||||
<small className="text-xs text-muted">
|
||||
Pour des raisons de sécurité, la clé n'est jamais réaffichée :
|
||||
ressaisissez-la à chaque modification de ce profil.
|
||||
{tr("Pour des raisons de sécurité, la clé n’est jamais réaffichée : ressaisissez-la à chaque modification de ce profil.", "For security, the key is never displayed again: enter it again whenever you edit this profile.")}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
@ -519,7 +520,7 @@ function OpenCodeProviderFields({
|
||||
onClick={() => void save()}
|
||||
className="w-fit"
|
||||
>
|
||||
Enregistrer
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
@ -537,6 +538,7 @@ function OpenCodeFields({
|
||||
servers: LocalModelServerConfig[];
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
||||
const patch = (next: Partial<OpenCodeConfig>) =>
|
||||
onChange({
|
||||
@ -548,13 +550,13 @@ function OpenCodeFields({
|
||||
return (
|
||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Local model (OpenCode + llama.cpp)
|
||||
{tr("Modèle local (OpenCode + llama.cpp)", "Local model (OpenCode + llama.cpp)")}
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Base URL</Caption>
|
||||
<Caption>{tr("URL de base", "Base URL")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} base url`}
|
||||
aria-label={tr(`URL de base de ${profile.name}`, `Base URL for ${profile.name}`)}
|
||||
value={opencode.baseURL}
|
||||
placeholder="http://localhost:8080/v1"
|
||||
invalid={Boolean(errors.baseURL)}
|
||||
@ -566,9 +568,9 @@ function OpenCodeFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Model</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={opencode.model}
|
||||
placeholder="qwen3-coder-30b"
|
||||
invalid={Boolean(errors.model)}
|
||||
@ -578,11 +580,11 @@ function OpenCodeFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
|
||||
<Caption>{tr("Clé API (facultative — un llama.cpp local n’en a pas besoin)", "API key (optional — a local llama.cpp needs none)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} api key`}
|
||||
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
|
||||
value={opencode.apiKey ?? ""}
|
||||
placeholder="e.g. sk-no-key"
|
||||
placeholder={tr("ex. sk-no-key", "e.g. sk-no-key")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ apiKey: v.length === 0 ? undefined : v });
|
||||
@ -594,32 +596,32 @@ function OpenCodeFields({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${profile.name} reasoning`}
|
||||
aria-label={tr(`Raisonnement de ${profile.name}`, `Reasoning for ${profile.name}`)}
|
||||
checked={opencode.reasoning ?? true}
|
||||
onChange={(e) => patch({ reasoning: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>Reasoning</Caption>
|
||||
<Caption>{tr("Raisonnement", "Reasoning")}</Caption>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${profile.name} attachment`}
|
||||
aria-label={tr(`Pièces jointes de ${profile.name}`, `Attachments for ${profile.name}`)}
|
||||
checked={opencode.attachment ?? false}
|
||||
onChange={(e) => patch({ attachment: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>Attachments</Caption>
|
||||
<Caption>{tr("Pièces jointes", "Attachments")}</Caption>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>
|
||||
Local model server (bind to a managed llama.cpp server, or none)
|
||||
{tr("Serveur de modèle local (lier à un serveur llama.cpp géré, ou aucun)", "Local model server (bind to a managed llama.cpp server, or none)")}
|
||||
</Caption>
|
||||
<ModelServerSelect
|
||||
ariaLabel={`${profile.name} local model server`}
|
||||
ariaLabel={tr(`Serveur de modèle local de ${profile.name}`, `Local model server for ${profile.name}`)}
|
||||
servers={servers}
|
||||
value={opencode.localModelServerId}
|
||||
onChange={(serverId) => patch({ localModelServerId: serverId })}
|
||||
|
||||
@ -30,7 +30,7 @@ function chooseDropdownOption(label: string | RegExp, optionName: string | RegEx
|
||||
async function waitReady() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
|
||||
(screen.getByRole("button", { name: "Créer un profil" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
@ -38,7 +38,7 @@ async function waitReady() {
|
||||
|
||||
async function createProfile() {
|
||||
const before = screen.queryAllByRole("listitem").length;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un profil" }));
|
||||
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
|
||||
}
|
||||
|
||||
@ -50,10 +50,10 @@ describe("ProfilesSettings", () => {
|
||||
await createProfile();
|
||||
await createProfile();
|
||||
let rows = screen.getAllByRole("listitem");
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/Nom du profil/i), {
|
||||
target: { value: "Codex mini" },
|
||||
});
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/Modèle du profil/i), {
|
||||
target: { value: "gpt-5-mini" },
|
||||
});
|
||||
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
|
||||
@ -63,10 +63,10 @@ describe("ProfilesSettings", () => {
|
||||
await createProfile();
|
||||
await createProfile();
|
||||
rows = screen.getAllByRole("listitem");
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/Nom du profil/i), {
|
||||
target: { value: "Claude Opus" },
|
||||
});
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/Modèle du profil/i), {
|
||||
target: { value: "claude-opus-4-8" },
|
||||
});
|
||||
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
|
||||
@ -96,7 +96,7 @@ describe("ProfilesSettings", () => {
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
|
||||
expect(saved.some((p) => p.name === "OpenAI Codex CLI copie copie")).toBe(true);
|
||||
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@ -114,7 +114,7 @@ describe("ProfilesSettings", () => {
|
||||
|
||||
await createProfile();
|
||||
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
|
||||
/modele du profil/,
|
||||
/Modèle du profil/i,
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
||||
expect(model.value).toBe("future-codex-model");
|
||||
@ -127,13 +127,13 @@ describe("ProfilesSettings", () => {
|
||||
await createProfile();
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
|
||||
const model = within(row).getByLabelText(/Modèle du profil/i) as HTMLInputElement;
|
||||
fireEvent.focus(model);
|
||||
|
||||
expect(await within(row).findByText("Compatible")).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByText(
|
||||
/Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./,
|
||||
/Compatible avec Codex CLI 0\.145\.0 d’après le catalogue local IdeA\./,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
@ -145,7 +145,7 @@ describe("ProfilesSettings", () => {
|
||||
expect(within(row).getByText("Inconnu")).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByText(
|
||||
/Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
|
||||
/Compatibilité inconnue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@ -156,7 +156,7 @@ describe("ProfilesSettings", () => {
|
||||
await createProfile();
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
|
||||
const model = within(row).getByLabelText(/Modèle du profil/i) as HTMLInputElement;
|
||||
fireEvent.change(model, { target: { value: "gpt-5-mini" } });
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
@ -192,20 +192,20 @@ describe("ProfilesSettings", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
|
||||
await waitReady();
|
||||
|
||||
expect(screen.getByRole("button", { name: "add model server" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Ajouter un serveur de modèles" })).toBeTruthy();
|
||||
expect(screen.getByText("Local A")).toBeTruthy();
|
||||
|
||||
await createProfile();
|
||||
await screen.findByLabelText(
|
||||
"OpenCode + llama.cpp copy local model server",
|
||||
"Serveur de modèle local de OpenCode + llama.cpp copie",
|
||||
);
|
||||
chooseDropdownOption(
|
||||
"OpenCode + llama.cpp copy local model server",
|
||||
"Serveur de modèle local de OpenCode + llama.cpp copie",
|
||||
/Local A/,
|
||||
);
|
||||
|
||||
const profileRow = screen
|
||||
.getByLabelText("OpenCode + llama.cpp copy local model server")
|
||||
.getByLabelText("Serveur de modèle local de OpenCode + llama.cpp copie")
|
||||
.closest("li");
|
||||
expect(profileRow).not.toBeNull();
|
||||
fireEvent.click(
|
||||
@ -222,12 +222,12 @@ describe("ProfilesSettings", () => {
|
||||
|
||||
fireEvent.click(
|
||||
within(profileRow as HTMLElement).getByRole("radio", {
|
||||
name: "Provider cloud",
|
||||
name: "Fournisseur cloud",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await within(profileRow as HTMLElement).findByLabelText(
|
||||
"OpenCode + llama.cpp copy provider",
|
||||
"Fournisseur de OpenCode + llama.cpp copie",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@ -242,17 +242,17 @@ describe("ProfilesSettings", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
|
||||
await waitReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un profil" }));
|
||||
|
||||
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copy");
|
||||
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copie");
|
||||
expect(name).toBeTruthy();
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copy");
|
||||
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copie");
|
||||
expect(saved.some((p) => p.structuredAdapter === "openCode")).toBe(true);
|
||||
});
|
||||
expect(cloneOpenCodeSpy).toHaveBeenCalledWith({
|
||||
name: "OpenCode + llama.cpp copy",
|
||||
name: "OpenCode + llama.cpp copie",
|
||||
opencode: expect.objectContaining({
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
model: "qwen3-coder-30b",
|
||||
@ -271,10 +271,10 @@ describe("ProfilesSettings", () => {
|
||||
await createProfile();
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy base url"), {
|
||||
fireEvent.change(within(row).getByLabelText("URL de base de OpenCode + llama.cpp copie"), {
|
||||
target: { value: "http://localhost:9090/v1" },
|
||||
});
|
||||
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy model"), {
|
||||
fireEvent.change(within(row).getByLabelText("Modèle de OpenCode + llama.cpp copie"), {
|
||||
target: { value: "local-special" },
|
||||
});
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
|
||||
@ -292,7 +292,7 @@ describe("ProfilesSettings", () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const copies = saved.filter((p) => p.structuredAdapter === "openCode");
|
||||
expect(copies).toHaveLength(2);
|
||||
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copy copy");
|
||||
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copie copie");
|
||||
expect(copies[1]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
|
||||
expect(copies[1]?.opencode?.model).toBe("local-special");
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
ModelServersPanel,
|
||||
useModelServers,
|
||||
@ -27,6 +28,7 @@ import { validateProfile } from "./profile";
|
||||
type ProfileTab = "codex" | "claude" | "openCode";
|
||||
type ModelTab = "codex" | "claude";
|
||||
type CatalogState = Record<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
const TABS: Array<{ id: ProfileTab; label: string }> = [
|
||||
{ id: "codex", label: "Codex" },
|
||||
@ -83,16 +85,16 @@ function withModel(profile: AgentProfile, model: string): AgentProfile {
|
||||
return { ...profile, model: nextModel };
|
||||
}
|
||||
|
||||
function optionLabel(entry: ProfileModelCatalogEntry): string {
|
||||
function optionLabel(entry: ProfileModelCatalogEntry, tr: Tr): string {
|
||||
return entry.recommended
|
||||
? `${entry.displayName} (${entry.modelId}, recommande)`
|
||||
? `${entry.displayName} (${entry.modelId}, ${tr("recommandé", "recommended")})`
|
||||
: `${entry.displayName} (${entry.modelId})`;
|
||||
}
|
||||
|
||||
function compatibilityLabel(compatibility: ModelCompatibility): string {
|
||||
if (compatibility === "compatible") return "Compatible";
|
||||
if (compatibility === "likelyTooRecent") return "Probablement trop récent";
|
||||
return "Inconnu";
|
||||
function compatibilityLabel(compatibility: ModelCompatibility, tr: Tr): string {
|
||||
if (compatibility === "compatible") return tr("Compatible", "Compatible");
|
||||
if (compatibility === "likelyTooRecent") return tr("Probablement trop récent", "Likely too recent");
|
||||
return tr("Inconnu", "Unknown");
|
||||
}
|
||||
|
||||
function engineLabel(tab: ModelTab): string {
|
||||
@ -102,10 +104,11 @@ function engineLabel(tab: ModelTab): string {
|
||||
function attentionBadge(
|
||||
compatibility: ModelCompatibility,
|
||||
cliVersion: string | null,
|
||||
tr: Tr,
|
||||
): string | null {
|
||||
if (!cliVersion) return "CLI non détecté";
|
||||
if (!cliVersion) return tr("CLI non détectée", "CLI not detected");
|
||||
if (compatibility === "compatible") return null;
|
||||
return compatibilityLabel(compatibility);
|
||||
return compatibilityLabel(compatibility, tr);
|
||||
}
|
||||
|
||||
function catalogEntryFor(
|
||||
@ -128,28 +131,29 @@ function modelHelp(
|
||||
tab: ModelTab,
|
||||
model: string,
|
||||
catalog: ProfileModelCatalog & { unavailable: boolean },
|
||||
tr: Tr,
|
||||
): string {
|
||||
const cli = engineLabel(tab);
|
||||
if (catalog.unavailable) {
|
||||
return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.";
|
||||
return tr("Catalogue du fournisseur indisponible ; vous pouvez saisir le modèle manuellement.", "Provider catalog unavailable; you can enter the model manually.");
|
||||
}
|
||||
if (!catalog.cliVersion) {
|
||||
return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`;
|
||||
return tr(`Version de ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`, `${cli} version not detected; IdeA cannot estimate compatibility.`);
|
||||
}
|
||||
const compatibility = compatibilityFor(model, catalog.models);
|
||||
if (compatibility === "compatible") {
|
||||
return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`;
|
||||
return tr(`Compatible avec ${cli} ${catalog.cliVersion} d’après le catalogue local IdeA.`, `Compatible with ${cli} ${catalog.cliVersion} according to IdeA's local catalog.`);
|
||||
}
|
||||
if (compatibility === "likelyTooRecent") {
|
||||
return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`;
|
||||
return tr(`Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour la CLI si le lancement échoue.`, `Likely too recent for ${cli} ${catalog.cliVersion}; update the CLI if launch fails.`);
|
||||
}
|
||||
return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`;
|
||||
return tr(`Compatibilité inconnue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`, `Compatibility unknown for ${cli} ${catalog.cliVersion}; manual entry remains allowed.`);
|
||||
}
|
||||
|
||||
function saveWarningText(tab: ModelTab, cliVersion: string | null): string {
|
||||
function saveWarningText(tab: ModelTab, cliVersion: string | null, tr: Tr): string {
|
||||
const cli = engineLabel(tab);
|
||||
const version = cliVersion ? ` ${cliVersion}` : "";
|
||||
return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`;
|
||||
return tr(`Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l’agent pourrait échouer au lancement tant que la CLI n’est pas mise à jour.`, `This model appears newer than your ${cli}${version}. The profile can be saved, but the agent may fail to launch until the CLI is updated.`);
|
||||
}
|
||||
|
||||
function matchingSuggestions(
|
||||
@ -183,10 +187,11 @@ function ModelField({
|
||||
catalog: ProfileModelCatalog & { unavailable: boolean };
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const inputId = `profile-model-${tab}-${profileId}`;
|
||||
const compatibility = compatibilityFor(model, catalog.models);
|
||||
const badge = attentionBadge(compatibility, catalog.cliVersion);
|
||||
const badge = attentionBadge(compatibility, catalog.cliVersion, tr);
|
||||
const suggestions = matchingSuggestions(model, catalog.models);
|
||||
|
||||
return (
|
||||
@ -195,12 +200,12 @@ function ModelField({
|
||||
htmlFor={inputId}
|
||||
className="flex items-center gap-2 text-xs font-medium text-muted"
|
||||
>
|
||||
Modèle
|
||||
{tr("Modèle", "Model")}
|
||||
<span
|
||||
className="text-faint"
|
||||
title="IdeA estime la compatibilité à partir du catalogue maintenu dans l'application et de la version du CLI détectée localement. Le provider peut accepter ou refuser le modèle différemment au moment du lancement."
|
||||
title={tr("IdeA estime la compatibilité à partir du catalogue maintenu dans l’application et de la version de la CLI détectée localement. Le fournisseur peut accepter ou refuser le modèle différemment au lancement.", "IdeA estimates compatibility from its maintained catalog and the locally detected CLI version. The provider may accept or reject the model differently at launch.")}
|
||||
>
|
||||
compatibilité estimée
|
||||
{tr("compatibilité estimée", "estimated compatibility")}
|
||||
</span>
|
||||
{badge && (
|
||||
<span
|
||||
@ -217,22 +222,22 @@ function ModelField({
|
||||
</label>
|
||||
<Input
|
||||
id={inputId}
|
||||
aria-label={`modele du profil ${profileName}`}
|
||||
aria-label={tr(`Modèle du profil ${profileName}`, `Model for profile ${profileName}`)}
|
||||
placeholder={
|
||||
catalog.models.length > 0
|
||||
? "Choisir ou saisir un modèle"
|
||||
: "Saisir un modèle"
|
||||
? tr("Choisir ou saisir un modèle", "Choose or enter a model")
|
||||
: tr("Saisir un modèle", "Enter a model")
|
||||
}
|
||||
value={model}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => window.setTimeout(() => setFocused(false), 120)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<small className="text-xs text-faint">{modelHelp(tab, model, catalog)}</small>
|
||||
<small className="text-xs text-faint">{modelHelp(tab, model, catalog, tr)}</small>
|
||||
{focused && suggestions.length > 0 && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={`suggestions de modèles ${profileName}`}
|
||||
aria-label={tr(`Suggestions de modèles pour ${profileName}`, `Model suggestions for ${profileName}`)}
|
||||
className="mt-1 max-h-36 overflow-auto rounded-md border border-border bg-raised p-1"
|
||||
>
|
||||
{suggestions.map((entry) => (
|
||||
@ -247,7 +252,7 @@ function ModelField({
|
||||
setFocused(false);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate">{optionLabel(entry)}</span>
|
||||
<span className="min-w-0 truncate">{optionLabel(entry, tr)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
@ -258,7 +263,7 @@ function ModelField({
|
||||
: "bg-surface text-muted",
|
||||
)}
|
||||
>
|
||||
{compatibilityLabel(entry.compatibility)}
|
||||
{compatibilityLabel(entry.compatibility, tr)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@ -269,6 +274,7 @@ function ModelField({
|
||||
}
|
||||
|
||||
export function ProfilesSettings() {
|
||||
const { tr } = useI18n();
|
||||
const { profile } = useGateways();
|
||||
const modelServers = useModelServers();
|
||||
const providerCatalog = useOpenCodeProviderCatalog();
|
||||
@ -357,7 +363,7 @@ export function ProfilesSettings() {
|
||||
try {
|
||||
if (activeTab === "openCode") {
|
||||
const created = await profile.cloneOpenCodeProfileFromSeed({
|
||||
name: `${seed.name} copy`,
|
||||
name: `${seed.name} ${tr("copie", "copy")}`,
|
||||
opencode: seed.opencode,
|
||||
});
|
||||
await profile.saveProfile(created);
|
||||
@ -372,7 +378,7 @@ export function ProfilesSettings() {
|
||||
const recommended = models.find((m) => m.recommended)?.modelId;
|
||||
await profile.cloneProfileFromSeed({
|
||||
seedProfileId: seed.id,
|
||||
name: `${seed.name} copy`,
|
||||
name: `${seed.name} ${tr("copie", "copy")}`,
|
||||
model: recommended,
|
||||
});
|
||||
await refresh();
|
||||
@ -398,7 +404,7 @@ export function ProfilesSettings() {
|
||||
tab === "codex" || tab === "claude"
|
||||
? compatibilityFor(modelOf(draft), catalogue[tab].models) ===
|
||||
"likelyTooRecent"
|
||||
? saveWarningText(tab, catalogue[tab].cliVersion)
|
||||
? saveWarningText(tab, catalogue[tab].cliVersion, tr)
|
||||
: null
|
||||
: null;
|
||||
await profile.saveProfile(draft);
|
||||
@ -419,7 +425,7 @@ export function ProfilesSettings() {
|
||||
try {
|
||||
if (source.structuredAdapter === "openCode") {
|
||||
const created = await profile.cloneOpenCodeProfileFromSeed({
|
||||
name: `${source.name} copy`,
|
||||
name: `${source.name} ${tr("copie", "copy")}`,
|
||||
opencode: source.opencode,
|
||||
});
|
||||
await profile.saveProfile(created);
|
||||
@ -429,7 +435,7 @@ export function ProfilesSettings() {
|
||||
|
||||
await profile.cloneProfileFromSeed({
|
||||
seedProfileId: source.id,
|
||||
name: `${source.name} copy`,
|
||||
name: `${source.name} ${tr("copie", "copy")}`,
|
||||
model:
|
||||
source.structuredAdapter === "codex" ||
|
||||
source.structuredAdapter === "claude"
|
||||
@ -459,18 +465,18 @@ export function ProfilesSettings() {
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="ai profiles settings"
|
||||
title="Profils IA"
|
||||
aria-label={tr("Paramètres des profils IA", "AI profile settings")}
|
||||
title={tr("Profils IA", "AI profiles")}
|
||||
actions={
|
||||
<Button size="sm" onClick={() => void createFromSeed()} disabled={!seed || busy}>
|
||||
Creer un profil
|
||||
{tr("Créer un profil", "Create profile")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="types de profils IA"
|
||||
aria-label={tr("Types de profils IA", "AI profile types")}
|
||||
className="inline-flex w-fit rounded-md border border-border bg-raised p-0.5"
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
@ -512,7 +518,7 @@ export function ProfilesSettings() {
|
||||
|
||||
{visibleProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
|
||||
{tr("Aucun profil", "No")} {TABS.find((tab) => tab.id === activeTab)?.label} {tr("configuré.", "profile configured.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
@ -528,9 +534,9 @@ export function ProfilesSettings() {
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Nom</span>
|
||||
<span className="text-xs font-medium text-muted">{tr("Nom", "Name")}</span>
|
||||
<Input
|
||||
aria-label={`nom du profil ${saved.name}`}
|
||||
aria-label={tr(`Nom du profil ${saved.name}`, `Name of profile ${saved.name}`)}
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
updateDraft(saved.id, (p) => ({
|
||||
@ -585,7 +591,7 @@ export function ProfilesSettings() {
|
||||
disabled={!dirty || busy || draft.name.trim() === ""}
|
||||
onClick={() => void save(saved.id)}
|
||||
>
|
||||
Enregistrer
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -593,17 +599,17 @@ export function ProfilesSettings() {
|
||||
disabled={busy}
|
||||
onClick={() => void duplicate(saved)}
|
||||
>
|
||||
Dupliquer
|
||||
{tr("Dupliquer", "Duplicate")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
className="text-danger hover:text-danger"
|
||||
aria-label={`supprimer ${saved.name}`}
|
||||
aria-label={tr(`Supprimer ${saved.name}`, `Delete ${saved.name}`)}
|
||||
onClick={() => void del(saved)}
|
||||
>
|
||||
Supprimer
|
||||
{tr("Supprimer", "Delete")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -16,6 +16,7 @@ import type { GraphCommit } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Spinner, cn } from "@/shared";
|
||||
import { computeGraphRows, type GraphLink, type GraphRow } from "./graphLayout";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
@ -136,14 +137,15 @@ function GraphColumn({ row, laneCount }: GraphColumnProps) {
|
||||
// Relative date helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function relativeDate(timestamp: number): string {
|
||||
function relativeDate(timestamp: number, language: "fr" | "en"): string {
|
||||
const secs = Math.floor(Date.now() / 1000) - timestamp;
|
||||
if (secs < 60) return "just now";
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
||||
if (secs < 2592000) return `${Math.floor(secs / 86400)}d ago`;
|
||||
if (secs < 31536000) return `${Math.floor(secs / 2592000)}mo ago`;
|
||||
return `${Math.floor(secs / 31536000)}y ago`;
|
||||
const rtf = new Intl.RelativeTimeFormat(language, { numeric: "auto", style: "narrow" });
|
||||
if (secs < 60) return rtf.format(0, "second");
|
||||
if (secs < 3600) return rtf.format(-Math.floor(secs / 60), "minute");
|
||||
if (secs < 86400) return rtf.format(-Math.floor(secs / 3600), "hour");
|
||||
if (secs < 2592000) return rtf.format(-Math.floor(secs / 86400), "day");
|
||||
if (secs < 31536000) return rtf.format(-Math.floor(secs / 2592000), "month");
|
||||
return rtf.format(-Math.floor(secs / 31536000), "year");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -156,18 +158,19 @@ interface CommitDetailProps {
|
||||
}
|
||||
|
||||
function CommitDetail({ commit, onClose }: CommitDetailProps) {
|
||||
const { tr } = useI18n();
|
||||
const isTag = (ref: string) => ref.startsWith("tag:");
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="commit detail"
|
||||
aria-label={tr("détail du commit", "commit detail")}
|
||||
className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto border-l border-border bg-surface p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-content">{commit.summary}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="close commit detail"
|
||||
aria-label={tr("fermer le détail du commit", "close commit detail")}
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-muted hover:text-content"
|
||||
>
|
||||
@ -183,18 +186,18 @@ function CommitDetail({ commit, onClose }: CommitDetailProps) {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-faint">Author</dt>
|
||||
<dt className="font-medium text-faint">{tr("Auteur", "Author")}</dt>
|
||||
<dd className="text-content">{commit.author}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-faint">Date</dt>
|
||||
<dt className="font-medium text-faint">{tr("Date", "Date")}</dt>
|
||||
<dd className="text-content">
|
||||
{new Date(commit.timestamp * 1000).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
{commit.parents.length > 0 && (
|
||||
<div>
|
||||
<dt className="font-medium text-faint">Parents</dt>
|
||||
<dt className="font-medium text-faint">{tr("Parents", "Parents")}</dt>
|
||||
<dd className="flex flex-col gap-0.5">
|
||||
{commit.parents.map((p) => (
|
||||
<code key={p} className="text-content">
|
||||
@ -236,6 +239,7 @@ export interface GitGraphViewProps {
|
||||
}
|
||||
|
||||
export function GitGraphView({ projectId }: GitGraphViewProps) {
|
||||
const { tr, language } = useI18n();
|
||||
const vm = useGitGraph(projectId);
|
||||
const [selectedHash, setSelectedHash] = useState<string | null>(null);
|
||||
|
||||
@ -250,21 +254,21 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
|
||||
const isTag = (ref: string) => ref.startsWith("tag:");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden" aria-label="git graph">
|
||||
<div className="flex flex-1 overflow-hidden" aria-label={tr("graphe Git", "git graph")}>
|
||||
{/* ── Graph + commit list ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-surface px-3 py-2">
|
||||
<span className="text-sm font-semibold text-content">Git Graph</span>
|
||||
<span className="text-sm font-semibold text-content">{tr("Graphe Git", "Git graph")}</span>
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="refresh git graph"
|
||||
aria-label={tr("rafraîchir le graphe Git", "refresh git graph")}
|
||||
onClick={() => void vm.refresh()}
|
||||
disabled={vm.busy}
|
||||
className="ml-auto text-xs text-muted hover:text-content disabled:opacity-40"
|
||||
>
|
||||
Refresh
|
||||
{tr("Rafraîchir", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -279,9 +283,9 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
|
||||
)}
|
||||
|
||||
{/* Rows */}
|
||||
<div className="flex-1 overflow-y-auto" role="list" aria-label="commits">
|
||||
<div className="flex-1 overflow-y-auto" role="list" aria-label={tr("commits", "commits")}>
|
||||
{vm.rows.length === 0 && !vm.busy && (
|
||||
<p className="p-4 text-sm text-muted">No commits found.</p>
|
||||
<p className="p-4 text-sm text-muted">{tr("Aucun commit trouvé.", "No commits found.")}</p>
|
||||
)}
|
||||
{vm.rows.map((row) => {
|
||||
const isSelected = row.commit.hash === selectedHash;
|
||||
@ -291,7 +295,7 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
|
||||
<div
|
||||
key={row.commit.hash}
|
||||
role="listitem"
|
||||
aria-label={`commit ${row.commit.hash.slice(0, 8)}: ${row.commit.summary}`}
|
||||
aria-label={tr(`commit ${row.commit.hash.slice(0, 8)} : ${row.commit.summary}`, `commit ${row.commit.hash.slice(0, 8)}: ${row.commit.summary}`)}
|
||||
onClick={() =>
|
||||
setSelectedHash(isSelected ? null : row.commit.hash)
|
||||
}
|
||||
@ -353,7 +357,7 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
|
||||
{row.commit.author}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-faint">
|
||||
{relativeDate(row.commit.timestamp)}
|
||||
{relativeDate(row.commit.timestamp, language)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -13,6 +13,7 @@ import { useState } from "react";
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { useGit } from "./useGit";
|
||||
import { buildFileTree, type GitTreeNode } from "./gitTree";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/**
|
||||
* Recursively renders a changed-files tree. Directories are static rows; file
|
||||
@ -31,6 +32,7 @@ function FileTree({
|
||||
onAction: (path: string) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{nodes.map((node) =>
|
||||
@ -74,12 +76,12 @@ function FileTree({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`${action} ${node.path}`}
|
||||
aria-label={tr(`${action === "unstage" ? "désindexer" : "indexer"} ${node.path}`, `${action} ${node.path}`)}
|
||||
disabled={busy}
|
||||
onClick={() => onAction(node.path)}
|
||||
className="shrink-0 text-muted hover:text-content"
|
||||
>
|
||||
{action === "unstage" ? "Unstage" : "Stage"}
|
||||
{action === "unstage" ? tr("Désindexer", "Unstage") : tr("Indexer", "Stage")}
|
||||
</Button>
|
||||
</li>
|
||||
),
|
||||
@ -94,6 +96,7 @@ export interface GitPanelProps {
|
||||
}
|
||||
|
||||
export function GitPanel({ projectId }: GitPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useGit(projectId);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
@ -133,22 +136,22 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
)}
|
||||
|
||||
{/* ── Changes ── */}
|
||||
<section aria-label="Changes" className="border-b border-border p-4">
|
||||
<section aria-label={tr("Modifications", "Changes")} className="border-b border-border p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Changes
|
||||
{tr("Modifications", "Changes")}
|
||||
</h4>
|
||||
{vm.busy && <Spinner size={12} />}
|
||||
</div>
|
||||
|
||||
{vm.files.length === 0 ? (
|
||||
<p className="text-sm text-muted">No changes.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucune modification.", "No changes.")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Staged — shown as a file tree. */}
|
||||
{staged.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted">Staged</p>
|
||||
<p className="mb-1 text-xs font-medium text-muted">{tr("Indexées", "Staged")}</p>
|
||||
<FileTree
|
||||
nodes={buildFileTree(staged)}
|
||||
depth={0}
|
||||
@ -162,7 +165,7 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
{/* Unstaged — shown as a file tree. */}
|
||||
{unstaged.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted">Unstaged</p>
|
||||
<p className="mb-1 text-xs font-medium text-muted">{tr("Non indexées", "Unstaged")}</p>
|
||||
<FileTree
|
||||
nodes={buildFileTree(unstaged)}
|
||||
depth={0}
|
||||
@ -177,14 +180,14 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
</section>
|
||||
|
||||
{/* ── Commit ── */}
|
||||
<section aria-label="Commit" className="border-b border-border p-4">
|
||||
<section aria-label={tr("Commit", "Commit")} className="border-b border-border p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Commit
|
||||
</h4>
|
||||
<form onSubmit={handleCommit} className="flex flex-col gap-2">
|
||||
<Input
|
||||
aria-label="commit message"
|
||||
placeholder="Commit message"
|
||||
aria-label={tr("message de commit", "commit message")}
|
||||
placeholder={tr("Message de commit", "Commit message")}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
/>
|
||||
@ -201,14 +204,14 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
</section>
|
||||
|
||||
{/* ── Branches ── */}
|
||||
<section aria-label="Branches" className="border-b border-border p-4">
|
||||
<section aria-label={tr("Branches", "Branches")} className="border-b border-border p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Branches
|
||||
</h4>
|
||||
<p className="mb-2 text-sm text-content">
|
||||
Current:{" "}
|
||||
{tr("Courante :", "Current:")}{" "}
|
||||
<span className="font-medium">
|
||||
{vm.branches.current ?? "(none)"}
|
||||
{vm.branches.current ?? tr("(aucune)", "(none)")}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@ -236,12 +239,12 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`checkout ${b}`}
|
||||
aria-label={tr(`basculer sur ${b}`, `checkout ${b}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void handleCheckout(b)}
|
||||
className="shrink-0 text-muted hover:text-content"
|
||||
>
|
||||
Checkout
|
||||
{tr("Basculer", "Checkout")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
@ -252,8 +255,8 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
|
||||
<form onSubmit={handleNewBranch} className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label="new branch name"
|
||||
placeholder="New branch"
|
||||
aria-label={tr("nom de la nouvelle branche", "new branch name")}
|
||||
placeholder={tr("Nouvelle branche", "New branch")}
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
className="flex-1"
|
||||
@ -263,18 +266,18 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
variant="ghost"
|
||||
disabled={!newBranch.trim() || vm.busy}
|
||||
>
|
||||
Create & checkout
|
||||
{tr("Créer et basculer", "Create & checkout")}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Log ── */}
|
||||
<section aria-label="Log" className="p-4">
|
||||
<section aria-label={tr("Historique", "Log")} className="p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Log
|
||||
{tr("Historique", "Log")}
|
||||
</h4>
|
||||
{vm.log.length === 0 ? (
|
||||
<p className="text-sm text-muted">No commits yet.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun commit.", "No commits yet.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.log.map((c) => (
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
*
|
||||
* Covers:
|
||||
* - status shows staged/unstaged groupings
|
||||
* - stage moves a file from Unstaged to Staged
|
||||
* - indexer moves a file from Unstaged to Staged
|
||||
* - commit with empty message is blocked (button disabled)
|
||||
* - commit with message but nothing staged is blocked (button disabled)
|
||||
* - commit moves staged files out and adds an entry to the log
|
||||
@ -41,7 +41,7 @@ function renderPanel(git: MockGitGateway = new MockGitGateway()) {
|
||||
/** Waits until the "Changes" section is rendered (panel stabilised). */
|
||||
async function waitForPanel() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("region", { name: "Changes" })).toBeTruthy();
|
||||
expect(screen.getByRole("region", { name: "Modifications" })).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
@ -59,15 +59,15 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
renderPanel(git);
|
||||
await waitForPanel();
|
||||
|
||||
await screen.findByText("Staged");
|
||||
await screen.findByText("Unstaged");
|
||||
// src/main.rs is staged → Unstage button exists
|
||||
await screen.findByText("Indexées");
|
||||
await screen.findByText("Non indexées");
|
||||
// src/main.rs is staged → Unindexer button exists
|
||||
expect(
|
||||
screen.getByRole("button", { name: "unstage src/main.rs" }),
|
||||
screen.getByRole("button", { name: "désindexer src/main.rs" }),
|
||||
).toBeTruthy();
|
||||
// README.md is unstaged → Stage button exists
|
||||
expect(
|
||||
screen.getByRole("button", { name: "stage README.md" }),
|
||||
screen.getByRole("button", { name: "indexer README.md" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -77,19 +77,19 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
|
||||
// Stage README.md
|
||||
const stageBtn = await screen.findByRole("button", {
|
||||
name: "stage README.md",
|
||||
name: "indexer README.md",
|
||||
});
|
||||
fireEvent.click(stageBtn);
|
||||
|
||||
// After staging, an Unstage button should appear for README.md
|
||||
// After staging, an Unindexer button should appear for README.md
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "unstage README.md" }),
|
||||
screen.getByRole("button", { name: "désindexer README.md" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Unstage moves a file back to Unstaged", async () => {
|
||||
it("clicking Unindexer moves a file back to Unstaged", async () => {
|
||||
const git = new MockGitGateway();
|
||||
await git.init(PROJECT_ID);
|
||||
await git.stage(PROJECT_ID, "src/main.rs");
|
||||
@ -98,13 +98,13 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
await waitForPanel();
|
||||
|
||||
const unstageBtn = await screen.findByRole("button", {
|
||||
name: "unstage src/main.rs",
|
||||
name: "désindexer src/main.rs",
|
||||
});
|
||||
fireEvent.click(unstageBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "stage src/main.rs" }),
|
||||
screen.getByRole("button", { name: "indexer src/main.rs" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -121,7 +121,7 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("commit message"), {
|
||||
fireEvent.change(screen.getByLabelText("message de commit"), {
|
||||
target: { value: "my message" },
|
||||
});
|
||||
|
||||
@ -138,7 +138,7 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
renderPanel(git);
|
||||
await waitForPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("commit message"), {
|
||||
fireEvent.change(screen.getByLabelText("message de commit"), {
|
||||
target: { value: "initial commit" },
|
||||
});
|
||||
|
||||
@ -148,7 +148,7 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
|
||||
// After commit, no files should remain (both were staged).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No changes.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucune modification.")).toBeTruthy();
|
||||
});
|
||||
|
||||
// The commit should appear in the log.
|
||||
@ -168,9 +168,9 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
expect(branchesSection).toBeTruthy();
|
||||
|
||||
// Create & checkout a new branch via the form.
|
||||
const branchInput = screen.getByLabelText("new branch name");
|
||||
const branchInput = screen.getByLabelText("nom de la nouvelle branche");
|
||||
fireEvent.change(branchInput, { target: { value: "feature/new-thing" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /create.*checkout/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /créer.*basculer/i }));
|
||||
|
||||
// After checkout, the new branch should appear (may show in current + list).
|
||||
await waitFor(() => {
|
||||
@ -181,7 +181,7 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
// A checkout button for "main" should now appear (since it's no longer current).
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "checkout main" }),
|
||||
screen.getByRole("button", { name: "basculer sur main" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -190,7 +190,7 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
expect(screen.getByText("No commits yet.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun commit.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -213,7 +213,7 @@ describe("MockGitGateway (unit)", () => {
|
||||
expect(Array.isArray(files)).toBe(true);
|
||||
});
|
||||
|
||||
it("stage toggles a file to staged=true", async () => {
|
||||
it("indexer toggles a file to staged=true", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const before = await gw.status("p");
|
||||
const target = before[0].path;
|
||||
@ -223,7 +223,7 @@ describe("MockGitGateway (unit)", () => {
|
||||
expect(file.staged).toBe(true);
|
||||
});
|
||||
|
||||
it("unstage toggles a file back to staged=false", async () => {
|
||||
it("désindexer toggles a file back to staged=false", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const files = await gw.status("p");
|
||||
const target = files[0].path;
|
||||
|
||||
@ -114,7 +114,7 @@ describe("GitGraphView", () => {
|
||||
await waitForCommits();
|
||||
|
||||
// The detail panel should not be visible yet.
|
||||
expect(screen.queryByRole("complementary", { name: "commit detail" })).toBeNull();
|
||||
expect(screen.queryByRole("complementary", { name: "détail du commit" })).toBeNull();
|
||||
|
||||
// Click the first commit row.
|
||||
const items = screen.getAllByRole("listitem");
|
||||
@ -122,7 +122,7 @@ describe("GitGraphView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "commit detail" }),
|
||||
screen.getByRole("complementary", { name: "détail du commit" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -147,7 +147,7 @@ describe("GitGraphView", () => {
|
||||
|
||||
// Author should appear in a <dd> (detail panel's definition list).
|
||||
await waitFor(() => {
|
||||
const detail = screen.getByRole("complementary", { name: "commit detail" });
|
||||
const detail = screen.getByRole("complementary", { name: "détail du commit" });
|
||||
expect(detail.textContent).toContain("Alice");
|
||||
});
|
||||
});
|
||||
@ -162,7 +162,7 @@ describe("GitGraphView", () => {
|
||||
fireEvent.click(mergeRow);
|
||||
|
||||
await waitFor(() => {
|
||||
const detail = screen.getByRole("complementary", { name: "commit detail" });
|
||||
const detail = screen.getByRole("complementary", { name: "détail du commit" });
|
||||
// The merge commit has two parents displayed as short hashes.
|
||||
expect(detail.textContent).toContain("bbbbbbbb");
|
||||
expect(detail.textContent).toContain("dddddddd");
|
||||
@ -178,16 +178,16 @@ describe("GitGraphView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "commit detail" }),
|
||||
screen.getByRole("complementary", { name: "détail du commit" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the close button.
|
||||
fireEvent.click(screen.getByRole("button", { name: "close commit detail" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fermer le détail du commit" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "commit detail" }),
|
||||
screen.queryByRole("complementary", { name: "détail du commit" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -201,7 +201,7 @@ describe("GitGraphView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "commit detail" }),
|
||||
screen.getByRole("complementary", { name: "détail du commit" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -210,7 +210,7 @@ describe("GitGraphView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "commit detail" }),
|
||||
screen.queryByRole("complementary", { name: "détail du commit" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -148,13 +148,13 @@ async function flush() {
|
||||
}
|
||||
|
||||
function controlsFor(cellId: string) {
|
||||
const selector = screen.getByLabelText(`agent selector ${cellId}`);
|
||||
const selector = screen.getByLabelText(`Sélecteur d’agent ${cellId}`);
|
||||
const controls = selector.parentElement as HTMLElement;
|
||||
return {
|
||||
selector,
|
||||
controls,
|
||||
splitColumns: screen.getByLabelText(`split ${cellId} columns`),
|
||||
close: screen.getByLabelText(`close ${cellId}`),
|
||||
splitColumns: screen.getByLabelText(`Diviser ${cellId} en colonnes`),
|
||||
close: screen.getByLabelText(`Fermer ${cellId}`),
|
||||
};
|
||||
}
|
||||
|
||||
@ -231,7 +231,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(`agent selector ${setup.bId}`) as HTMLSelectElement)
|
||||
(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`) as HTMLSelectElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy();
|
||||
@ -381,7 +381,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
renderGrid(setup.gateways);
|
||||
|
||||
const group = await screen.findByRole("group", {
|
||||
name: `mode CLI agent ${setup.bId}`,
|
||||
name: `Mode CLI de l’agent ${setup.bId}`,
|
||||
});
|
||||
const { controls } = controlsFor(setup.bId);
|
||||
|
||||
@ -417,19 +417,19 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
const mutate = vi.spyOn(setup.layout, "mutateLayout");
|
||||
|
||||
expect(customToolbar.className).toContain("z-20");
|
||||
expect(screen.getByLabelText(`agent selector ${setup.bId}`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`)).toBeTruthy();
|
||||
expect(screen.getByRole("group", {
|
||||
name: `mode CLI agent ${setup.bId}`,
|
||||
name: `Mode CLI de l’agent ${setup.bId}`,
|
||||
})).toBeTruthy();
|
||||
expect(screen.getByLabelText(`split ${setup.bId} columns`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`split ${setup.bId} rows`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`close ${setup.bId}`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Diviser ${setup.bId} en lignes`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Fermer ${setup.bId}`)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", {
|
||||
name: `cancel current turn ${setup.bId}`,
|
||||
})).toBeNull();
|
||||
expect(screen.queryByText("Busy")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(`split ${setup.bId} columns`));
|
||||
fireEvent.click(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`));
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
@ -442,7 +442,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const selector = screen.getByLabelText(`agent selector ${setup.bId}`);
|
||||
const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`);
|
||||
fireEvent.change(selector, { target: { value: setup.otherAgentId } });
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
|
||||
@ -172,7 +172,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/mock-attachment.txt/)).toBeTruthy(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "hello there" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Envoyer"));
|
||||
@ -324,12 +324,12 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
|
||||
expect(screen.queryByText("Busy · Main")).toBeNull();
|
||||
expect(screen.getByLabelText(`agent selector ${leaf.id}`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Sélecteur d’agent ${leaf.id}`)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", {
|
||||
name: `cancel current turn ${leaf.id}`,
|
||||
})).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty(
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
),
|
||||
@ -411,9 +411,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
try {
|
||||
renderGrid(gateways);
|
||||
|
||||
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||||
const handle = screen.getByRole("button", {
|
||||
name: `redimensionner le composer ${leafId}`,
|
||||
name: `Redimensionner l’éditeur ${leafId}`,
|
||||
});
|
||||
expect(composer.style.height).toBe("64px");
|
||||
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
|
||||
|
||||
@ -151,7 +151,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
);
|
||||
|
||||
// Click the close button on cell `b` — `b` must disappear, `a` must remain.
|
||||
fireEvent.click(screen.getByLabelText("close b"));
|
||||
fireEvent.click(screen.getByLabelText("Fermer b"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
|
||||
@ -180,7 +180,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
);
|
||||
|
||||
// Closing `a` (index 0) must keep `b` (index 1).
|
||||
fireEvent.click(screen.getByLabelText(`close ${a}`));
|
||||
fireEvent.click(screen.getByLabelText(`Fermer ${a}`));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
|
||||
@ -196,7 +196,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
|
||||
);
|
||||
expect(screen.queryByLabelText(/^close /)).toBeNull();
|
||||
expect(screen.queryByLabelText(/^Fermer /)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the loading/empty container when the layout gateway is absent", () => {
|
||||
|
||||
@ -28,6 +28,7 @@ import type {
|
||||
} from "@/ports";
|
||||
import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import {
|
||||
AgentRestartOverlay,
|
||||
@ -94,6 +95,7 @@ export function LayoutGrid({
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: LayoutGridProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useLayout(projectId, layoutId);
|
||||
const work = useProjectWorkState(projectId);
|
||||
const [refitEpoch, setRefitEpoch] = useState(0);
|
||||
@ -110,7 +112,7 @@ export function LayoutGrid({
|
||||
{vm.error}
|
||||
</p>
|
||||
) : (
|
||||
<p>Loading layout…</p>
|
||||
<p>{tr("Chargement de la disposition…", "Loading layout…")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -346,26 +348,27 @@ function StoppedAgentCellPanel({
|
||||
onRestart: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<section
|
||||
className="stopped-agent-cell"
|
||||
aria-label={`${agentName} arrêté`}
|
||||
aria-label={`${agentName} ${tr("arrêté", "stopped")}`}
|
||||
>
|
||||
<div className="stopped-agent-cell__header">
|
||||
<h3 className="stopped-agent-cell__name">{agentName}</h3>
|
||||
<span className="stopped-agent-cell__badge">Arrêté</span>
|
||||
<span className="stopped-agent-cell__badge">{tr("Arrêté", "Stopped")}</span>
|
||||
</div>
|
||||
<div className="stopped-agent-cell__content">
|
||||
<p className="stopped-agent-cell__message">
|
||||
{error ? "Erreur de relance" : restarting ? "Relance…" : "Cette session est terminée."}
|
||||
{error ? tr("Erreur de relance", "Restart failed") : restarting ? tr("Relance…", "Restarting…") : tr("Cette session est terminée.", "This session has ended.")}
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="stopped-agent-cell__help">
|
||||
Impossible de relancer l’agent. Réessayez.
|
||||
{tr("Impossible de relancer l’agent. Réessayez.", "Unable to restart the agent. Try again.")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="stopped-agent-cell__help">
|
||||
Ouvre une nouvelle session pour cet agent.
|
||||
{tr("Ouvre une nouvelle session pour cet agent.", "Opens a new session for this agent.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -376,7 +379,7 @@ function StoppedAgentCellPanel({
|
||||
onClick={onRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? "Relance…" : "Relancer"}
|
||||
{restarting ? tr("Relance…", "Restarting…") : tr("Relancer", "Restart")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -384,31 +387,31 @@ function StoppedAgentCellPanel({
|
||||
onClick={onRefresh}
|
||||
disabled={restarting}
|
||||
>
|
||||
Actualiser
|
||||
{tr("Actualiser", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function restartFailureLabel(code?: string): string {
|
||||
function restartFailureLabel(code: string | undefined, tr: (fr: string, en: string) => string): string {
|
||||
switch (code) {
|
||||
case "STOP_TIMEOUT":
|
||||
case "STOP_FAILED":
|
||||
return "Impossible d’arrêter l’agent.";
|
||||
return tr("Impossible d’arrêter l’agent.", "Unable to stop the agent.");
|
||||
case "LAUNCH_FAILED":
|
||||
case "RESUME_FAILED":
|
||||
return "Impossible de relancer l’agent.";
|
||||
return tr("Impossible de relancer l’agent.", "Unable to restart the agent.");
|
||||
case "RUNTIME_NOT_FOUND":
|
||||
return "Runtime de l’agent introuvable.";
|
||||
return tr("Runtime de l’agent introuvable.", "Agent runtime not found.");
|
||||
case "AGENT_NOT_BOUND_TO_CELL":
|
||||
return "L’agent n’est plus associé à cette cellule.";
|
||||
return tr("L’agent n’est plus associé à cette cellule.", "The agent is no longer assigned to this cell.");
|
||||
case "CONFLICT_AGENT_VISIBLE_ELSEWHERE":
|
||||
return "Cet agent est visible dans une autre cellule.";
|
||||
return tr("Cet agent est visible dans une autre cellule.", "This agent is visible in another cell.");
|
||||
case "CONFLICT":
|
||||
return "Un redémarrage est déjà en cours pour cet agent.";
|
||||
return tr("Un redémarrage est déjà en cours pour cet agent.", "This agent is already restarting.");
|
||||
default:
|
||||
return "Impossible de redémarrer l’agent.";
|
||||
return tr("Impossible de redémarrer l’agent.", "Unable to restart the agent.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -421,6 +424,7 @@ function CellAgentRestartOverlay({
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const failed = state.status === "failed";
|
||||
return (
|
||||
<div
|
||||
@ -451,7 +455,7 @@ function CellAgentRestartOverlay({
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{failed ? restartFailureLabel(state.code) : "Redémarrage de l’agent…"}
|
||||
{failed ? restartFailureLabel(state.code, tr) : tr("Redémarrage de l’agent…", "Restarting agent…")}
|
||||
</strong>
|
||||
{failed && state.message && (
|
||||
<p style={{ margin: "6px 0 0", color: "var(--color-muted, #9aa7b4)" }}>
|
||||
@ -467,8 +471,8 @@ function CellAgentRestartOverlay({
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={onClose}>Fermer</button>
|
||||
<button type="button" onClick={onRetry}>Réessayer</button>
|
||||
<button type="button" onClick={onClose}>{tr("Fermer", "Close")}</button>
|
||||
<button type="button" onClick={onRetry}>{tr("Réessayer", "Retry")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -531,6 +535,7 @@ function LeafView({
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
}: LeafViewProps) {
|
||||
const { tr } = useI18n();
|
||||
// A cell can be closed only when it lives inside a (binary) split: closing it
|
||||
// collapses the parent split, keeping the *sibling*. Splits are always binary
|
||||
// in this model (a split wraps a leaf into a 2-child container), so the kept
|
||||
@ -901,8 +906,8 @@ function LeafView({
|
||||
else void restartCellAgent();
|
||||
}
|
||||
const targetAnnouncementsFallback = delegatedTicket?.requesterLabel
|
||||
? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.`
|
||||
: "Travaille sur une demande en cours.";
|
||||
? tr(`Travaille sur une demande de ${delegatedTicket.requesterLabel}.`, `Working on a request from ${delegatedTicket.requesterLabel}.`)
|
||||
: tr("Travaille sur une demande en cours.", "Working on a request.");
|
||||
const [restartPending, setRestartPending] = useState(false);
|
||||
const [restartError, setRestartError] = useState(false);
|
||||
const [freshRestartedSession, setFreshRestartedSession] =
|
||||
@ -1041,7 +1046,7 @@ function LeafView({
|
||||
await refreshWorkState();
|
||||
return;
|
||||
}
|
||||
if (!agentGateway.launchAgent) throw new Error("Agent launch unavailable");
|
||||
if (!agentGateway.launchAgent) throw new Error(tr("Lancement de l’agent indisponible", "Agent launch unavailable"));
|
||||
const handle = await agentGateway.launchAgent(
|
||||
projectId,
|
||||
agentId,
|
||||
@ -1094,7 +1099,7 @@ function LeafView({
|
||||
: liveAgents;
|
||||
const host = fresh.find((la) => la.agentId === candidate);
|
||||
return {
|
||||
message: "Cet agent est déjà actif dans une autre cellule.",
|
||||
message: tr("Cet agent est déjà actif dans une autre cellule.", "This agent is already active in another cell."),
|
||||
goToNodeId: host && host.nodeId !== id ? host.nodeId : undefined,
|
||||
};
|
||||
};
|
||||
@ -1355,7 +1360,7 @@ function LeafView({
|
||||
>
|
||||
{/* Agent selector */}
|
||||
<select
|
||||
aria-label={`agent selector ${id}`}
|
||||
aria-label={`${tr("Sélecteur d’agent", "Agent selector")} ${id}`}
|
||||
value={agentId ?? ""}
|
||||
disabled={cellRestartBusy}
|
||||
onChange={(e) => {
|
||||
@ -1381,7 +1386,7 @@ function LeafView({
|
||||
const elsewhere = visibleElsewhere(val);
|
||||
if (elsewhere) {
|
||||
setBusyNotice({
|
||||
message: "Cet agent est déjà actif dans une autre cellule.",
|
||||
message: tr("Cet agent est déjà actif dans une autre cellule.", "This agent is already active in another cell."),
|
||||
goToNodeId: elsewhere.nodeId,
|
||||
});
|
||||
return;
|
||||
@ -1392,7 +1397,7 @@ function LeafView({
|
||||
if (isBackground) {
|
||||
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
|
||||
setBusyNotice({
|
||||
message: "Session active introuvable pour cet agent.",
|
||||
message: tr("Session active introuvable pour cet agent.", "No active session found for this agent."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -1421,14 +1426,14 @@ function LeafView({
|
||||
minWidth: 56,
|
||||
}}
|
||||
>
|
||||
<option value="">Plain</option>
|
||||
<option value="">{tr("Terminal", "Plain")}</option>
|
||||
{agents.map((a) => {
|
||||
const elsewhere = visibleElsewhere(a.id);
|
||||
const background = backgroundLive(a.id);
|
||||
return (
|
||||
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
|
||||
{a.name}
|
||||
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
|
||||
{elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@ -1437,7 +1442,7 @@ function LeafView({
|
||||
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`mode CLI agent ${id}`}
|
||||
aria-label={`${tr("Mode CLI de l’agent", "Agent CLI mode")} ${id}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
overflow: "hidden",
|
||||
@ -1451,7 +1456,7 @@ function LeafView({
|
||||
type="button"
|
||||
disabled={cellRestartBusy}
|
||||
aria-pressed={cellMode === "tui"}
|
||||
title="TUI native"
|
||||
title={tr("TUI native", "Native TUI")}
|
||||
onClick={() => requestMode("tui")}
|
||||
style={{
|
||||
border: 0,
|
||||
@ -1473,13 +1478,13 @@ function LeafView({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
TUI native
|
||||
{tr("TUI native", "Native TUI")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cellRestartBusy}
|
||||
aria-pressed={cellMode === "custom"}
|
||||
title="CLI custom"
|
||||
title={tr("CLI custom", "Custom CLI")}
|
||||
onClick={() => requestMode("custom")}
|
||||
style={{
|
||||
border: 0,
|
||||
@ -1500,7 +1505,7 @@ function LeafView({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
CLI custom
|
||||
{tr("CLI custom", "Custom CLI")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -1508,8 +1513,8 @@ function LeafView({
|
||||
{agentId && (
|
||||
<button
|
||||
type="button"
|
||||
title={cellRestartBusy ? "Redémarrage de l’agent…" : "Redémarrer l’agent"}
|
||||
aria-label={cellRestartBusy ? "Redémarrage de l’agent…" : "Redémarrer l’agent"}
|
||||
title={cellRestartBusy ? tr("Redémarrage de l’agent…", "Restarting agent…") : tr("Redémarrer l’agent", "Restart agent")}
|
||||
aria-label={cellRestartBusy ? tr("Redémarrage de l’agent…", "Restarting agent…") : tr("Redémarrer l’agent", "Restart agent")}
|
||||
disabled={cellRestartBusy}
|
||||
onClick={requestCellAgentRestart}
|
||||
>
|
||||
@ -1519,16 +1524,16 @@ function LeafView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Split into columns"
|
||||
aria-label={`split ${id} columns`}
|
||||
title={tr("Diviser en colonnes", "Split into columns")}
|
||||
aria-label={tr(`Diviser ${id} en colonnes`, `Split ${id} into columns`)}
|
||||
onClick={() => void vm.split(id, "row")}
|
||||
>
|
||||
⬌
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Split into rows"
|
||||
aria-label={`split ${id} rows`}
|
||||
title={tr("Diviser en lignes", "Split into rows")}
|
||||
aria-label={tr(`Diviser ${id} en lignes`, `Split ${id} into rows`)}
|
||||
onClick={() => void vm.split(id, "column")}
|
||||
>
|
||||
⬍
|
||||
@ -1536,8 +1541,8 @@ function LeafView({
|
||||
{canClose && (
|
||||
<button
|
||||
type="button"
|
||||
title="Close this terminal"
|
||||
aria-label={`close ${id}`}
|
||||
title={tr("Fermer ce terminal", "Close this terminal")}
|
||||
aria-label={tr(`Fermer ${id}`, `Close ${id}`)}
|
||||
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
|
||||
>
|
||||
✕
|
||||
@ -1563,7 +1568,7 @@ function LeafView({
|
||||
>
|
||||
{!busyByWorkState && inboxDepth > 0 && (
|
||||
<span
|
||||
title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
|
||||
title={tr(`${inboxDepth} message${inboxDepth === 1 ? "" : "s"} en attente`, `${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`)}
|
||||
style={{
|
||||
maxWidth: 180,
|
||||
overflow: "hidden",
|
||||
@ -1578,12 +1583,12 @@ function LeafView({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Queued · {inboxDepth}
|
||||
{tr("En attente", "Queued")} · {inboxDepth}
|
||||
</span>
|
||||
)}
|
||||
{busyByWorkState && inboxDepth > 0 && (
|
||||
<span
|
||||
title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
|
||||
title={tr(`${inboxDepth} message${inboxDepth === 1 ? "" : "s"} en file`, `${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: "1px solid var(--color-primary, #5b9bd5)",
|
||||
@ -1595,12 +1600,12 @@ function LeafView({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Queued {inboxDepth}
|
||||
{tr("En file", "Queued")} {inboxDepth}
|
||||
</span>
|
||||
)}
|
||||
{completedBackgroundTasks.length > 0 && (
|
||||
<span
|
||||
title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
|
||||
title={tr(`${completedBackgroundTasks.length} résultat${completedBackgroundTasks.length === 1 ? "" : "s"} de tâche de fond`, `${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: "1px solid var(--color-success, #57b36a)",
|
||||
@ -1612,7 +1617,7 @@ function LeafView({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Task done {completedBackgroundTasks.length}
|
||||
{tr("Tâche terminée", "Task done")} {completedBackgroundTasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -1654,11 +1659,11 @@ function LeafView({
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Chargement…
|
||||
{tr("Chargement…", "Loading…")}
|
||||
</div>
|
||||
) : agentId && stoppedAgentCell ? (
|
||||
<StoppedAgentCellPanel
|
||||
agentName={pinnedAgent?.name ?? "Agent"}
|
||||
agentName={pinnedAgent?.name ?? tr("Agent", "Agent")}
|
||||
restarting={restartPending}
|
||||
error={restartError}
|
||||
onRestart={() => void restartStoppedAgent()}
|
||||
@ -1702,10 +1707,10 @@ function LeafView({
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "var(--color-content, #e0e0e0)" }}>
|
||||
CLI custom indisponible
|
||||
{tr("CLI personnalisée indisponible", "Custom CLI unavailable")}
|
||||
</strong>
|
||||
<span>
|
||||
Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.
|
||||
{tr("Cette cellule reste configurée en CLI personnalisée. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.", "This cell remains configured for the custom CLI. Check the agent profile or temporarily use the native TUI.")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -1719,7 +1724,7 @@ function LeafView({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Utiliser la TUI native
|
||||
{tr("Utiliser la TUI native", "Use native TUI")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@ -1797,7 +1802,7 @@ function LeafView({
|
||||
data-testid="model-server-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="model server launch"
|
||||
aria-label={tr("Lancement du serveur de modèles", "Model server launch")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
@ -1833,7 +1838,7 @@ function LeafView({
|
||||
<div
|
||||
data-testid="model-server-progress"
|
||||
role="progressbar"
|
||||
aria-label="progression du téléchargement"
|
||||
aria-label={tr("Progression du téléchargement", "Download progress")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={
|
||||
@ -1948,7 +1953,7 @@ function LeafView({
|
||||
{busyNotice.goToNodeId && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="aller à la cellule"
|
||||
aria-label={tr("aller à la cellule", "go to cell")}
|
||||
onClick={() => {
|
||||
goToCell(busyNotice.goToNodeId!);
|
||||
setBusyNotice(null);
|
||||
@ -1964,7 +1969,7 @@ function LeafView({
|
||||
padding: "1px 6px",
|
||||
}}
|
||||
>
|
||||
Aller à la cellule
|
||||
{tr("Aller à la cellule", "Go to cell")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -1973,7 +1978,7 @@ function LeafView({
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label="Confirmer le changement de CLI"
|
||||
aria-label={tr("Confirmer le changement de CLI", "Confirm CLI change")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
@ -1998,18 +2003,18 @@ function LeafView({
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 14 }}>
|
||||
Changer de CLI agent
|
||||
{tr("Changer de CLI agent", "Change agent CLI")}
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
|
||||
IdeA va basculer cette cellule vers{" "}
|
||||
{tr("IdeA va basculer cette cellule vers", "IdeA will switch this cell to")}{" "}
|
||||
{pendingModeSwitch.target === "custom"
|
||||
? "la CLI custom"
|
||||
: "la TUI native"}
|
||||
{" "}et reprendre la session depuis cette surface.
|
||||
? tr("la CLI custom", "the custom CLI")
|
||||
: tr("la TUI native", "the native TUI")}
|
||||
{" "}{tr("et reprendre la session depuis cette surface.", "and resume the session from that surface.")}
|
||||
</p>
|
||||
{conversationId && (
|
||||
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
|
||||
Une reprise est possible si le profil et le backend conservent cette conversation.
|
||||
{tr("Une reprise est possible si le profil et le backend conservent cette conversation.", "The session can be resumed if the profile and backend retain this conversation.")}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
@ -2032,7 +2037,7 @@ function LeafView({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -2046,7 +2051,7 @@ function LeafView({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Basculer
|
||||
{tr("Basculer", "Switch")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -2056,7 +2061,7 @@ function LeafView({
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label="Redémarrer l’agent ?"
|
||||
aria-label={tr("Redémarrer l’agent ?", "Restart agent?")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
@ -2079,9 +2084,9 @@ function LeafView({
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 14 }}>Redémarrer l’agent ?</h2>
|
||||
<h2 style={{ margin: 0, fontSize: 14 }}>{tr("Redémarrer l’agent ?", "Restart agent?")}</h2>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 12 }}>
|
||||
L’activité en cours sera interrompue avant la relance de l’agent.
|
||||
{tr("L’activité en cours sera interrompue avant la relance de l’agent.", "Current activity will be interrupted before the agent restarts.")}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
@ -2095,10 +2100,10 @@ function LeafView({
|
||||
type="button"
|
||||
onClick={() => setRestartConfirmationOpen(false)}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</button>
|
||||
<button type="button" onClick={() => void restartCellAgent()}>
|
||||
Interrompre et redémarrer
|
||||
{tr("Interrompre et redémarrer", "Interrupt and restart")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,7 +47,7 @@ describe("LayoutTabs", () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderTabs(layout);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("créer un layout")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -85,10 +85,10 @@ describe("LayoutTabs", () => {
|
||||
fireEvent.dblClick(screen.getByText("Default"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/rename layout/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/renommer le layout/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText(/rename layout/i) as HTMLInputElement;
|
||||
const input = screen.getByLabelText(/renommer le layout/i) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "My Layout" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
@ -102,10 +102,10 @@ describe("LayoutTabs", () => {
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/delete layout/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/supprimer le layout/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const closeBtn = screen.getByLabelText(/delete layout/i) as HTMLButtonElement;
|
||||
const closeBtn = screen.getByLabelText(/supprimer le layout/i) as HTMLButtonElement;
|
||||
expect(closeBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
@ -130,7 +130,7 @@ describe("LayoutTabs", () => {
|
||||
expect(screen.getByText("Second")).toBeTruthy();
|
||||
});
|
||||
|
||||
const deleteBtns = screen.getAllByLabelText(/delete layout/i);
|
||||
const deleteBtns = screen.getAllByLabelText(/supprimer le layout/i);
|
||||
// Both tabs exist, pick the second (Second).
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteBtns[1]);
|
||||
|
||||
@ -17,6 +17,7 @@ import type { LayoutInfo } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
import { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
|
||||
import { useLayouts } from "./useLayouts";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface LayoutTabsProps {
|
||||
projectId: string;
|
||||
@ -31,6 +32,7 @@ interface LayoutTabsProps {
|
||||
}
|
||||
|
||||
export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useLayouts(projectId);
|
||||
|
||||
// Propagate the active layout (id + kind) to the parent on every change.
|
||||
@ -130,7 +132,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
setRenameValue("");
|
||||
}
|
||||
}}
|
||||
aria-label={`rename layout ${tab.name}`}
|
||||
aria-label={tr(`renommer le layout ${tab.name}`, `rename layout ${tab.name}`)}
|
||||
className="w-24 rounded border border-border bg-base px-1 py-0.5 text-sm text-content focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
) : (
|
||||
@ -149,9 +151,9 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
>
|
||||
{tab.kind === "gitGraph" && (
|
||||
<span
|
||||
aria-label="git graph layout"
|
||||
aria-label={tr("layout du graphe Git", "git graph layout")}
|
||||
className="text-xs text-primary"
|
||||
title="Git Graph"
|
||||
title={tr("Graphe Git", "Git graph")}
|
||||
>
|
||||
⎇
|
||||
</span>
|
||||
@ -161,7 +163,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`delete layout ${tab.name}`}
|
||||
aria-label={tr(`supprimer le layout ${tab.name}`, `delete layout ${tab.name}`)}
|
||||
disabled={vm.layouts.length <= 1}
|
||||
onClick={() => void handleClose(tab.id)}
|
||||
className={cn(
|
||||
@ -181,7 +183,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="create layout"
|
||||
aria-label={tr("créer un layout", "create layout")}
|
||||
onClick={() => setShowCreateMenu((v) => !v)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded border border-transparent text-sm text-muted transition-colors hover:border-border hover:text-content"
|
||||
>
|
||||
@ -195,7 +197,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="create terminal layout"
|
||||
aria-label={tr("créer un layout terminal", "create terminal layout")}
|
||||
onClick={() => void handleCreate("terminal")}
|
||||
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
@ -204,11 +206,11 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="create git graph layout"
|
||||
aria-label={tr("créer un layout de graphe Git", "create git graph layout")}
|
||||
onClick={() => void handleCreate("gitGraph")}
|
||||
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
⎇ Git graph
|
||||
⎇ {tr("Graphe Git", "Git graph")}
|
||||
</button>
|
||||
<PluginLayoutSelectorSection
|
||||
registry={pluginRegistry}
|
||||
|
||||
@ -84,14 +84,14 @@ describe("LayoutTabs — git graph kind", () => {
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("créer un layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("create layout"));
|
||||
fireEvent.click(screen.getByLabelText("créer un layout"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create terminal layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("create git graph layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("créer un layout terminal")).toBeTruthy();
|
||||
expect(screen.getByLabelText("créer un layout de graphe Git")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -134,10 +134,10 @@ describe("LayoutTabs — git graph kind", () => {
|
||||
renderTabs(layout, "p1", onActiveLayoutChange, registry);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("créer un layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("create layout"));
|
||||
fireEvent.click(screen.getByLabelText("créer un layout"));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: /Android Health/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
@ -172,7 +172,7 @@ describe("LayoutTabs — git graph kind", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// The aria-label "git graph layout" should be present.
|
||||
expect(screen.getByLabelText("git graph layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("layout du graphe Git")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -185,6 +185,6 @@ describe("LayoutTabs — git graph kind", () => {
|
||||
expect(screen.getByText("Default")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText("git graph layout")).toBeNull();
|
||||
expect(screen.queryByLabelText("layout du graphe Git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -244,9 +244,9 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
// Close the SECOND cell: keep the first, drop the second. Closing a cell is
|
||||
// now a detach-only view operation; it must not kill the PTY.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy();
|
||||
expect(screen.getByLabelText(`Fermer ${droppedId}`)).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText(`close ${droppedId}`));
|
||||
fireEvent.click(screen.getByLabelText(`Fermer ${droppedId}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
|
||||
|
||||
@ -292,7 +292,7 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
||||
|
||||
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
|
||||
const selectA = (await screen.findByLabelText(
|
||||
`agent selector ${a}`,
|
||||
`Sélecteur d’agent ${a}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
|
||||
@ -397,7 +397,7 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
|
||||
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
|
||||
// live nodeId still pointing at the now-visible cell A).
|
||||
const selectB = (await screen.findByLabelText(
|
||||
`agent selector ${b}`,
|
||||
`Sélecteur d’agent ${b}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
||||
@ -439,7 +439,7 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const selectB = (await screen.findByLabelText(
|
||||
`agent selector ${b}`,
|
||||
`Sélecteur d’agent ${b}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
||||
|
||||
@ -15,6 +15,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import type { MemoryIndexEntry, MemoryLink, MemoryType } from "@/domain";
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ["user", "feedback", "project", "reference"];
|
||||
|
||||
@ -57,6 +58,7 @@ export function MemoryEditor({
|
||||
onClose,
|
||||
busy = false,
|
||||
}: MemoryEditorProps) {
|
||||
const { tr } = useI18n();
|
||||
const editing = entry != null;
|
||||
|
||||
const [name, setName] = useState(entry?.title ?? "");
|
||||
@ -115,17 +117,17 @@ export function MemoryEditor({
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="memory editor"
|
||||
aria-label={tr("éditeur de mémoire", "memory editor")}
|
||||
>
|
||||
{/* ── Header bar ── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-content">
|
||||
{editing ? `Edit note — ${entry.title}` : "New note"}
|
||||
{editing ? tr(`Modifier la note — ${entry.title}`, `Edit note — ${entry.title}`) : tr("Nouvelle note", "New note")}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="close memory editor"
|
||||
aria-label={tr("fermer l'éditeur de mémoire", "close memory editor")}
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
@ -150,12 +152,12 @@ export function MemoryEditor({
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label htmlFor="me-name" className="text-xs font-medium text-muted">
|
||||
Name
|
||||
{tr("Nom", "Name")}
|
||||
</label>
|
||||
<Input
|
||||
id="me-name"
|
||||
aria-label="memory name"
|
||||
placeholder="My note"
|
||||
aria-label={tr("nom de la mémoire", "memory name")}
|
||||
placeholder={tr("Ma note", "My note")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={busy}
|
||||
@ -166,11 +168,11 @@ export function MemoryEditor({
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label htmlFor="me-type" className="text-xs font-medium text-muted">
|
||||
Type
|
||||
{tr("Type", "Type")}
|
||||
</label>
|
||||
<select
|
||||
id="me-type"
|
||||
aria-label="memory type"
|
||||
aria-label={tr("type de mémoire", "memory type")}
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as MemoryType)}
|
||||
disabled={busy}
|
||||
@ -188,12 +190,12 @@ export function MemoryEditor({
|
||||
{/* ── Description ── */}
|
||||
<div className="flex shrink-0 flex-col gap-1 border-b border-border px-4 py-3">
|
||||
<label htmlFor="me-description" className="text-xs font-medium text-muted">
|
||||
Description
|
||||
{tr("Description", "Description")}
|
||||
</label>
|
||||
<Input
|
||||
id="me-description"
|
||||
aria-label="memory description"
|
||||
placeholder="One-line hook"
|
||||
aria-label={tr("description de la mémoire", "memory description")}
|
||||
placeholder={tr("Résumé en une ligne", "One-line summary")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={busy}
|
||||
@ -203,11 +205,11 @@ export function MemoryEditor({
|
||||
{/* ── Content ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
|
||||
<label htmlFor="me-content" className="mb-1 text-xs font-medium text-muted">
|
||||
Content
|
||||
{tr("Contenu", "Content")}
|
||||
</label>
|
||||
<textarea
|
||||
id="me-content"
|
||||
aria-label="memory content"
|
||||
aria-label={tr("contenu de la mémoire", "memory content")}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={busy}
|
||||
@ -217,17 +219,17 @@ export function MemoryEditor({
|
||||
"focus:border-primary placeholder:text-faint",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
placeholder="# Note Use [[other-slug]] to link…"
|
||||
placeholder={tr("# Note\n\nUtilisez [[autre-slug]] pour créer un lien…", "# Note\n\nUse [[other-slug]] to link…")}
|
||||
/>
|
||||
|
||||
{/* ── Resolved links (edit-mode, read-only) ── */}
|
||||
{editing && (
|
||||
<div className="mt-3 shrink-0">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Links
|
||||
{tr("Liens", "Links")}
|
||||
</h4>
|
||||
{links.length === 0 ? (
|
||||
<p className="mt-1 text-sm text-muted">No resolved links.</p>
|
||||
<p className="mt-1 text-sm text-muted">{tr("Aucun lien résolu.", "No resolved links.")}</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{links.map((slug) => (
|
||||
@ -247,16 +249,16 @@ export function MemoryEditor({
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
aria-label="Save note"
|
||||
aria-label={tr("Enregistrer la note", "Save note")}
|
||||
disabled={!canSave}
|
||||
loading={busy}
|
||||
>
|
||||
Save
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -21,6 +21,7 @@ import { useGateways } from "@/app/di";
|
||||
import { useEmbedder } from "@/features/embedder";
|
||||
import { MemoryEditor } from "./MemoryEditor";
|
||||
import { useMemory } from "./useMemory";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export interface MemoryPanelProps {
|
||||
/** The project whose memory notes to manage. */
|
||||
@ -31,6 +32,7 @@ export interface MemoryPanelProps {
|
||||
type EditorState = { mode: "create" } | { mode: "edit"; entry: MemoryIndexEntry };
|
||||
|
||||
export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useMemory(projectId);
|
||||
const embedderVm = useEmbedder();
|
||||
const { memory } = useGateways();
|
||||
@ -88,20 +90,20 @@ export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel title="Memory" className="flex flex-col gap-0">
|
||||
<Panel title={tr("Mémoire", "Memory")} className="flex flex-col gap-0">
|
||||
{/* ── Active recall tier (read-only transparency) ── */}
|
||||
<p
|
||||
className="border-b border-border px-4 py-2 text-xs text-faint"
|
||||
data-testid="memory-tier"
|
||||
>
|
||||
Tier:{" "}
|
||||
{tr("Niveau :", "Tier:")}{" "}
|
||||
{embedderVm.active ? (
|
||||
<span className="text-content">
|
||||
Vector — {embedderVm.active.strategy}
|
||||
{embedderVm.active.model ? ` ${embedderVm.active.model}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content">Naïve (None)</span>
|
||||
<span className="text-content">{tr("Naïve (aucun)", "Naive (none)")}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@ -117,18 +119,18 @@ export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||
{/* ── Create button ── */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Notes
|
||||
{tr("Notes", "Notes")}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
aria-label="create memory"
|
||||
aria-label={tr("créer une mémoire", "create memory")}
|
||||
disabled={vm.busy}
|
||||
onClick={() => setEditorState({ mode: "create" })}
|
||||
>
|
||||
New note
|
||||
{tr("Nouvelle note", "New note")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -136,7 +138,7 @@ export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||
{/* ── Index list ── */}
|
||||
<div className="p-4">
|
||||
{vm.entries.length === 0 ? (
|
||||
<p className="text-sm text-muted">No memory notes yet.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucune note de mémoire.", "No memory notes yet.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.entries.map((e) => (
|
||||
@ -159,21 +161,21 @@ export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`edit memory ${e.title}`}
|
||||
aria-label={tr(`modifier la mémoire ${e.title}`, `edit memory ${e.title}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => setEditorState({ mode: "edit", entry: e })}
|
||||
>
|
||||
Edit
|
||||
{tr("Modifier", "Edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`delete memory ${e.title}`}
|
||||
aria-label={tr(`supprimer la mémoire ${e.title}`, `delete memory ${e.title}`)}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.deleteMemory(e.slug)}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
Delete
|
||||
{tr("Supprimer", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@ -55,7 +55,7 @@ async function waitForMemoryIdle() {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", { name: "create memory" }) as HTMLButtonElement
|
||||
screen.getByRole("button", { name: "créer une mémoire" }) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
@ -69,21 +69,21 @@ async function createMemory(
|
||||
type: "user" | "feedback" | "project" | "reference" = "project",
|
||||
) {
|
||||
await waitForMemoryIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("memory name")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("memory name"), {
|
||||
fireEvent.click(screen.getByRole("button", { name: "créer une mémoire" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("nom de la mémoire")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("nom de la mémoire"), {
|
||||
target: { value: name },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||
fireEvent.change(screen.getByLabelText("description de la mémoire"), {
|
||||
target: { value: description },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory type"), {
|
||||
fireEvent.change(screen.getByLabelText("type de mémoire"), {
|
||||
target: { value: type },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||
fireEvent.change(screen.getByLabelText("contenu de la mémoire"), {
|
||||
target: { value: content },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer la note" }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -94,7 +94,7 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
it("shows the empty state initially", async () => {
|
||||
renderMemoryPanel();
|
||||
await waitForMemoryIdle();
|
||||
expect(screen.getByText("No memory notes yet.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucune note de mémoire.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the index (title + hook + type) on mount", async () => {
|
||||
@ -141,27 +141,27 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
it("Save is disabled until description and content are filled", async () => {
|
||||
renderMemoryPanel();
|
||||
await waitForMemoryIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "créer une mémoire" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Save note" })).toBeTruthy(),
|
||||
expect(screen.getByRole("button", { name: "Enregistrer la note" })).toBeTruthy(),
|
||||
);
|
||||
const save = screen.getByRole("button", {
|
||||
name: "Save note",
|
||||
name: "Enregistrer la note",
|
||||
}) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
// Filling everything enables Save.
|
||||
fireEvent.change(screen.getByLabelText("memory name"), {
|
||||
fireEvent.change(screen.getByLabelText("nom de la mémoire"), {
|
||||
target: { value: "X" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||
fireEvent.change(screen.getByLabelText("description de la mémoire"), {
|
||||
target: { value: "h" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||
fireEvent.change(screen.getByLabelText("contenu de la mémoire"), {
|
||||
target: { value: "c" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Save note" }) as HTMLButtonElement)
|
||||
(screen.getByRole("button", { name: "Enregistrer la note" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
@ -172,24 +172,24 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
await screen.findByText("Editable Note");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "edit memory Editable Note" }),
|
||||
screen.getByRole("button", { name: "modifier la mémoire Editable Note" }),
|
||||
);
|
||||
|
||||
// Slug shown read-only — there's no editable "memory name" input in edit-mode.
|
||||
await waitFor(() => expect(screen.getByText("editable-note")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("memory name")).toBeNull();
|
||||
expect(screen.queryByLabelText("nom de la mémoire")).toBeNull();
|
||||
|
||||
// Description / type / content are editable.
|
||||
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||
fireEvent.change(screen.getByLabelText("description de la mémoire"), {
|
||||
target: { value: "new hook" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory type"), {
|
||||
fireEvent.change(screen.getByLabelText("type de mémoire"), {
|
||||
target: { value: "feedback" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||
fireEvent.change(screen.getByLabelText("contenu de la mémoire"), {
|
||||
target: { value: "new body" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer la note" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const note = await memory.getMemory(PROJECT_ID, "editable-note");
|
||||
@ -205,7 +205,7 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
await screen.findByText("To Delete");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "delete memory To Delete" }),
|
||||
screen.getByRole("button", { name: "supprimer la mémoire To Delete" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("To Delete")).toBeNull());
|
||||
});
|
||||
@ -231,7 +231,7 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
renderMemoryPanel(memory);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "edit memory Source Note" }),
|
||||
await screen.findByRole("button", { name: "modifier la mémoire Source Note" }),
|
||||
);
|
||||
|
||||
// The resolved link to the existing note appears; the broken one does not.
|
||||
|
||||
@ -21,6 +21,7 @@ import type {
|
||||
StopPolicy,
|
||||
} from "@/domain";
|
||||
import { Button, IconButton, Input, Panel, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { ModelServersViewModel } from "./useModelServers";
|
||||
import {
|
||||
deriveServedModelName,
|
||||
@ -42,11 +43,15 @@ function Hint({ children }: { children: React.ReactNode }) {
|
||||
return <small className="text-xs text-muted">{children}</small>;
|
||||
}
|
||||
|
||||
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||||
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
||||
{ value: "keepAlive", label: "Keep alive" },
|
||||
{ value: "stopWhenUnused", label: "Stop when unused" },
|
||||
];
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
function stopPolicies(tr: Tr): { value: StopPolicy; label: string }[] {
|
||||
return [
|
||||
{ value: "stopOnAppExit", label: tr("Arrêter à la fermeture de l’application", "Stop on app exit") },
|
||||
{ value: "keepAlive", label: tr("Garder actif", "Keep alive") },
|
||||
{ value: "stopWhenUnused", label: tr("Arrêter si inutilisé", "Stop when unused") },
|
||||
];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
||||
@ -64,13 +69,14 @@ function formatBytes(bytes: number): string {
|
||||
function deleteArtifactConfirmation(
|
||||
server: LocalModelServerConfig,
|
||||
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
|
||||
tr: Tr,
|
||||
): string {
|
||||
const size =
|
||||
artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`;
|
||||
artifact.sizeBytes == null ? "" : `${tr("Espace libéré", "Space freed")}: ${formatBytes(artifact.sizeBytes)}.`;
|
||||
return [
|
||||
"Supprimer le modèle téléchargé ?",
|
||||
`Serveur : ${server.name}`,
|
||||
"Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.",
|
||||
tr("Supprimer le modèle téléchargé ?", "Delete downloaded model?"),
|
||||
`${tr("Serveur", "Server")}: ${server.name}`,
|
||||
tr("Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.", "The local server will remain configured, but IdeA will have to download this model again on the next launch."),
|
||||
size,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@ -83,6 +89,7 @@ export interface ModelServersPanelProps {
|
||||
}
|
||||
|
||||
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
|
||||
// no editor open (list-only).
|
||||
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
|
||||
@ -108,25 +115,25 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
server: LocalModelServerConfig,
|
||||
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
|
||||
) {
|
||||
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
|
||||
if (!window.confirm(deleteArtifactConfirmation(server, artifact, tr))) return;
|
||||
await vm.deleteArtifact(server.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="local model servers"
|
||||
aria-label={tr("Serveurs de modèles locaux", "Local model servers")}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-content">
|
||||
Local model servers
|
||||
{tr("Serveurs de modèles locaux", "Local model servers")}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label="add model server"
|
||||
aria-label={tr("Ajouter un serveur de modèles", "Add model server")}
|
||||
onClick={startAdd}
|
||||
className="ml-auto"
|
||||
>
|
||||
Add server
|
||||
{tr("Ajouter un serveur", "Add server")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@ -145,8 +152,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
|
||||
{vm.servers.length === 0 && !draft && (
|
||||
<p className="text-xs text-muted">
|
||||
No local server declared yet. Add one to auto-manage a `llama-server`,
|
||||
or keep using an external endpoint.
|
||||
{tr("Aucun serveur local déclaré. Ajoutez-en un pour gérer automatiquement un `llama-server`, ou continuez à utiliser un point d’accès externe.", "No local server declared yet. Add one to auto-manage a `llama-server`, or keep using an external endpoint.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -168,7 +174,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{server.baseURL} · {server.servedModelName}
|
||||
{server.autoStart ? " · auto-start" : ""}
|
||||
{server.autoStart ? ` · ${tr("démarrage automatique", "auto-start")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
@ -176,35 +182,35 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-label={`download in progress ${server.name}`}
|
||||
aria-label={tr(`Téléchargement en cours pour ${server.name}`, `Download in progress for ${server.name}`)}
|
||||
disabled
|
||||
>
|
||||
Téléchargement en cours
|
||||
{tr("Téléchargement en cours", "Downloading")}
|
||||
</Button>
|
||||
)}
|
||||
{downloaded && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
aria-label={`delete downloaded model ${server.name}`}
|
||||
aria-label={tr(`Supprimer le modèle téléchargé de ${server.name}`, `Delete downloaded model for ${server.name}`)}
|
||||
onClick={() => void confirmDeleteArtifact(server, downloaded)}
|
||||
loading={deletingArtifact}
|
||||
disabled={vm.busy && !deletingArtifact}
|
||||
>
|
||||
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
|
||||
{deletingArtifact ? tr("Suppression…", "Deleting…") : tr("Supprimer le modèle téléchargé", "Delete downloaded model")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
aria-label={tr(`Modifier ${server.name}`, `Edit ${server.name}`)}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
{tr("Modifier", "Edit")}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
aria-label={tr(`Supprimer ${server.name}`, `Delete ${server.name}`)}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
@ -242,6 +248,7 @@ function SourceTabs({
|
||||
kind: ModelSourceKind;
|
||||
onSelect: (kind: ModelSourceKind) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const tab = (value: ModelSourceKind, label: string) => {
|
||||
const active = kind === value;
|
||||
return (
|
||||
@ -263,11 +270,11 @@ function SourceTabs({
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="source du modèle"
|
||||
aria-label={tr("Source du modèle", "Model source")}
|
||||
className="flex gap-1 rounded-md border border-border p-1"
|
||||
>
|
||||
{tab("huggingFace", "Télécharger depuis Hugging Face")}
|
||||
{tab("localPath", "Fichier .gguf local")}
|
||||
{tab("huggingFace", tr("Télécharger depuis Hugging Face", "Download from Hugging Face"))}
|
||||
{tab("localPath", tr("Fichier .gguf local", "Local .gguf file"))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -280,8 +287,9 @@ function CommandPreview({
|
||||
preview: ModelServerCommandPreview | null;
|
||||
autoStart: boolean;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const title = autoStart ? "Commande générée" : "Commande à lancer manuellement";
|
||||
const title = autoStart ? tr("Commande générée", "Generated command") : tr("Commande à lancer manuellement", "Command to run manually");
|
||||
|
||||
async function copy() {
|
||||
if (!preview) return;
|
||||
@ -296,7 +304,7 @@ function CommandPreview({
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="command preview"
|
||||
aria-label={tr("Aperçu de la commande", "Command preview")}
|
||||
className="flex flex-col gap-1 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -304,24 +312,24 @@ function CommandPreview({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="copier la commande"
|
||||
aria-label={tr("Copier la commande", "Copy command")}
|
||||
onClick={() => void copy()}
|
||||
disabled={!preview}
|
||||
className="ml-auto"
|
||||
>
|
||||
{copied ? "Copié" : "Copier"}
|
||||
{copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{preview ? (
|
||||
<code
|
||||
aria-label="preview command line"
|
||||
aria-label={tr("Ligne de commande prévisualisée", "Command line preview")}
|
||||
className="whitespace-pre-wrap break-all font-mono text-xs text-content"
|
||||
>
|
||||
{preview.display}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-xs text-muted">
|
||||
Renseigne une source de modèle pour voir la commande.
|
||||
{tr("Renseignez une source de modèle pour voir la commande.", "Enter a model source to see the command.")}
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
@ -346,6 +354,7 @@ function ServerEditor({
|
||||
onCancel: () => void;
|
||||
onSubmit: (nextDraft?: LocalModelServerConfig) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const errors: ModelServerErrors = validateModelServer(draft);
|
||||
const valid = Object.keys(errors).length === 0;
|
||||
const patch = (next: Partial<LocalModelServerConfig>) =>
|
||||
@ -431,17 +440,17 @@ function ServerEditor({
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
aria-label="model server editor"
|
||||
aria-label={tr("Éditeur de serveur de modèles", "Model server editor")}
|
||||
className="flex flex-col gap-3 rounded-md border border-primary/40 p-3"
|
||||
>
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
||||
{draft.name.trim().length > 0 ? draft.name : tr("Nouveau serveur", "New server")}
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Nom</Caption>
|
||||
<Caption>{tr("Nom", "Name")}</Caption>
|
||||
<Input
|
||||
aria-label="server name"
|
||||
aria-label={tr("Nom du serveur", "Server name")}
|
||||
value={draft.name}
|
||||
invalid={Boolean(errors.name)}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
@ -451,15 +460,15 @@ function ServerEditor({
|
||||
|
||||
{/* --- Source du modèle --------------------------------------------- */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Caption>Source du modèle</Caption>
|
||||
<Caption>{tr("Source du modèle", "Model source")}</Caption>
|
||||
<SourceTabs kind={sourceKind} onSelect={selectKind} />
|
||||
</div>
|
||||
|
||||
{sourceKind === "huggingFace" ? (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle Hugging Face</Caption>
|
||||
<Caption>{tr("Modèle Hugging Face", "Hugging Face model")}</Caption>
|
||||
<Input
|
||||
aria-label="hugging face model"
|
||||
aria-label={tr("Modèle Hugging Face", "Hugging Face model")}
|
||||
value={hfRepo}
|
||||
placeholder="unsloth/Qwen3.5-9B-GGUF:Q4_K_M"
|
||||
invalid={Boolean(errors.modelSource)}
|
||||
@ -472,16 +481,16 @@ function ServerEditor({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Hint>llama.cpp téléchargera/cache le fichier automatiquement.</Hint>
|
||||
<Hint>{tr("llama.cpp téléchargera et mettra le fichier en cache automatiquement.", "llama.cpp will download and cache the file automatically.")}</Hint>
|
||||
{errors.modelSource && (
|
||||
<small className="text-xs text-danger">{errors.modelSource}</small>
|
||||
)}
|
||||
</label>
|
||||
) : (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Fichier .gguf</Caption>
|
||||
<Caption>{tr("Fichier .gguf", ".gguf file")}</Caption>
|
||||
<Input
|
||||
aria-label="local gguf file"
|
||||
aria-label={tr("Fichier .gguf local", "Local .gguf file")}
|
||||
value={localPath}
|
||||
placeholder="/home/me/models/qwen.gguf"
|
||||
invalid={Boolean(errors.modelSource)}
|
||||
@ -494,7 +503,7 @@ function ServerEditor({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Hint>Chemin absolu vers un .gguf déjà présent.</Hint>
|
||||
<Hint>{tr("Chemin absolu vers un fichier .gguf déjà présent.", "Absolute path to an existing .gguf file.")}</Hint>
|
||||
{errors.modelSource && (
|
||||
<small className="text-xs text-danger">{errors.modelSource}</small>
|
||||
)}
|
||||
@ -503,15 +512,15 @@ function ServerEditor({
|
||||
|
||||
{/* --- Nom du modèle dans IdeA -------------------------------------- */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Nom du modèle dans IdeA</Caption>
|
||||
<Caption>{tr("Nom du modèle dans IdeA", "Model name in IdeA")}</Caption>
|
||||
<Input
|
||||
aria-label="server served model name"
|
||||
aria-label={tr("Nom du modèle servi", "Served model name")}
|
||||
value={draft.servedModelName}
|
||||
placeholder="qwen3-coder-30b"
|
||||
invalid={Boolean(errors.servedModelName)}
|
||||
onChange={(e) => patch({ servedModelName: e.target.value })}
|
||||
/>
|
||||
<Hint>Nom envoyé aux clients OpenAI-compatible.</Hint>
|
||||
<Hint>{tr("Nom envoyé aux clients compatibles OpenAI.", "Name sent to OpenAI-compatible clients.")}</Hint>
|
||||
{errors.servedModelName && (
|
||||
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
||||
)}
|
||||
@ -520,9 +529,9 @@ function ServerEditor({
|
||||
{/* --- Serveur ------------------------------------------------------ */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||
<Caption>Commande serveur</Caption>
|
||||
<Caption>{tr("Commande du serveur", "Server command")}</Caption>
|
||||
<Input
|
||||
aria-label="server binary path"
|
||||
aria-label={tr("Chemin de l’exécutable du serveur", "Server binary path")}
|
||||
value={draft.binaryPath ?? ""}
|
||||
placeholder="llama-server"
|
||||
onChange={(e) => {
|
||||
@ -530,13 +539,13 @@ function ServerEditor({
|
||||
patch({ binaryPath: v.length === 0 ? undefined : v });
|
||||
}}
|
||||
/>
|
||||
<Hint>Laisse vide pour résoudre `llama-server` via le PATH.</Hint>
|
||||
<Hint>{tr("Laissez vide pour trouver `llama-server` via le PATH.", "Leave empty to resolve `llama-server` through PATH.")}</Hint>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[6rem] flex-col gap-1">
|
||||
<Caption>Port</Caption>
|
||||
<Caption>{tr("Port", "Port")}</Caption>
|
||||
<Input
|
||||
aria-label="server port"
|
||||
aria-label={tr("Port du serveur", "Server port")}
|
||||
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.port)}
|
||||
@ -547,9 +556,9 @@ function ServerEditor({
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Base URL (OpenAI-compatible)</Caption>
|
||||
<Caption>{tr("URL de base (compatible OpenAI)", "Base URL (OpenAI-compatible)")}</Caption>
|
||||
<Input
|
||||
aria-label="server base url"
|
||||
aria-label={tr("URL de base du serveur", "Server base URL")}
|
||||
value={draft.baseURL}
|
||||
placeholder="http://localhost:8080/v1"
|
||||
invalid={Boolean(errors.baseURL)}
|
||||
@ -563,16 +572,16 @@ function ServerEditor({
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="server auto start"
|
||||
aria-label={tr("Démarrage automatique du serveur", "Server auto start")}
|
||||
checked={draft.autoStart}
|
||||
disabled={!draft.modelSource}
|
||||
onChange={(e) => patch({ autoStart: e.target.checked })}
|
||||
className="mt-1 accent-primary disabled:opacity-50"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<Caption>Démarrer automatiquement</Caption>
|
||||
<Caption>{tr("Démarrer automatiquement", "Start automatically")}</Caption>
|
||||
{!draft.modelSource && (
|
||||
<Hint>Renseigne d'abord une source de modèle pour l'activer.</Hint>
|
||||
<Hint>{tr("Renseignez d’abord une source de modèle pour l’activer.", "Enter a model source first to enable it.")}</Hint>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
@ -580,16 +589,16 @@ function ServerEditor({
|
||||
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
|
||||
<details className="rounded-md border border-border p-2">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted">
|
||||
Réglages llama.cpp
|
||||
{tr("Réglages llama.cpp", "llama.cpp settings")}
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>
|
||||
Couches GPU <span className="text-muted">(-ngl)</span>
|
||||
{tr("Couches GPU", "GPU layers")} <span className="text-muted">(-ngl)</span>
|
||||
</Caption>
|
||||
<Input
|
||||
aria-label="gpu layers"
|
||||
aria-label={tr("Couches GPU", "GPU layers")}
|
||||
value={draft.gpuLayers === undefined ? "" : String(draft.gpuLayers)}
|
||||
inputMode="numeric"
|
||||
placeholder="99"
|
||||
@ -600,15 +609,15 @@ function ServerEditor({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Hint>0 = CPU seul.</Hint>
|
||||
<Hint>{tr("0 = processeur uniquement.", "0 = CPU only.")}</Hint>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>
|
||||
Taille de contexte <span className="text-muted">(-c)</span>
|
||||
{tr("Taille du contexte", "Context size")} <span className="text-muted">(-c)</span>
|
||||
</Caption>
|
||||
<Input
|
||||
aria-label="context size"
|
||||
aria-label={tr("Taille du contexte", "Context size")}
|
||||
value={
|
||||
draft.contextSize === undefined ? "" : String(draft.contextSize)
|
||||
}
|
||||
@ -627,10 +636,10 @@ function ServerEditor({
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>
|
||||
Adresse d'écoute <span className="text-muted">(--host)</span>
|
||||
{tr("Adresse d’écoute", "Listen address")} <span className="text-muted">(--host)</span>
|
||||
</Caption>
|
||||
<Input
|
||||
aria-label="listen host"
|
||||
aria-label={tr("Adresse d’écoute", "Listen address")}
|
||||
value={draft.host}
|
||||
placeholder="127.0.0.1"
|
||||
invalid={Boolean(errors.host)}
|
||||
@ -644,20 +653,20 @@ function ServerEditor({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="jinja template"
|
||||
aria-label={tr("Modèle de discussion Jinja", "Jinja chat template")}
|
||||
checked={draft.jinja}
|
||||
onChange={(e) => patch({ jinja: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>
|
||||
Template chat Jinja <span className="text-muted">(--jinja)</span>
|
||||
{tr("Modèle de discussion Jinja", "Jinja chat template")} <span className="text-muted">(--jinja)</span>
|
||||
</Caption>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Arguments supplémentaires</Caption>
|
||||
<Caption>{tr("Arguments supplémentaires", "Extra arguments")}</Caption>
|
||||
<Input
|
||||
aria-label="extra arguments"
|
||||
aria-label={tr("Arguments supplémentaires", "Extra arguments")}
|
||||
value={argsText}
|
||||
placeholder="--flash-attn --parallel 2"
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
@ -666,16 +675,15 @@ function ServerEditor({
|
||||
/>
|
||||
{reservedHits.length > 0 && (
|
||||
<small className="text-xs text-warning" role="status">
|
||||
{reservedHits.join(", ")} : cette option est déjà pilotée par un
|
||||
champ ci-dessus.
|
||||
{reservedHits.join(", ")} : {tr("cette option est déjà pilotée par un champ ci-dessus.", "this option is already controlled by a field above.")}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Caption>Stop policy</Caption>
|
||||
<Caption>{tr("Politique d’arrêt", "Stop policy")}</Caption>
|
||||
<select
|
||||
aria-label="server stop policy"
|
||||
aria-label={tr("Politique d’arrêt du serveur", "Server stop policy")}
|
||||
value={draft.stopPolicy}
|
||||
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
||||
className={cn(
|
||||
@ -683,7 +691,7 @@ function ServerEditor({
|
||||
"border border-border outline-none focus:border-primary",
|
||||
)}
|
||||
>
|
||||
{STOP_POLICIES.map((p) => (
|
||||
{stopPolicies(tr).map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
@ -699,20 +707,20 @@ function ServerEditor({
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label="save model server"
|
||||
aria-label={tr("Enregistrer le serveur de modèles", "Save model server")}
|
||||
onClick={() => onSubmit(draftWithCommittedArgs())}
|
||||
disabled={busy || !valid}
|
||||
>
|
||||
Save server
|
||||
{tr("Enregistrer le serveur", "Save server")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="cancel model server"
|
||||
aria-label={tr("Annuler la modification du serveur", "Cancel model server")}
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -238,16 +238,16 @@ function renderPanelInsideSpaceTrap(modelServer = new MockModelServerGateway())
|
||||
describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
it("declares a Hugging Face server end-to-end with a V2 payload", async () => {
|
||||
const { modelServer } = renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("server name"), {
|
||||
fireEvent.change(screen.getByLabelText("Nom du serveur"), {
|
||||
target: { value: "Local A" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
|
||||
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
||||
const save = screen.getByLabelText("Enregistrer le serveur de modèles") as HTMLButtonElement;
|
||||
await waitFor(() => expect(save.disabled).toBe(false));
|
||||
fireEvent.click(save);
|
||||
|
||||
@ -264,37 +264,37 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
expect(saved.host).toBe("127.0.0.1");
|
||||
expect(saved.jinja).toBe(false);
|
||||
expect("modelPath" in saved).toBe(false);
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Modifier Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles between the Hugging Face and local .gguf source fields", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
|
||||
// HF tab is the default.
|
||||
expect(screen.getByLabelText("hugging face model")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("local gguf file")).toBeNull();
|
||||
expect(screen.getByLabelText("Modèle Hugging Face")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Fichier .gguf local")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /fichier \.gguf local/i }));
|
||||
expect(screen.getByLabelText("local gguf file")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("hugging face model")).toBeNull();
|
||||
expect(screen.getByLabelText("Fichier .gguf local")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Modèle Hugging Face")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefills the served model name from the source when left blank", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText("server served model name") as HTMLInputElement).value,
|
||||
(screen.getByLabelText("Nom du modèle servi") as HTMLInputElement).value,
|
||||
).toBe("Qwen3.5-9B-GGUF");
|
||||
});
|
||||
|
||||
it("mirrors a soft warning when a reserved flag is typed in the extra args", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.change(screen.getByLabelText("extra arguments"), {
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
fireEvent.change(screen.getByLabelText("Arguments supplémentaires"), {
|
||||
target: { value: "--host 0.0.0.0" },
|
||||
});
|
||||
expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy();
|
||||
@ -302,22 +302,22 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
|
||||
it("keeps extra-args spaces while typing and only parses them on save", async () => {
|
||||
const { modelServer } = renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("server name"), {
|
||||
fireEvent.change(screen.getByLabelText("Nom du serveur"), {
|
||||
target: { value: "Spaced Args" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
const args = screen.getByLabelText("extra arguments") as HTMLInputElement;
|
||||
const args = screen.getByLabelText("Arguments supplémentaires") as HTMLInputElement;
|
||||
fireEvent.change(args, {
|
||||
target: { value: "--flash-attn --parallel 2 " },
|
||||
});
|
||||
|
||||
expect(args.value).toBe("--flash-attn --parallel 2 ");
|
||||
|
||||
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
||||
const save = screen.getByLabelText("Enregistrer le serveur de modèles") as HTMLButtonElement;
|
||||
await waitFor(() => expect(save.disabled).toBe(false));
|
||||
fireEvent.click(save);
|
||||
|
||||
@ -329,9 +329,9 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
|
||||
it("does not let surrounding keyboard handlers swallow spaces in the extra args field", () => {
|
||||
renderPanelInsideSpaceTrap();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
|
||||
const args = screen.getByLabelText("extra arguments") as HTMLInputElement;
|
||||
const args = screen.getByLabelText("Arguments supplémentaires") as HTMLInputElement;
|
||||
const accepted = fireEvent.keyDown(args, {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
@ -343,25 +343,25 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
|
||||
it("blocks auto-start until a model source is set", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
const autoStart = screen.getByLabelText("server auto start") as HTMLInputElement;
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
const autoStart = screen.getByLabelText("Démarrage automatique du serveur") as HTMLInputElement;
|
||||
expect(autoStart.disabled).toBe(true);
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText("server auto start") as HTMLInputElement).disabled,
|
||||
(screen.getByLabelText("Démarrage automatique du serveur") as HTMLInputElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the backend-built command preview after a source is entered", async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
|
||||
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
|
||||
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||
});
|
||||
const line = (await screen.findByLabelText(
|
||||
"preview command line",
|
||||
"Ligne de commande prévisualisée",
|
||||
)) as HTMLElement;
|
||||
expect(line.textContent).toMatch(/-hf unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
|
||||
expect(line.textContent).toMatch(/--port 8080/);
|
||||
@ -373,13 +373,13 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
modelServer.markInUse(SERVER.id);
|
||||
renderPanel(modelServer);
|
||||
|
||||
await screen.findByLabelText("edit Local A");
|
||||
fireEvent.click(screen.getByLabelText("delete Local A"));
|
||||
await screen.findByLabelText("Modifier Local A");
|
||||
fireEvent.click(screen.getByLabelText("Supprimer Local A"));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/encore utilisé par un profil/i,
|
||||
);
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Modifier Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => {
|
||||
@ -395,7 +395,7 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderPanel(modelServer);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
|
||||
fireEvent.click(await screen.findByLabelText("Supprimer le modèle téléchargé de Local A"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/));
|
||||
@ -408,8 +408,8 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
|
||||
expect(screen.getByLabelText("Modifier Local A")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Supprimer le modèle téléchargé de Local A")).toBeNull();
|
||||
|
||||
confirm.mockRestore();
|
||||
});
|
||||
@ -429,9 +429,9 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
});
|
||||
renderPanel(modelServer);
|
||||
|
||||
await screen.findByLabelText("edit Local A");
|
||||
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
|
||||
expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull();
|
||||
await screen.findByLabelText("Modifier Local A");
|
||||
expect(screen.queryByLabelText("Supprimer le modèle téléchargé de Local A")).toBeNull();
|
||||
expect(screen.queryByLabelText("Supprimer le modèle téléchargé Local file")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => {
|
||||
@ -442,10 +442,10 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
});
|
||||
renderPanel(modelServer);
|
||||
|
||||
const downloading = await screen.findByLabelText("download in progress Local A");
|
||||
const downloading = await screen.findByLabelText("Téléchargement en cours pour Local A");
|
||||
expect((downloading as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(downloading.textContent).toContain("Téléchargement en cours");
|
||||
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
|
||||
expect(screen.queryByLabelText("Supprimer le modèle téléchargé de Local A")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a short inline error when artifact deletion is blocked", async () => {
|
||||
@ -461,12 +461,12 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderPanel(modelServer);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
|
||||
fireEvent.click(await screen.findByLabelText("Supprimer le modèle téléchargé de Local A"));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/téléchargement en cours ou agent actif/i,
|
||||
);
|
||||
expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Supprimer le modèle téléchargé de Local A")).toBeTruthy();
|
||||
|
||||
confirm.mockRestore();
|
||||
});
|
||||
@ -476,11 +476,11 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
await modelServer.saveModelServer(SERVER);
|
||||
renderPanel(modelServer);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("edit Local A"));
|
||||
fireEvent.change(screen.getByLabelText("server served model name"), {
|
||||
fireEvent.click(await screen.findByLabelText("Modifier Local A"));
|
||||
fireEvent.change(screen.getByLabelText("Nom du modèle servi"), {
|
||||
target: { value: "qwen3-coder-7b" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("save model server"));
|
||||
fireEvent.click(screen.getByLabelText("Enregistrer le serveur de modèles"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await modelServer.listModelServers();
|
||||
|
||||
@ -4,12 +4,14 @@ import { useGateways } from "@/app/di";
|
||||
import type { Agent } from "@/domain";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export interface McpPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function McpPanel({ projectId }: McpPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const { agent } = useGateways();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -34,14 +36,14 @@ export function McpPanel({ projectId }: McpPanelProps) {
|
||||
return (
|
||||
<Panel
|
||||
title="MCP"
|
||||
actions={loading ? <Spinner size={14} /> : <Button size="sm" variant="ghost" onClick={() => void refresh()}>Refresh</Button>}
|
||||
actions={loading ? <Spinner size={14} /> : <Button size="sm" variant="ghost" onClick={() => void refresh()}>{tr("Rafraîchir", "Refresh")}</Button>}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
flush
|
||||
>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-start gap-2 p-4">
|
||||
<p role="alert" className="text-sm text-danger">{error}</p>
|
||||
<Button size="sm" onClick={() => void refresh()}>Réessayer</Button>
|
||||
<Button size="sm" onClick={() => void refresh()}>{tr("Réessayer", "Retry")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<McpToolPermissionsPanel projectId={projectId} agents={agents} />
|
||||
|
||||
@ -16,10 +16,12 @@ import {
|
||||
MockPermissionGateway,
|
||||
MockPluginGateway,
|
||||
MockProfileGateway,
|
||||
MockUserSettingsGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { McpPanel } from "./McpPanel";
|
||||
import { I18nProvider } from "@/i18n";
|
||||
|
||||
const PROJECT_ID = "proj-mcp-permissions-test";
|
||||
|
||||
@ -35,11 +37,12 @@ async function renderPanel() {
|
||||
permission,
|
||||
plugin: new MockPluginGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
userSettings: new MockUserSettingsGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<McpPanel projectId={PROJECT_ID} />
|
||||
<I18nProvider><McpPanel projectId={PROJECT_ID} /></I18nProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
@ -129,10 +132,11 @@ describe("McpPanel", () => {
|
||||
permission,
|
||||
plugin: new MockPluginGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
userSettings: new MockUserSettingsGateway(),
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<McpPanel projectId={PROJECT_ID} />
|
||||
<I18nProvider><McpPanel projectId={PROJECT_ID} /></I18nProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
@ -186,14 +190,14 @@ describe("McpPanel", () => {
|
||||
await selectAgentTarget("DevFrontend");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||
|
||||
expect(screen.getByText(/16 lecture autorisés · 0 écriture autorisé/)).toBeTruthy();
|
||||
expect(screen.getByText(/16 lectures autorisées · 0 écritures autorisées/)).toBeTruthy();
|
||||
|
||||
const writeCheckbox = screen.getByRole("checkbox", {
|
||||
name: /idea_ticket_update_carnet$/,
|
||||
});
|
||||
fireEvent.click(writeCheckbox);
|
||||
|
||||
expect(screen.getByText(/16 lecture autorisés · 1 écriture autorisé/)).toBeTruthy();
|
||||
expect(screen.getByText(/16 lectures autorisées · 1 écriture autorisée/)).toBeTruthy();
|
||||
expect(screen.getByText("Modifications non enregistrées")).toBeTruthy();
|
||||
expect(screen.getByText("Ajouté")).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -19,10 +19,12 @@ import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain";
|
||||
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
import {
|
||||
mcpToolGroupLabel,
|
||||
mcpToolGroupsForCatalogue,
|
||||
mcpToolLabel,
|
||||
} from "./mcpToolGroups";
|
||||
import { useMcpToolPermissions } from "./useMcpToolPermissions";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export interface McpToolPermissionsPanelProps {
|
||||
projectId: string;
|
||||
@ -50,6 +52,7 @@ export function McpToolPermissionsPanel({
|
||||
agents,
|
||||
}: McpToolPermissionsPanelProps) {
|
||||
const vm = useMcpToolPermissions(projectId);
|
||||
const { tr } = useI18n();
|
||||
const plugins = useProjectPlugins(projectId);
|
||||
const [target, setTarget] = useState<McpTarget>({ type: "project" });
|
||||
// Read imperatively (not via re-render) by `selectTarget` so an unsaved
|
||||
@ -60,7 +63,7 @@ export function McpToolPermissionsPanel({
|
||||
function selectTarget(next: McpTarget) {
|
||||
if (dirtyRef.current) {
|
||||
const proceed = window.confirm(
|
||||
"Des modifications non enregistrées seront perdues. Continuer ?",
|
||||
tr("Des modifications non enregistrées seront perdues. Continuer ?", "Unsaved changes will be lost. Continue?"),
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
@ -86,7 +89,7 @@ export function McpToolPermissionsPanel({
|
||||
{vm.error}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => void vm.refresh()}>
|
||||
Réessayer
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -102,7 +105,7 @@ export function McpToolPermissionsPanel({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4 md:flex-row">
|
||||
<nav aria-label="cibles des permissions MCP" className="flex w-full shrink-0 flex-col gap-2 md:w-56">
|
||||
<nav aria-label={tr("cibles des permissions MCP", "MCP permission targets")} className="flex w-full shrink-0 flex-col gap-2 md:w-56">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={target.type === "project"}
|
||||
@ -113,15 +116,15 @@ export function McpToolPermissionsPanel({
|
||||
target.type === "project" ? "border-primary bg-raised" : "border-border bg-surface",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-content">Défaut projet</span>
|
||||
<span className="font-medium text-content">{tr("Défaut projet", "Project default")}</span>
|
||||
<span className="text-xs text-muted">
|
||||
{doc.projectDefault ? "Personnalisé" : "Lecture seule"}
|
||||
{doc.projectDefault ? tr("Personnalisé", "Customized") : tr("Lecture seule", "Read-only")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<h4 className="mt-1 text-xs font-semibold uppercase tracking-wide text-faint">Agents</h4>
|
||||
<h4 className="mt-1 text-xs font-semibold uppercase tracking-wide text-faint">{tr("Agents", "Agents")}</h4>
|
||||
{agents.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun agent dans ce projet.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun agent dans ce projet.", "No agents in this project.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{agents.map((agent) => {
|
||||
@ -150,7 +153,7 @@ export function McpToolPermissionsPanel({
|
||||
: "bg-primary/10 text-muted",
|
||||
)}
|
||||
>
|
||||
{hasOverride ? "Override" : "Hérité"}
|
||||
{hasOverride ? tr("Override", "Override") : tr("Hérité", "Inherited")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@ -165,7 +168,7 @@ export function McpToolPermissionsPanel({
|
||||
<McpToolEditor
|
||||
key="project"
|
||||
targetKind="project"
|
||||
title="Tools MCP IdeA — Défaut projet"
|
||||
title={tr("Tools MCP IdeA — Défaut projet", "IdeA MCP tools — Project default")}
|
||||
catalogue={doc.catalogue}
|
||||
storedAllowed={doc.projectDefault?.allowedTools ?? null}
|
||||
comparisonBaseline={doc.catalogue.readOnlyTools}
|
||||
@ -223,6 +226,7 @@ function PluginMcpToolsEditor({
|
||||
agentName: string;
|
||||
plugins: ReturnType<typeof useProjectPlugins>;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const matches = (value: string) =>
|
||||
@ -259,10 +263,10 @@ function PluginMcpToolsEditor({
|
||||
<Panel title={`MCP plugins — ${agentName}`} flush>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Input
|
||||
aria-label="rechercher un tool MCP plugin"
|
||||
aria-label={tr("rechercher un tool MCP plugin", "search plugin MCP tool")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Rechercher un tool ou un plugin"
|
||||
placeholder={tr("Rechercher un tool ou un plugin", "Search for a tool or plugin")}
|
||||
/>
|
||||
{plugins.error && <p role="alert" className="text-sm text-danger">{plugins.error}</p>}
|
||||
{plugins.installed.map((plugin) => {
|
||||
@ -289,7 +293,7 @@ function PluginMcpToolsEditor({
|
||||
disabled={plugins.busy || (!enabled && !assigned)}
|
||||
onChange={(event) => void plugins.setToolAssigned({ agentId, ...ref }, event.target.checked)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
||||
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — {tr("Plugin désactivé", "Plugin disabled")}</span>}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@ -298,12 +302,12 @@ function PluginMcpToolsEditor({
|
||||
})}
|
||||
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => (
|
||||
<div key={`missing:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId} <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
|
||||
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
|
||||
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId} <span className="text-warning">— {tr("Indisponible", "Unavailable")}</span> <span className="text-faint">{tr("Plugin", "Plugin")}: {ref.pluginId}</span></span>
|
||||
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setToolAssigned({ agentId, ...ref }, false)}>{tr("Retirer", "Remove")}</Button>
|
||||
</div>
|
||||
))}
|
||||
{!plugins.loading && cataloguedCount === 0 && tombstones.length === 0 && (
|
||||
<p className="text-sm text-muted">Aucun tool MCP plugin catalogué.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun tool MCP plugin catalogué.", "No plugin MCP tools in the catalog.")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
@ -344,6 +348,7 @@ function McpToolEditor({
|
||||
onSaveAgentOverride,
|
||||
onResetOverride,
|
||||
}: McpToolEditorProps) {
|
||||
const { tr } = useI18n();
|
||||
const initialAllowed = storedAllowed ?? comparisonBaseline;
|
||||
const [overrideActive, setOverrideActive] = useState(
|
||||
targetKind === "project" || storedAllowed != null,
|
||||
@ -466,29 +471,28 @@ function McpToolEditor({
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{targetKind === "agent" && (
|
||||
<p className="text-xs text-muted">
|
||||
{overrideActive ? "Override personnalisé" : "Hérite du défaut projet"}
|
||||
{overrideActive ? tr("Override personnalisé", "Custom override") : tr("Hérite du défaut projet", "Inherits project default")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{targetKind === "agent" && !overrideActive && (
|
||||
<Button size="sm" onClick={createOverride} disabled={busy}>
|
||||
Créer un override
|
||||
{tr("Créer un override", "Create override")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Résumé effectif
|
||||
{tr("Résumé effectif", "Effective summary")}
|
||||
</h5>
|
||||
<p className="text-sm text-content">
|
||||
{readAllowedCount} lecture autorisés · {writeAllowedCount} écriture autorisé
|
||||
{writeAllowedCount > 1 ? "s" : ""}
|
||||
{readAllowedCount} {tr("lectures autorisées", "read tools allowed")} · {writeAllowedCount} {writeAllowedCount === 1 ? tr("écriture autorisée", "write tool allowed") : tr("écritures autorisées", "write tools allowed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Détail des tools
|
||||
{tr("Détail des tools", "Tool details")}
|
||||
</h5>
|
||||
{toolGroups.map((group) => (
|
||||
<ToolGroupSection
|
||||
@ -508,7 +512,7 @@ function McpToolEditor({
|
||||
|
||||
{unknownTools.length > 0 && (
|
||||
<ToolGroupSection
|
||||
group={{ id: "unknown", label: "Tools inconnus", tools: unknownTools }}
|
||||
group={{ id: "unknown", label: tr("Tools inconnus", "Unknown tools"), tools: unknownTools }}
|
||||
expanded={expandedGroups.unknown ?? true}
|
||||
onToggle={() => toggleGroup("unknown")}
|
||||
catalogue={catalogue}
|
||||
@ -529,15 +533,14 @@ function McpToolEditor({
|
||||
className="flex flex-col gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning"
|
||||
>
|
||||
<p>
|
||||
Ce tool sera autorisé pour tous les agents sans override. Confirmer cette
|
||||
modification ?
|
||||
{tr("Ce tool sera autorisé pour tous les agents sans override. Confirmer cette modification ?", "This tool will be allowed for every agent without an override. Confirm this change?")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setPendingConfirm(false)}>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => void handleSave()}>
|
||||
Confirmer
|
||||
{tr("Confirmer", "Confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -551,24 +554,24 @@ function McpToolEditor({
|
||||
|
||||
{savedFlash && (
|
||||
<p role="status" className="text-sm text-success">
|
||||
Permissions enregistrées
|
||||
{tr("Permissions enregistrées", "Permissions saved")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<div>
|
||||
{dirty && (
|
||||
<span className="text-xs text-warning">Modifications non enregistrées</span>
|
||||
<span className="text-xs text-warning">{tr("Modifications non enregistrées", "Unsaved changes")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{targetKind === "agent" && hasPersistedOverride && (
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void handleReset()}>
|
||||
Réinitialiser l'override
|
||||
{tr("Réinitialiser l'override", "Reset override")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" disabled={busy || !dirty} onClick={cancelDraft}>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -577,7 +580,7 @@ function McpToolEditor({
|
||||
loading={busy && dirty}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
Enregistrer
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -612,6 +615,7 @@ function ToolGroupSection({
|
||||
onToggleTool,
|
||||
unknown,
|
||||
}: ToolGroupSectionProps) {
|
||||
const { tr } = useI18n();
|
||||
const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length;
|
||||
const total = group.tools.length;
|
||||
|
||||
@ -620,13 +624,13 @@ function ToolGroupSection({
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${group.label}, ${allowedCount} sur ${total} autorisé${total > 1 ? "s" : ""}`}
|
||||
aria-label={`${mcpToolGroupLabel(group, tr)}, ${allowedCount} ${tr("sur", "of")} ${total} ${tr("autorisés", "allowed")}`}
|
||||
onClick={onToggle}
|
||||
className="flex min-h-[32px] w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-raised"
|
||||
>
|
||||
<span className="font-medium text-content">{group.label}</span>
|
||||
<span className="font-medium text-content">{mcpToolGroupLabel(group, tr)}</span>
|
||||
<span className="text-xs text-muted">
|
||||
{allowedCount}/{total} autorisés
|
||||
{allowedCount}/{total} {tr("autorisés", "allowed")}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
@ -671,15 +675,6 @@ function rowStatus(
|
||||
return displaySet.includes(tool) ? "allowed" : "denied";
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<RowStatus, string> = {
|
||||
allowed: "Autorisé",
|
||||
denied: "Refusé",
|
||||
inherited: "Hérité",
|
||||
added: "Ajouté",
|
||||
removed: "Retiré",
|
||||
unknown: "Inconnu",
|
||||
};
|
||||
|
||||
interface ToolRowProps {
|
||||
tool: string;
|
||||
isRead: boolean;
|
||||
@ -691,7 +686,8 @@ interface ToolRowProps {
|
||||
}
|
||||
|
||||
function ToolRow({ tool, isRead, unknown, editable, checked, status, onToggle }: ToolRowProps) {
|
||||
const label = mcpToolLabel(tool);
|
||||
const { tr } = useI18n();
|
||||
const label = mcpToolLabel(tool, tr);
|
||||
const inputId = `mcp-tool-${tool}`;
|
||||
return (
|
||||
<li className="flex min-h-[32px] items-center gap-2 px-3 py-1.5">
|
||||
@ -713,7 +709,7 @@ function ToolRow({ tool, isRead, unknown, editable, checked, status, onToggle }:
|
||||
isRead ? "bg-primary/10 text-primary" : "bg-warning/15 text-warning",
|
||||
)}
|
||||
>
|
||||
{isRead ? "Lecture" : "Écriture"}
|
||||
{isRead ? tr("Lecture", "Read") : tr("Écriture", "Write")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@ -731,7 +727,7 @@ function ToolRow({ tool, isRead, unknown, editable, checked, status, onToggle }:
|
||||
: "bg-primary/10 text-muted",
|
||||
)}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
{{ allowed: tr("Autorisé", "Allowed"), denied: tr("Refusé", "Denied"), inherited: tr("Hérité", "Inherited"), added: tr("Ajouté", "Added"), removed: tr("Retiré", "Removed"), unknown: tr("Inconnu", "Unknown") }[status]}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type {
|
||||
NetworkPolicy,
|
||||
PermissionSet,
|
||||
@ -24,30 +25,25 @@ type EditorTarget =
|
||||
| { type: "project" }
|
||||
| { type: "agent"; agentId: string; agentName: string };
|
||||
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
const CAPABILITY_ROWS: {
|
||||
key: keyof Omit<PolicyDraft, "fallback">;
|
||||
label: string;
|
||||
labels: [string, string];
|
||||
}[] = [
|
||||
{ key: "read", label: "Read" },
|
||||
{ key: "write", label: "Write" },
|
||||
{ key: "delete", label: "Delete" },
|
||||
{ key: "executeBash", label: "Bash" },
|
||||
{ key: "read", labels: ["Lecture", "Read"] },
|
||||
{ key: "write", labels: ["Écriture", "Write"] },
|
||||
{ key: "delete", labels: ["Suppression", "Delete"] },
|
||||
{ key: "executeBash", labels: ["Bash", "Bash"] },
|
||||
];
|
||||
|
||||
const POSTURE_LABELS: Record<PermissionPosture, string> = {
|
||||
ask: "Ask",
|
||||
allow: "Allow",
|
||||
deny: "Deny",
|
||||
};
|
||||
function postureLabel(posture: PermissionPosture, tr: Tr) {
|
||||
return { ask: tr("Demander", "Ask"), allow: tr("Autoriser", "Allow"), deny: tr("Refuser", "Deny") }[posture];
|
||||
}
|
||||
|
||||
const NETWORK_LABELS: Record<NetworkPolicy, string> = {
|
||||
allow: "Autorisé",
|
||||
deny: "Interdit",
|
||||
ask: "Demande",
|
||||
};
|
||||
|
||||
const CROSS_PROJECT_HELP =
|
||||
"Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.";
|
||||
function networkLabel(policy: NetworkPolicy, tr: Tr) {
|
||||
return { allow: tr("Autorisé", "Allowed"), deny: tr("Interdit", "Denied"), ask: tr("Demande", "Ask") }[policy];
|
||||
}
|
||||
|
||||
function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissionSet | null {
|
||||
const permissions: SystemPermissionSet = {};
|
||||
@ -56,6 +52,7 @@ function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissi
|
||||
}
|
||||
|
||||
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = usePermissions(projectId);
|
||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||
|
||||
@ -120,7 +117,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Permissions"
|
||||
title={tr("Permissions", "Permissions")}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
@ -128,7 +125,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.refresh()}
|
||||
>
|
||||
Refresh
|
||||
{tr("Actualiser", "Refresh")}
|
||||
</Button>
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
@ -147,8 +144,8 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
className="flex flex-col gap-4 p-4"
|
||||
>
|
||||
<PolicyCard
|
||||
title="Project defaults"
|
||||
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
|
||||
title={tr("Valeurs par défaut du projet", "Project defaults")}
|
||||
subtitle={hasProjectDefaults ? tr("Configurées", "Configured") : tr("Comportement CLI natif", "Native CLI behavior")}
|
||||
active={target.type === "project"}
|
||||
custom={isCustomPolicy(vm.document?.projectDefaults)}
|
||||
onSelect={() => setTarget({ type: "project" })}
|
||||
@ -158,12 +155,12 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Agents
|
||||
{tr("Agents", "Agents")}
|
||||
</h4>
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
</div>
|
||||
{vm.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">No agents yet.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun agent pour le moment.", "No agents yet.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{vm.rows.map((row) => {
|
||||
@ -173,7 +170,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
<li key={row.agent.id}>
|
||||
<PolicyCard
|
||||
title={row.agent.name}
|
||||
subtitle={row.override ? "Override" : "Inherited"}
|
||||
subtitle={row.override ? tr("Surcharge", "Override") : tr("Hérité", "Inherited")}
|
||||
active={active}
|
||||
custom={isCustomPolicy(row.override)}
|
||||
onSelect={() =>
|
||||
@ -196,8 +193,8 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
key={target.type === "project" ? "project" : target.agentId}
|
||||
title={
|
||||
target.type === "project"
|
||||
? "Project defaults"
|
||||
: `Override — ${target.agentName}`
|
||||
? tr("Valeurs par défaut du projet", "Project defaults")
|
||||
: `${tr("Surcharge", "Override")} — ${target.agentName}`
|
||||
}
|
||||
draft={activeDraft}
|
||||
sourcePolicy={activePolicy}
|
||||
@ -216,8 +213,8 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
key={`network-${target.type === "project" ? "project" : target.agentId}`}
|
||||
title={
|
||||
target.type === "project"
|
||||
? "Réseau — defaults projet"
|
||||
: `Réseau — ${target.agentName}`
|
||||
? tr("Réseau — valeurs par défaut du projet", "Network — project defaults")
|
||||
: `${tr("Réseau", "Network")} — ${target.agentName}`
|
||||
}
|
||||
source={activeSystemSet}
|
||||
resolved={activeResolvedSystem}
|
||||
@ -256,6 +253,7 @@ function PolicyCard({
|
||||
policy,
|
||||
onSelect,
|
||||
}: PolicyCardProps) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@ -274,12 +272,12 @@ function PolicyCard({
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{custom && (
|
||||
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
Custom
|
||||
{tr("Personnalisé", "Custom")}
|
||||
</span>
|
||||
)}
|
||||
{policy && (
|
||||
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{POSTURE_LABELS[policy.fallback]}
|
||||
{postureLabel(policy.fallback, tr)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@ -308,6 +306,7 @@ function NetworkPermissionEditor({
|
||||
busy,
|
||||
onSave,
|
||||
}: NetworkPermissionEditorProps) {
|
||||
const { tr } = useI18n();
|
||||
const current = source?.network ?? "";
|
||||
const currentCrossProjectContactable = contactable;
|
||||
const [local, setLocal] = useState<NetworkPolicy | "">(current);
|
||||
@ -336,64 +335,64 @@ function NetworkPermissionEditor({
|
||||
<header className="border-b border-border px-3 py-2.5">
|
||||
<h4 className="text-sm font-semibold text-content">{title}</h4>
|
||||
<p className="text-xs text-muted">
|
||||
Politique voulue, état effectif et verrou runtime.
|
||||
{tr("Politique voulue, état effectif et verrou d’exécution.", "Requested policy, effective state, and runtime lock.")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||
<StatusLine
|
||||
label="Wanted"
|
||||
value={wanted ? `Réseau ${NETWORK_LABELS[wanted].toLowerCase()}` : "Non configuré"}
|
||||
label={tr("Demandé", "Requested")}
|
||||
value={wanted ? `${tr("Réseau", "Network")} ${networkLabel(wanted, tr).toLowerCase()}` : tr("Non configuré", "Not configured")}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Effective"
|
||||
label={tr("Effectif", "Effective")}
|
||||
value={
|
||||
effective
|
||||
? `Réseau ${NETWORK_LABELS[effective].toLowerCase()}`
|
||||
: "Indisponible"
|
||||
? `${tr("Réseau", "Network")} ${networkLabel(effective, tr).toLowerCase()}`
|
||||
: tr("Indisponible", "Unavailable")
|
||||
}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Runtime"
|
||||
label={tr("Exécution", "Runtime")}
|
||||
value={
|
||||
runtimeLocked
|
||||
? "Runtime verrouillé"
|
||||
? tr("Exécution verrouillée", "Runtime locked")
|
||||
: runtimeUnobserved
|
||||
? "Aucun runtime actif"
|
||||
? tr("Aucune exécution active", "No active runtime")
|
||||
: runtimeReadOnly
|
||||
? "Runtime en lecture seule"
|
||||
: "Runtime modifiable"
|
||||
? tr("Exécution en lecture seule", "Read-only runtime")
|
||||
: tr("Exécution modifiable", "Editable runtime")
|
||||
}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Control"
|
||||
value={readOnly ? "Lecture seule" : "Modifiable"}
|
||||
label={tr("Contrôle", "Control")}
|
||||
value={readOnly ? tr("Lecture seule", "Read only") : tr("Modifiable", "Editable")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{resolved?.control.reason ?? "Cette configuration réseau est en lecture seule."}
|
||||
{resolved?.control.reason ?? tr("Cette configuration réseau est en lecture seule.", "This network configuration is read only.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!readOnly && runtimeUnobserved && (
|
||||
<p className="rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs text-primary">
|
||||
Aucun runtime actif. La configuration sera appliquée au prochain lancement.
|
||||
{tr("Aucune exécution active. La configuration sera appliquée au prochain lancement.", "No active runtime. The configuration will be applied on the next launch.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!readOnly && runtimeLocked && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{runtimeReason ?? "Le runtime actif expose un état réseau en lecture seule."}
|
||||
{runtimeReason ?? tr("L’exécution active expose un état réseau en lecture seule.", "The active runtime exposes a read-only network state.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Réseau</span>
|
||||
<span className="text-xs font-medium text-muted">{tr("Réseau", "Network")}</span>
|
||||
<select
|
||||
aria-label={`${title} network`}
|
||||
aria-label={`${title} — ${tr("réseau", "network")}`}
|
||||
value={local}
|
||||
disabled={busy || readOnly}
|
||||
onChange={(e) => setLocal(e.target.value as NetworkPolicy | "")}
|
||||
@ -402,16 +401,16 @@ function NetworkPermissionEditor({
|
||||
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="">Non configuré</option>
|
||||
<option value="allow">Réseau autorisé</option>
|
||||
<option value="deny">Réseau interdit</option>
|
||||
<option value="ask">Demande d'autorisation</option>
|
||||
<option value="">{tr("Non configuré", "Not configured")}</option>
|
||||
<option value="allow">{tr("Réseau autorisé", "Network allowed")}</option>
|
||||
<option value="deny">{tr("Réseau interdit", "Network denied")}</option>
|
||||
<option value="ask">{tr("Demande d’autorisation", "Ask for permission")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{inherited?.network && !source?.network && (
|
||||
<p className="text-xs text-muted">
|
||||
Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}.
|
||||
{tr("Hérité du projet", "Inherited from project")}: {tr("Réseau", "Network")} {networkLabel(inherited.network, tr).toLowerCase()}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -419,7 +418,7 @@ function NetworkPermissionEditor({
|
||||
<label className="flex items-start gap-2 rounded-md border border-border/70 bg-raised px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Contactable depuis d’autres projets"
|
||||
aria-label={tr("Contactable depuis d’autres projets", "Contactable from other projects")}
|
||||
checked={localCrossProjectContactable}
|
||||
onChange={(e) => setLocalCrossProjectContactable(e.target.checked)}
|
||||
disabled={busy || readOnly}
|
||||
@ -427,9 +426,9 @@ function NetworkPermissionEditor({
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-sm font-medium text-content">
|
||||
Contactable depuis d’autres projets
|
||||
{tr("Contactable depuis d’autres projets", "Contactable from other projects")}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{CROSS_PROJECT_HELP}</span>
|
||||
<span className="text-xs text-muted">{tr("Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.", "Allows agents from other IdeA projects to contact this agent through cross-project orchestration. The agent remains subject to its permissions and context.")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
@ -438,7 +437,7 @@ function NetworkPermissionEditor({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Clear network"
|
||||
aria-label={tr("Effacer le réglage réseau", "Clear network setting")}
|
||||
disabled={
|
||||
busy ||
|
||||
readOnly ||
|
||||
@ -447,12 +446,12 @@ function NetworkPermissionEditor({
|
||||
}
|
||||
onClick={() => onSave(null)}
|
||||
>
|
||||
Clear
|
||||
{tr("Effacer", "Clear")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label="Save network"
|
||||
aria-label={tr("Enregistrer le réglage réseau", "Save network setting")}
|
||||
disabled={busy || readOnly || !changed}
|
||||
loading={busy && changed}
|
||||
onClick={() =>
|
||||
@ -464,7 +463,7 @@ function NetworkPermissionEditor({
|
||||
)
|
||||
}
|
||||
>
|
||||
Save
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -504,6 +503,7 @@ function PermissionEditor({
|
||||
onSave,
|
||||
onClear,
|
||||
}: PermissionEditorProps) {
|
||||
const { tr } = useI18n();
|
||||
const [local, setLocal] = useState<PolicyDraft>(draft);
|
||||
// `draft` is a NEW object every time the underlying document changes for ANY
|
||||
// reason (initial load landing after mount, a Refresh, another tab's save…),
|
||||
@ -545,21 +545,21 @@ function PermissionEditor({
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-semibold text-content">{title}</h4>
|
||||
<p className="text-xs text-muted">
|
||||
{sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"}
|
||||
{sourcePolicy ? tr(`${sourcePolicy.rules.length} règle${sourcePolicy.rules.length > 1 ? "s" : ""}`, `${sourcePolicy.rules.length} rule${sourcePolicy.rules.length > 1 ? "s" : ""}`) : tr("Non configuré", "Not configured")}
|
||||
</p>
|
||||
</div>
|
||||
{custom && (
|
||||
<span className="shrink-0 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
Advanced
|
||||
{tr("Avancé", "Advanced")}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Fallback</span>
|
||||
<span className="text-xs font-medium text-muted">{tr("Comportement par défaut", "Fallback")}</span>
|
||||
<select
|
||||
aria-label={`${title} fallback`}
|
||||
aria-label={`${title} — ${tr("comportement par défaut", "fallback")}`}
|
||||
value={local.fallback}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
@ -573,27 +573,29 @@ function PermissionEditor({
|
||||
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="ask">Ask</option>
|
||||
<option value="allow">Allow</option>
|
||||
<option value="deny">Deny</option>
|
||||
<option value="ask">{tr("Demander", "Ask")}</option>
|
||||
<option value="allow">{tr("Autoriser", "Allow")}</option>
|
||||
<option value="deny">{tr("Refuser", "Deny")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{CAPABILITY_ROWS.map((row) => (
|
||||
{CAPABILITY_ROWS.map((row) => {
|
||||
const label = tr(...row.labels);
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="grid grid-cols-[4.5rem_1fr] items-center gap-2"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted">{row.label}</span>
|
||||
<span className="text-xs font-medium text-muted">{label}</span>
|
||||
<SegmentedChoice
|
||||
value={local[row.key]}
|
||||
disabled={busy}
|
||||
label={row.label}
|
||||
label={label}
|
||||
onChange={(choice) => setCapability(row.key, choice)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
@ -603,7 +605,7 @@ function PermissionEditor({
|
||||
disabled={busy || !canClear}
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
{tr("Effacer", "Clear")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -612,7 +614,7 @@ function PermissionEditor({
|
||||
loading={busy && changed}
|
||||
onClick={() => onSave(local)}
|
||||
>
|
||||
Save
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -633,15 +635,16 @@ function SegmentedChoice({
|
||||
label,
|
||||
onChange,
|
||||
}: SegmentedChoiceProps) {
|
||||
const { tr } = useI18n();
|
||||
const choices: { value: CapabilityChoice; label: string }[] = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "allow", label: "Allow" },
|
||||
{ value: "deny", label: "Deny" },
|
||||
{ value: "none", label: tr("Aucun", "None") },
|
||||
{ value: "allow", label: tr("Autoriser", "Allow") },
|
||||
{ value: "deny", label: tr("Refuser", "Deny") },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${label} permission`}
|
||||
aria-label={`${tr("Permission", "Permission")} — ${label}`}
|
||||
className="grid grid-cols-3 overflow-hidden rounded-md border border-border"
|
||||
>
|
||||
{choices.map((choice) => (
|
||||
|
||||
@ -14,6 +14,8 @@ export interface McpToolGroupDef {
|
||||
label: string;
|
||||
tools: string[];
|
||||
}
|
||||
type Tr = (french: string, english: string) => string;
|
||||
const fr: Tr = (french) => french;
|
||||
|
||||
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
|
||||
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
||||
@ -124,9 +126,22 @@ export const MCP_TOOL_LABELS: Record<string, string> = {
|
||||
idea_run_in_background: "Exécuter une commande en arrière-plan",
|
||||
};
|
||||
|
||||
const MCP_TOOL_LABELS_EN: Record<string, string> = {
|
||||
idea_project_list: "List IdeA projects", idea_project_read_context: "Read another project's context", idea_project_list_agents: "List agents in another project", idea_project_ask_agent: "Ask an agent in another project", idea_project_cancel_agent: "Cancel a cross-project request",
|
||||
idea_list_agents: "List agents", idea_context_read: "Read an agent's context", idea_memory_read: "Read project memory", idea_skill_read: "Read a skill", idea_workstate_read: "Read work state", idea_ticket_read: "Read a ticket", idea_ticket_list: "List tickets", idea_ticket_read_carnet: "Read ticket notes", idea_sprint_list: "List sprints",
|
||||
idea_ask_agent: "Delegate to an agent", idea_ask_agents: "Ask several agents", idea_launch_agent: "Launch an agent", idea_stop_agent: "Stop an agent", idea_update_context: "Update an agent's context", idea_context_propose: "Propose context", idea_memory_write: "Write project memory", idea_workstate_set: "Write work state", idea_create_skill: "Create a skill",
|
||||
idea_ticket_create: "Create a ticket", idea_ticket_update: "Update a ticket", idea_ticket_update_status: "Update ticket status", idea_ticket_update_priority: "Update ticket priority", idea_ticket_update_carnet: "Update ticket notes", idea_ticket_link: "Link tickets", idea_ticket_unlink: "Unlink tickets", idea_run_in_background: "Run a command in the background",
|
||||
};
|
||||
|
||||
export function mcpToolGroupLabel(group: McpToolGroupDef, tr: Tr = fr): string {
|
||||
const english: Record<string, string> = { crossProject: "IdeA cross-project", readProject: "Project read", readTickets: "Ticket read", delegation: "Agent delegation", contextMemory: "Context and memory", tickets: "Tickets", work: "Work and execution", skills: "Skills", other: "Other" };
|
||||
return tr(group.label, english[group.id] ?? group.label);
|
||||
}
|
||||
|
||||
/** Short human label for `tool`, falling back to the exact name if unmapped. */
|
||||
export function mcpToolLabel(tool: string): string {
|
||||
return MCP_TOOL_LABELS[tool] ?? tool;
|
||||
export function mcpToolLabel(tool: string, tr: Tr = fr): string {
|
||||
const french = MCP_TOOL_LABELS[tool];
|
||||
return french ? tr(french, MCP_TOOL_LABELS_EN[tool] ?? french) : tool;
|
||||
}
|
||||
|
||||
/** The group a tool belongs to, or `undefined` if not locally mapped. */
|
||||
|
||||
@ -44,12 +44,12 @@ describe("PermissionsPanel", () => {
|
||||
it("saves project defaults from the compact policy editor", async () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Project defaults fallback"), {
|
||||
fireEvent.change(screen.getByLabelText("Valeurs par défaut du projet — comportement par défaut"), {
|
||||
target: { value: "allow" },
|
||||
});
|
||||
clickChoice("Read permission", "Allow");
|
||||
clickChoice("Bash permission", "Deny");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
clickChoice("Permission — Lecture", "Autoriser");
|
||||
clickChoice("Permission — Bash", "Refuser");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectPermissions(PROJECT_ID);
|
||||
@ -65,11 +65,11 @@ describe("PermissionsPanel", () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByText("Builder"));
|
||||
fireEvent.change(screen.getByLabelText("Override — Builder fallback"), {
|
||||
fireEvent.change(screen.getByLabelText("Surcharge — Builder — comportement par défaut"), {
|
||||
target: { value: "deny" },
|
||||
});
|
||||
clickChoice("Write permission", "Allow");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
clickChoice("Permission — Écriture", "Autoriser");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectPermissions(PROJECT_ID);
|
||||
@ -79,7 +79,7 @@ describe("PermissionsPanel", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Effacer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectPermissions(PROJECT_ID);
|
||||
@ -90,10 +90,10 @@ describe("PermissionsPanel", () => {
|
||||
it("saves project network defaults in the separate system permissions document", async () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Réseau — defaults projet network"), {
|
||||
fireEvent.change(screen.getByLabelText("Réseau — valeurs par défaut du projet — réseau"), {
|
||||
target: { value: "allow" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le réglage réseau" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
|
||||
@ -121,7 +121,7 @@ describe("PermissionsPanel", () => {
|
||||
|
||||
await waitFor(() => expect(checkbox.disabled).toBe(false));
|
||||
fireEvent.click(checkbox);
|
||||
const save = screen.getByRole("button", { name: "Save network" });
|
||||
const save = screen.getByRole("button", { name: "Enregistrer le réglage réseau" });
|
||||
await waitFor(() => expect(save).toHaveProperty("disabled", false));
|
||||
fireEvent.click(save);
|
||||
|
||||
@ -170,11 +170,11 @@ describe("PermissionsPanel", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Runtime verrouillé")).toBeTruthy();
|
||||
expect(screen.getByText("Exécution verrouillée")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Runtime network is locked.")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
|
||||
(screen.getByLabelText("Réseau — valeurs par défaut du projet — réseau") as HTMLSelectElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
@ -183,22 +183,22 @@ describe("PermissionsPanel", () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Aucun runtime actif")).toBeTruthy();
|
||||
expect(screen.getByText("Aucune exécution active")).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Aucun runtime actif. La configuration sera appliquée au prochain lancement.",
|
||||
"Aucune exécution active. La configuration sera appliquée au prochain lancement.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
|
||||
(screen.getByLabelText("Réseau — valeurs par défaut du projet — réseau") as HTMLSelectElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Réseau — defaults projet network"), {
|
||||
fireEvent.change(screen.getByLabelText("Réseau — valeurs par défaut du projet — réseau"), {
|
||||
target: { value: "deny" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le réglage réseau" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
|
||||
@ -212,13 +212,13 @@ describe("PermissionsPanel", () => {
|
||||
fireEvent.click(screen.getByText("Builder"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Aucun runtime actif")).toBeTruthy();
|
||||
expect(screen.getByText("Aucune exécution active")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("Lecture seule")).toBeNull();
|
||||
expect(screen.queryByText("Runtime verrouillé")).toBeNull();
|
||||
expect(screen.queryByText("Exécution verrouillée")).toBeNull();
|
||||
|
||||
const select = screen.getByLabelText("Réseau — Builder network") as HTMLSelectElement;
|
||||
const save = screen.getByRole("button", { name: "Save network" });
|
||||
const select = screen.getByLabelText("Réseau — Builder — réseau") as HTMLSelectElement;
|
||||
const save = screen.getByRole("button", { name: "Enregistrer le réglage réseau" });
|
||||
|
||||
expect(select.disabled).toBe(false);
|
||||
expect(save).toHaveProperty("disabled", true);
|
||||
@ -262,10 +262,10 @@ describe("PermissionsPanel", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Runtime verrouillé")).toBeTruthy();
|
||||
expect(screen.getByText("Exécution verrouillée")).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
|
||||
(screen.getByLabelText("Réseau — valeurs par défaut du projet — réseau") as HTMLSelectElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button, zIndex } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface PluginConfirmDialogProps {
|
||||
title: string;
|
||||
@ -29,6 +30,7 @@ export function PluginConfirmDialog({
|
||||
onCancel,
|
||||
}: PluginConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const { tr } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus();
|
||||
@ -59,7 +61,7 @@ export function PluginConfirmDialog({
|
||||
<p className="whitespace-pre-line text-sm text-muted">{body}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@ -121,7 +121,7 @@ describe("PluginLayoutCellView", () => {
|
||||
const onChooseAnotherLayout = vi.fn();
|
||||
renderCell(registry, { cell: testCell, onChooseAnotherLayout });
|
||||
|
||||
expect(screen.queryByText("Layout indisponible")).toBeNull();
|
||||
expect(screen.queryByText("Disposition indisponible")).toBeNull();
|
||||
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
|
||||
expect(testCell).toEqual(cell());
|
||||
});
|
||||
@ -133,7 +133,7 @@ describe("PluginLayoutCellView", () => {
|
||||
registry.add(stubPlugin(ThrowingLayoutComponent));
|
||||
renderCell(registry);
|
||||
|
||||
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
||||
expect(screen.getByText("Disposition indisponible")).toBeTruthy();
|
||||
expect(screen.queryByTestId("state")).toBeNull();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
|
||||
@ -15,12 +15,7 @@
|
||||
|
||||
import type { CustomPluginLayoutCell, PluginLayoutAvailability } from "@/domain";
|
||||
import { Button, Panel } from "@/shared";
|
||||
|
||||
const REASON_LABEL: Record<Exclude<PluginLayoutAvailability, "available">, string> = {
|
||||
"plugin-disabled": "Le plugin fournisseur est désactivé.",
|
||||
"plugin-missing": "Le plugin fournisseur n'est pas installé.",
|
||||
incompatible: "Le plugin fournisseur ne déclare plus ce type de layout.",
|
||||
};
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface PluginLayoutFallbackProps {
|
||||
cell: CustomPluginLayoutCell;
|
||||
@ -38,17 +33,23 @@ export function PluginLayoutFallback({
|
||||
onOpenPlugins,
|
||||
onChooseAnotherLayout,
|
||||
}: PluginLayoutFallbackProps) {
|
||||
const { tr } = useI18n();
|
||||
const reason = {
|
||||
"plugin-disabled": tr("Le plugin fournisseur est désactivé.", "The providing plugin is disabled."),
|
||||
"plugin-missing": tr("Le plugin fournisseur n’est pas installé.", "The providing plugin is not installed."),
|
||||
incompatible: tr("Le plugin fournisseur ne déclare plus ce type de disposition.", "The providing plugin no longer declares this layout type."),
|
||||
}[availability];
|
||||
return (
|
||||
<Panel className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="text-sm font-semibold text-content">Layout indisponible</p>
|
||||
<p className="text-xs text-muted">{REASON_LABEL[availability]}</p>
|
||||
<p className="text-xs text-faint">Fourni par « {providerDisplayName ?? cell.pluginId} »</p>
|
||||
<p className="text-sm font-semibold text-content">{tr("Disposition indisponible", "Layout unavailable")}</p>
|
||||
<p className="text-xs text-muted">{reason}</p>
|
||||
<p className="text-xs text-faint">{tr("Fourni par", "Provided by")} « {providerDisplayName ?? cell.pluginId} »</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onOpenPlugins}>
|
||||
Ouvrir Plugins
|
||||
{tr("Ouvrir Plugins", "Open Plugins")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onChooseAnotherLayout}>
|
||||
Choisir un autre layout
|
||||
{tr("Choisir une autre disposition", "Choose another layout")}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
|
||||
import type { PluginLayoutContribution } from "@/domain";
|
||||
import type { PluginRuntimeRegistry } from "@/plugins/runtime";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export interface PluginLayoutChoice {
|
||||
pluginId: string;
|
||||
@ -75,20 +76,21 @@ export function PluginLayoutSelectorSection({
|
||||
registry,
|
||||
onSelect,
|
||||
}: PluginLayoutSelectorSectionProps) {
|
||||
const { tr } = useI18n();
|
||||
const choices = listPluginLayoutChoices(registry);
|
||||
if (choices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="group" aria-label="Layouts plugins">
|
||||
<div role="group" aria-label={tr("Layouts de plugins", "Plugin layouts")}>
|
||||
<p className="px-3 pt-2 text-[0.65rem] font-semibold uppercase tracking-wide text-faint">
|
||||
Layouts plugins
|
||||
{tr("Layouts de plugins", "Plugin layouts")}
|
||||
</p>
|
||||
{choices.map((choice) => (
|
||||
<button
|
||||
key={`${choice.pluginId}:${choice.layout.type}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={`create ${choice.layout.label} layout`}
|
||||
aria-label={tr(`créer le layout ${choice.layout.label}`, `create ${choice.layout.label} layout`)}
|
||||
onClick={() => onSelect(choice)}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
|
||||
@ -12,19 +12,24 @@ import { useState } from "react";
|
||||
|
||||
import type { PluginAdmin, PluginLifecycleState, PluginReview } from "@/domain";
|
||||
import { Button, Panel } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
|
||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||
import { usePlugins } from "./usePlugins";
|
||||
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||
|
||||
const STATE_LABEL: Record<PluginLifecycleState, string> = {
|
||||
enabled: "Activé",
|
||||
disabled: "Désactivé",
|
||||
"pending-enable": "Activation en attente",
|
||||
"pending-disable": "Désactivation en attente",
|
||||
"pending-uninstall": "Désinstallation en attente",
|
||||
invalid: "Invalide",
|
||||
};
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
function stateLabel(state: PluginLifecycleState, tr: Tr) {
|
||||
return {
|
||||
enabled: tr("Activé", "Enabled"),
|
||||
disabled: tr("Désactivé", "Disabled"),
|
||||
"pending-enable": tr("Activation en attente", "Pending enable"),
|
||||
"pending-disable": tr("Désactivation en attente", "Pending disable"),
|
||||
"pending-uninstall": tr("Désinstallation en attente", "Pending uninstall"),
|
||||
invalid: tr("Invalide", "Invalid"),
|
||||
}[state];
|
||||
}
|
||||
|
||||
type PendingAction =
|
||||
| { kind: "disable"; plugin: PluginAdmin }
|
||||
@ -42,6 +47,7 @@ export interface PluginsPanelProps {
|
||||
}
|
||||
|
||||
export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps = {}) {
|
||||
const { tr } = useI18n();
|
||||
const vm = usePlugins();
|
||||
const pluginRuntime = usePluginRuntime();
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
@ -54,7 +60,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
if (!path) return;
|
||||
const review = await vm.reviewArchive(path);
|
||||
if (!review) {
|
||||
setInstallFlowError("La revue du paquet a échoué.");
|
||||
setInstallFlowError(tr("La revue du paquet a échoué.", "Package review failed."));
|
||||
return;
|
||||
}
|
||||
setInstallFlow({ sourceKind: "archive", path, review });
|
||||
@ -66,7 +72,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
if (!path) return;
|
||||
const review = await vm.reviewDirectory(path);
|
||||
if (!review) {
|
||||
setInstallFlowError("La revue du paquet a échoué.");
|
||||
setInstallFlowError(tr("La revue du paquet a échoué.", "Package review failed."));
|
||||
return;
|
||||
}
|
||||
setInstallFlow({ sourceKind: "directory", path, review });
|
||||
@ -98,17 +104,17 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Ouvrez un projet pour configurer les plugins utilisés par ce projet.
|
||||
{tr("Ouvrez un projet pour configurer les plugins utilisés par ce projet.", "Open a project to configure the plugins used by that project.")}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-content">Installation globale</h2>
|
||||
<h2 className="text-sm font-semibold text-content">{tr("Installation globale", "Global installation")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
|
||||
Installer depuis une archive…
|
||||
{tr("Installer depuis une archive…", "Install from archive…")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -116,7 +122,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
onClick={() => void startInstallFromDirectory()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Installer depuis un dossier…
|
||||
{tr("Installer depuis un dossier…", "Install from directory…")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -132,7 +138,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
<Panel className="border-warning/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p role="alert" className="text-sm font-medium text-warning">
|
||||
Certains plugins installés n'ont pas pu être chargés.
|
||||
{tr("Certains plugins installés n’ont pas pu être chargés.", "Some installed plugins could not be loaded.")}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pluginRuntime.failures.map((failure) => (
|
||||
@ -150,7 +156,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
<Panel>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-content">
|
||||
Certains plugins attendent un projet actif.
|
||||
{tr("Certains plugins attendent un projet actif.", "Some plugins are waiting for an active project.")}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pluginRuntime.pending.map((pending) => (
|
||||
@ -165,7 +171,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
|
||||
{vm.plugins.length === 0 ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">Aucun plugin installé.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun plugin installé.", "No plugins installed.")}</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
@ -183,15 +189,15 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<span
|
||||
aria-label="plugin state"
|
||||
aria-label={tr("État du plugin", "Plugin state")}
|
||||
className={
|
||||
p.lifecycleState === "invalid" ? "text-danger" : undefined
|
||||
}
|
||||
>
|
||||
{STATE_LABEL[p.lifecycleState]}
|
||||
{stateLabel(p.lifecycleState, tr)}
|
||||
</span>
|
||||
{p.restartRequired && (
|
||||
<span className="text-warning">Redémarrage requis</span>
|
||||
<span className="text-warning">{tr("Redémarrage requis", "Restart required")}</span>
|
||||
)}
|
||||
{p.sourceLabel && <span>· {p.sourceLabel}</span>}
|
||||
</div>
|
||||
@ -204,7 +210,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{p.lifecycleState === "invalid" ? (
|
||||
<Button size="sm" variant="ghost" disabled>
|
||||
Isolé
|
||||
{tr("Isolé", "Quarantined")}
|
||||
</Button>
|
||||
) : p.enabled ? (
|
||||
<Button
|
||||
@ -213,7 +219,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
onClick={() => setPendingAction({ kind: "disable", plugin: p })}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Désactiver
|
||||
{tr("Désactiver", "Disable")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@ -222,7 +228,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
onClick={() => void vm.setEnabled(p.id, true)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Activer
|
||||
{tr("Activer", "Enable")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@ -231,7 +237,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
onClick={() => void vm.openPluginsFolder(p.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Ouvrir le dossier
|
||||
{tr("Ouvrir le dossier", "Open folder")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -239,7 +245,7 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
onClick={() => setPendingAction({ kind: "uninstall", plugin: p })}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Désinstaller
|
||||
{tr("Désinstaller", "Uninstall")}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
@ -250,10 +256,10 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
|
||||
{installFlow && (
|
||||
<ConfirmDialog
|
||||
title={`Installer "${installFlow.review.displayName}" ?`}
|
||||
body={reviewSummary(installFlow.review)}
|
||||
title={tr(`Installer « ${installFlow.review.displayName} » ?`, `Install “${installFlow.review.displayName}”?`)}
|
||||
body={reviewSummary(installFlow.review, tr)}
|
||||
confirmLabel={
|
||||
isInstallable(installFlow.review) ? "Installer" : "Corriger avant d'installer"
|
||||
isInstallable(installFlow.review) ? tr("Installer", "Install") : tr("Corriger avant d’installer", "Fix before installing")
|
||||
}
|
||||
busy={vm.busy}
|
||||
danger={!isInstallable(installFlow.review)}
|
||||
@ -269,15 +275,15 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
<ConfirmDialog
|
||||
title={
|
||||
pendingAction.kind === "disable"
|
||||
? `Désactiver "${pendingAction.plugin.displayName}" ?`
|
||||
: `Désinstaller "${pendingAction.plugin.displayName}" ?`
|
||||
? tr(`Désactiver « ${pendingAction.plugin.displayName} » ?`, `Disable “${pendingAction.plugin.displayName}”?`)
|
||||
: tr(`Désinstaller « ${pendingAction.plugin.displayName} » ?`, `Uninstall “${pendingAction.plugin.displayName}”?`)
|
||||
}
|
||||
body={
|
||||
pendingAction.kind === "disable"
|
||||
? "Ses contributions seront masquées immédiatement ; un redémarrage sera peut-être nécessaire pour purger complètement le plugin."
|
||||
: "Le plugin, ses fichiers et ses contributions seront supprimés après redémarrage."
|
||||
? tr("Ses contributions seront masquées immédiatement ; un redémarrage sera peut-être nécessaire pour purger complètement le plugin.", "Its contributions will be hidden immediately; a restart may be required to unload the plugin completely.")
|
||||
: tr("Le plugin, ses fichiers et ses contributions seront supprimés après redémarrage.", "The plugin, its files, and its contributions will be removed after restart.")
|
||||
}
|
||||
confirmLabel={pendingAction.kind === "disable" ? "Désactiver" : "Désinstaller"}
|
||||
confirmLabel={pendingAction.kind === "disable" ? tr("Désactiver", "Disable") : tr("Désinstaller", "Uninstall")}
|
||||
danger={pendingAction.kind === "uninstall"}
|
||||
busy={vm.busy}
|
||||
onConfirm={() => void confirmPendingAction()}
|
||||
@ -288,16 +294,16 @@ export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps =
|
||||
);
|
||||
}
|
||||
|
||||
function reviewSummary(review: PluginReview): string {
|
||||
function reviewSummary(review: PluginReview, tr: Tr): string {
|
||||
const { contributionSummary: c } = review;
|
||||
const parts = [
|
||||
`Éditeur : ${review.publisher ?? "inconnu"}`,
|
||||
`Version : ${review.version}`,
|
||||
`${tr("Éditeur", "Publisher")} : ${review.publisher ?? tr("inconnu", "unknown")}`,
|
||||
`${tr("Version", "Version")} : ${review.version}`,
|
||||
review.description ?? null,
|
||||
`Confiance : full-trust — ce plugin peut exécuter du code arbitraire dans IdeA.`,
|
||||
`Contributions : ${c.topLevelMenus} menu(s), ${c.menuItems} item(s), ${c.layouts} layout(s), ${c.mcpServers} serveur(s) MCP.`,
|
||||
`${tr("Confiance", "Trust")}: full-trust — ${tr("ce plugin peut exécuter du code arbitraire dans IdeA.", "this plugin can execute arbitrary code in IdeA.")}`,
|
||||
`${tr("Contributions", "Contributions")}: ${c.topLevelMenus} ${tr("menu(s)", "menu(s)")}, ${c.menuItems} ${tr("élément(s)", "item(s)")}, ${c.layouts} ${tr("disposition(s)", "layout(s)")}, ${c.mcpServers} ${tr("serveur(s) MCP", "MCP server(s)")}.`,
|
||||
...(review.issues ?? []).map(
|
||||
(i) => `${i.severity === "error" ? "Erreur" : "Avertissement"} : ${i.message}`,
|
||||
(i) => `${i.severity === "error" ? tr("Erreur", "Error") : tr("Avertissement", "Warning")} : ${i.message}`,
|
||||
),
|
||||
].filter((p): p is string => Boolean(p));
|
||||
return parts.join("\n");
|
||||
|
||||
@ -63,7 +63,7 @@ describe("project plugin settings #282", () => {
|
||||
|
||||
expect(await screen.findByText("Unity Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Installé globalement")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Voir les capabilities"));
|
||||
fireEvent.click(screen.getByText("Voir les capacités"));
|
||||
expect(screen.getByText("unity-debug")).toBeTruthy();
|
||||
expect(screen.getByText("Unity Tools")).toBeTruthy();
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { useProjectPlugins } from "./useProjectPlugins";
|
||||
|
||||
export interface ProjectPluginsPanelProps {
|
||||
@ -14,13 +15,14 @@ export function ProjectPluginsPanel({
|
||||
projectId,
|
||||
onAssignAgents,
|
||||
}: ProjectPluginsPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useProjectPlugins(projectId);
|
||||
|
||||
if (vm.loading && !vm.settings) {
|
||||
return (
|
||||
<Panel aria-label="plugins du projet" className="flex items-center gap-2">
|
||||
<Panel aria-label={tr("Plugins du projet", "Project plugins")} className="flex items-center gap-2">
|
||||
<Spinner size={14} />
|
||||
<span className="text-sm text-muted">Chargement des plugins du projet…</span>
|
||||
<span className="text-sm text-muted">{tr("Chargement des plugins du projet…", "Loading project plugins…")}</span>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -31,18 +33,17 @@ export function ProjectPluginsPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label="plugins du projet" className="flex flex-col gap-3">
|
||||
<section aria-label={tr("Plugins du projet", "Project plugins")} className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
||||
<h2 className="text-sm font-semibold text-content">{tr("Plugins du projet", "Project plugins")}</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Activez les plugins utilisés par ce projet. Les attributions se gèrent
|
||||
dans les panneaux Skills et MCP.
|
||||
{tr("Activez les plugins utilisés par ce projet. Les attributions se gèrent dans les panneaux Skills et MCP.", "Enable the plugins used by this project. Assignments are managed in the Skills and MCP panels.")}
|
||||
</p>
|
||||
</div>
|
||||
{onAssignAgents && (
|
||||
<Button size="sm" variant="ghost" onClick={onAssignAgents}>
|
||||
Gérer les skills
|
||||
{tr("Gérer les skills", "Manage skills")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -50,7 +51,7 @@ export function ProjectPluginsPanel({
|
||||
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
|
||||
|
||||
{vm.installed.length === 0 && missing.length === 0 ? (
|
||||
<Panel><p className="text-sm text-muted">Aucun plugin installé globalement.</p></Panel>
|
||||
<Panel><p className="text-sm text-muted">{tr("Aucun plugin installé globalement.", "No globally installed plugins.")}</p></Panel>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{vm.installed.map((plugin) => {
|
||||
@ -68,11 +69,11 @@ export function ProjectPluginsPanel({
|
||||
<span className="text-sm font-semibold text-content">{plugin.displayName}</span>
|
||||
<span className="text-xs text-faint">v{plugin.version}</span>
|
||||
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] text-muted">
|
||||
Installé globalement
|
||||
{tr("Installé globalement", "Globally installed")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{countLabel(skills.length, "skill", "skills")} · {countLabel(servers.length, "serveur MCP", "serveurs MCP")}
|
||||
{countLabel(skills.length, "skill", "skills")} · {countLabel(servers.length, tr("serveur MCP", "MCP server"), tr("serveurs MCP", "MCP servers"))}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs text-content">
|
||||
@ -82,19 +83,19 @@ export function ProjectPluginsPanel({
|
||||
disabled={vm.busy}
|
||||
onChange={(event) => void vm.setEnabled(plugin.id, event.target.checked)}
|
||||
/>
|
||||
Activé pour ce projet
|
||||
{tr("Activé pour ce projet", "Enabled for this project")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs font-medium text-primary">
|
||||
Voir les capabilities
|
||||
{tr("Voir les capacités", "View capabilities")}
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-content">Skills fournis</h3>
|
||||
<h3 className="text-xs font-semibold text-content">{tr("Skills fournis", "Provided skills")}</h3>
|
||||
{skills.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-muted">Aucun skill fourni.</p>
|
||||
<p className="mt-1 text-xs text-muted">{tr("Aucun skill fourni.", "No skill provided.")}</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col gap-1">
|
||||
{skills.map((skill) => (
|
||||
@ -107,9 +108,9 @@ export function ProjectPluginsPanel({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-content">Tools MCP fournis</h3>
|
||||
<h3 className="text-xs font-semibold text-content">{tr("Outils MCP fournis", "Provided MCP tools")}</h3>
|
||||
{servers.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-muted">Aucun serveur MCP fourni.</p>
|
||||
<p className="mt-1 text-xs text-muted">{tr("Aucun serveur MCP fourni.", "No MCP server provided.")}</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col gap-1">
|
||||
{servers.map((server) => (
|
||||
@ -133,7 +134,7 @@ export function ProjectPluginsPanel({
|
||||
<Panel className="flex items-center justify-between gap-4 border-warning/40">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-content">{activation.pluginId}</span>
|
||||
<p className="text-xs text-warning">Plugin manquant · configuration conservée</p>
|
||||
<p className="text-xs text-warning">{tr("Plugin manquant · configuration conservée", "Missing plugin · configuration preserved")}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
@ -142,7 +143,7 @@ export function ProjectPluginsPanel({
|
||||
disabled={vm.busy || !activation.enabled}
|
||||
onChange={(event) => void vm.setEnabled(activation.pluginId, event.target.checked)}
|
||||
/>
|
||||
Activé pour ce projet
|
||||
{tr("Activé pour ce projet", "Enabled for this project")}
|
||||
</label>
|
||||
</Panel>
|
||||
</li>
|
||||
|
||||
@ -181,7 +181,7 @@ describe("PluginsPanel", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
expect(screen.getByText("Certains plugins installés n'ont pas pu être chargés.")).toBeTruthy();
|
||||
expect(screen.getByText("Certains plugins installés n’ont pas pu être chargés.")).toBeTruthy();
|
||||
expect(screen.getByText("com.example.hello-plugin")).toBeTruthy();
|
||||
expect(screen.getByText(/Cannot use import statement outside a module/)).toBeTruthy();
|
||||
});
|
||||
@ -190,7 +190,7 @@ describe("PluginsPanel", () => {
|
||||
renderPanelWithLiveRuntime(new FailingRuntimeCatalogPluginGateway());
|
||||
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
expect(screen.getByText("Certains plugins installés n'ont pas pu être chargés.")).toBeTruthy();
|
||||
expect(screen.getByText("Certains plugins installés n’ont pas pu être chargés.")).toBeTruthy();
|
||||
expect(screen.getByText("<runtime-catalog>")).toBeTruthy();
|
||||
expect(screen.getByText(/runtime catalog failed/)).toBeTruthy();
|
||||
});
|
||||
@ -210,7 +210,7 @@ describe("PluginsPanel", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
const failureTitle = screen.getByText("Certains plugins installés n'ont pas pu être chargés.");
|
||||
const failureTitle = screen.getByText("Certains plugins installés n’ont pas pu être chargés.");
|
||||
const pendingTitle = screen.getByText("Certains plugins attendent un projet actif.");
|
||||
expect(
|
||||
failureTitle.compareDocumentPosition(pendingTitle) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
@ -245,7 +245,7 @@ describe("PluginsPanel", () => {
|
||||
|
||||
expect(await screen.findByText("Certains plugins attendent un projet actif.")).toBeTruthy();
|
||||
expect(screen.getByText("Project Plugin")).toBeTruthy();
|
||||
expect(screen.queryByText("Certains plugins installés n'ont pas pu être chargés.")).toBeNull();
|
||||
expect(screen.queryByText("Certains plugins installés n’ont pas pu être chargés.")).toBeNull();
|
||||
|
||||
await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" });
|
||||
|
||||
@ -293,7 +293,7 @@ describe("PluginsPanel", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const failureTitle = await screen.findByText("Certains plugins installés n'ont pas pu être chargés.");
|
||||
const failureTitle = await screen.findByText("Certains plugins installés n’ont pas pu être chargés.");
|
||||
const failureSection = failureTitle.closest("section");
|
||||
expect(failureSection).not.toBeNull();
|
||||
expect(within(failureSection as HTMLElement).getByText("dev.acme.app-needs-project")).toBeTruthy();
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { IconButton, zIndex } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface ProjectActionsMenuProps {
|
||||
projectName: string;
|
||||
@ -16,6 +17,7 @@ export function ProjectActionsMenu({
|
||||
onOpen,
|
||||
onRemove,
|
||||
}: ProjectActionsMenuProps) {
|
||||
const { tr } = useI18n();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
@ -58,7 +60,7 @@ export function ProjectActionsMenu({
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
size="sm"
|
||||
aria-label={`Actions pour ${projectName}`}
|
||||
aria-label={tr(`Actions pour ${projectName}`, `Actions for ${projectName}`)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
@ -71,7 +73,7 @@ export function ProjectActionsMenu({
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={`Actions pour ${projectName}`}
|
||||
aria-label={tr(`Actions pour ${projectName}`, `Actions for ${projectName}`)}
|
||||
className="fixed w-48 rounded-md border border-border bg-surface py-1 shadow-xl"
|
||||
style={{ ...position, zIndex: zIndex.transientPopup }}
|
||||
>
|
||||
@ -84,7 +86,7 @@ export function ProjectActionsMenu({
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Ouvrir
|
||||
{tr("Ouvrir", "Open")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -95,7 +97,7 @@ export function ProjectActionsMenu({
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
Retirer d'IdeA…
|
||||
{tr("Retirer d’IdeA…", "Remove from IdeA…")}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
|
||||
import type { GatewayError } from "@/domain";
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export interface ProjectContextPanelProps {
|
||||
/** Project whose `.ideai/CONTEXT.md` should be edited. */
|
||||
@ -25,6 +26,7 @@ function describe(e: unknown): string {
|
||||
}
|
||||
|
||||
export function ProjectContextPanel({ projectId }: ProjectContextPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const { project } = useGateways();
|
||||
const [content, setContent] = useState("");
|
||||
const [savedContent, setSavedContent] = useState("");
|
||||
@ -70,7 +72,7 @@ export function ProjectContextPanel({ projectId }: ProjectContextPanelProps) {
|
||||
const dirty = content !== savedContent;
|
||||
|
||||
return (
|
||||
<Panel title="Project Context" className="flex flex-col gap-0">
|
||||
<Panel title={tr("Contexte du projet", "Project context")} className="flex flex-col gap-0">
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
@ -86,7 +88,7 @@ export function ProjectContextPanel({ projectId }: ProjectContextPanelProps) {
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<textarea
|
||||
aria-label="project context"
|
||||
aria-label={tr("contexte du projet", "project context")}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={18}
|
||||
@ -106,7 +108,7 @@ export function ProjectContextPanel({ projectId }: ProjectContextPanelProps) {
|
||||
disabled={busy || !dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
Save
|
||||
{tr("Enregistrer", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
42
frontend/src/features/projects/ProjectIconPicker.tsx
Normal file
42
frontend/src/features/projects/ProjectIconPicker.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ProjectIconRef } from "@/ports";
|
||||
import { Button, Input } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PROJECT_ICON_CATALOG, ProjectIcon } from "./projectIcons";
|
||||
|
||||
export function ProjectIconPicker({ value, onApply, onCancel }: {
|
||||
value: ProjectIconRef | null;
|
||||
onApply(value: ProjectIconRef | null): void;
|
||||
onCancel(): void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<ProjectIconRef | null>(value);
|
||||
const results = useMemo(() => {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word))));
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
|
||||
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
|
||||
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
|
||||
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
|
||||
<div role="listbox" aria-label={t("project.icon.results")} className="grid min-h-28 grid-cols-4 gap-2 overflow-y-auto sm:grid-cols-6">
|
||||
{results.map((entry) => {
|
||||
const active = selected?.set === entry.set && selected.name === entry.name;
|
||||
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
|
||||
<ProjectIcon icon={entry} size={32} decorative />
|
||||
<span className="line-clamp-2">{entry.label}</span>
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>
|
||||
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
import type { Project } from "@/domain";
|
||||
import { Button, Input, Panel } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export interface ProjectLauncherProps {
|
||||
/** Form field values */
|
||||
@ -37,6 +38,7 @@ export function ProjectLauncher({
|
||||
onOpen,
|
||||
error,
|
||||
}: ProjectLauncherProps) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<div className="flex flex-1 items-start justify-center p-8">
|
||||
<div className="flex w-full max-w-2xl flex-col gap-6">
|
||||
@ -50,34 +52,34 @@ export function ProjectLauncher({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-content">New project</h2>
|
||||
<h2 className="text-base font-semibold text-content">{tr("Nouveau projet", "New project")}</h2>
|
||||
<form onSubmit={onSubmit} className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="project name"
|
||||
placeholder="Project name"
|
||||
aria-label={tr("nom du projet", "project name")}
|
||||
placeholder={tr("Nom du projet", "Project name")}
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<Input
|
||||
aria-label="project root"
|
||||
aria-label={tr("racine du projet", "project root")}
|
||||
placeholder="/absolute/project/root"
|
||||
value={root}
|
||||
onChange={(e) => onRootChange(e.target.value)}
|
||||
className="min-w-80 flex-1"
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={!canCreate}>
|
||||
Create project
|
||||
{tr("Créer le projet", "Create project")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onRefresh} disabled={busy}>
|
||||
Refresh
|
||||
{tr("Rafraîchir", "Refresh")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Panel title="Known projects">
|
||||
<Panel title={tr("Projets connus", "Known projects")}>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">No projects yet.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun projet.", "No projects yet.")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{projects.map((p) => (
|
||||
@ -90,7 +92,7 @@ export function ProjectLauncher({
|
||||
<code className="truncate text-xs text-muted">{p.root}</code>
|
||||
</span>
|
||||
<Button size="sm" onClick={() => onOpen(p.id)}>
|
||||
Open
|
||||
{tr("Ouvrir", "Open")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@ -39,7 +39,7 @@ describe("ProjectSettingsPanel", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rétablir la couleur par défaut" }));
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith(project.id, { themeColor: null }));
|
||||
expect(screen.getByText("Contexte du projet")).toBeTruthy();
|
||||
expect(screen.getAllByText("Contexte du projet").length).toBeGreaterThan(0);
|
||||
window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
|
||||
});
|
||||
|
||||
@ -224,7 +224,9 @@ describe("ProjectSettingsPanel", () => {
|
||||
const pendingSave = deferred<{ themeColor: string | null }>();
|
||||
vi.spyOn(gateways.project, "updateProjectSettings").mockImplementation(
|
||||
async (projectId, settings) =>
|
||||
projectId === alpha.id ? pendingSave.promise : settings,
|
||||
projectId === alpha.id
|
||||
? pendingSave.promise
|
||||
: { themeColor: settings.themeColor ?? null, icon: settings.icon ?? null },
|
||||
);
|
||||
const view = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -246,6 +248,53 @@ describe("ProjectSettingsPanel", () => {
|
||||
await waitFor(() => expect(picker.value).toBe("#22aa66"));
|
||||
});
|
||||
|
||||
it("searches the English icon catalog, previews a selection, and persists it", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
const update = vi.spyOn(gateways.project, "updateProjectSettings");
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={project.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Choisir une icône" }));
|
||||
const search = screen.getByLabelText("Search icons in English");
|
||||
fireEvent.change(search, { target: { value: "docker" } });
|
||||
const docker = await screen.findByRole("option", { name: "Docker" });
|
||||
fireEvent.click(docker);
|
||||
expect(docker.getAttribute("aria-selected")).toBe("true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(project.id, {
|
||||
icon: { set: "simple-icons", name: "docker" },
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: "Modifier l’icône" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rolls back an icon removal when persistence fails", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
await gateways.project.updateProjectSettings(project.id, {
|
||||
icon: { set: "devicon", name: "python" },
|
||||
});
|
||||
const update = vi
|
||||
.spyOn(gateways.project, "updateProjectSettings")
|
||||
.mockRejectedValueOnce(new Error("temporary"));
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={project.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Retirer l’icône" }));
|
||||
expect(await screen.findByText("Impossible d’appliquer l’icône.")).toBeTruthy();
|
||||
expect(update).toHaveBeenCalledWith(project.id, { icon: null });
|
||||
expect(screen.getByRole("button", { name: "Modifier l’icône" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps strict colours and raises dark accents to readable contrast", () => {
|
||||
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
|
||||
expect(validThemeColor("red")).toBeNull();
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { ProjectIconRef } from "@/ports";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||
import { ProjectIcon, publishProjectIconChanged } from "./projectIcons";
|
||||
import {
|
||||
DEFAULT_PROJECT_COLOR,
|
||||
onProjectThemeChanged,
|
||||
@ -14,11 +18,15 @@ export { PROJECT_THEME_CHANGED_EVENT } from "./projectTheme";
|
||||
|
||||
export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
const { project } = useGateways();
|
||||
const { t } = useI18n();
|
||||
const [themeColor, setThemeColor] = useState<string | null>(null);
|
||||
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [failedSave, setFailedSave] = useState<{
|
||||
themeColor: string | null;
|
||||
kind: "theme" | "icon";
|
||||
value: string | ProjectIconRef | null;
|
||||
} | null>(null);
|
||||
const loadSequence = useRef(0);
|
||||
const saveSequence = useRef(0);
|
||||
@ -34,6 +42,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
setError(null);
|
||||
setFailedSave(null);
|
||||
setThemeColor(null);
|
||||
setProjectIcon(null);
|
||||
try {
|
||||
const settings = await project.readProjectSettings(loadedProjectId);
|
||||
if (
|
||||
@ -43,17 +52,18 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
) return;
|
||||
const loaded = validThemeColor(settings.themeColor);
|
||||
setThemeColor(loaded);
|
||||
setProjectIcon(settings.icon ?? null);
|
||||
} catch {
|
||||
if (
|
||||
loadSequence.current !== sequence ||
|
||||
currentProjectId.current !== loadedProjectId ||
|
||||
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
|
||||
) return;
|
||||
setError("Impossible de charger les paramètres du projet.");
|
||||
setError(t("project.settings.loadError"));
|
||||
} finally {
|
||||
if (loadSequence.current === sequence) setBusy(false);
|
||||
}
|
||||
}, [project, projectId]);
|
||||
}, [project, projectId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@ -115,8 +125,8 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
saveSequence.current === sequence
|
||||
) {
|
||||
setThemeColor(previous);
|
||||
setError("Impossible d’appliquer la couleur.");
|
||||
setFailedSave({ themeColor: requested });
|
||||
setError(t("project.color.error"));
|
||||
setFailedSave({ kind: "theme", value: requested });
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
@ -128,14 +138,42 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIcon(next: ProjectIconRef | null) {
|
||||
const savedProjectId = projectId;
|
||||
const sequence = ++saveSequence.current;
|
||||
const previous = projectIcon;
|
||||
setIconPickerOpen(false);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFailedSave(null);
|
||||
setProjectIcon(next);
|
||||
publishProjectIconChanged({ projectId: savedProjectId, icon: next });
|
||||
try {
|
||||
const saved = await project.updateProjectSettings(savedProjectId, { icon: next });
|
||||
if (currentProjectId.current === savedProjectId && saveSequence.current === sequence) {
|
||||
setProjectIcon(saved.icon ?? null);
|
||||
}
|
||||
publishProjectIconChanged({ projectId: savedProjectId, icon: saved.icon ?? null });
|
||||
} catch {
|
||||
publishProjectIconChanged({ projectId: savedProjectId, icon: previous });
|
||||
if (currentProjectId.current === savedProjectId && saveSequence.current === sequence) {
|
||||
setProjectIcon(previous);
|
||||
setError(t("project.icon.error"));
|
||||
setFailedSave({ kind: "icon", value: next });
|
||||
}
|
||||
} finally {
|
||||
if (currentProjectId.current === savedProjectId && saveSequence.current === sequence) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel title="Apparence" className="flex flex-col gap-4 p-4">
|
||||
<Panel title={t("project.appearance")} className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-content">
|
||||
Couleur du projet
|
||||
{t("project.color")}
|
||||
<input
|
||||
aria-label="Couleur du projet"
|
||||
aria-label={t("project.color")}
|
||||
type="color"
|
||||
value={themeColor ?? DEFAULT_PROJECT_COLOR}
|
||||
disabled={busy}
|
||||
@ -144,9 +182,19 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" variant="ghost" disabled={busy || themeColor === null} onClick={() => void save(null)}>
|
||||
Rétablir la couleur par défaut
|
||||
{t("project.color.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<div className="flex items-center gap-3 text-sm font-medium text-content">
|
||||
{projectIcon ? <ProjectIcon icon={projectIcon} size={38} /> : <span className="flex h-9 w-9 items-center justify-center rounded border border-dashed border-border text-muted">—</span>}
|
||||
<span>{t("project.icon")}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{projectIcon && <Button size="sm" variant="ghost" disabled={busy} onClick={() => void saveIcon(null)}>{t("project.icon.remove")}</Button>}
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setIconPickerOpen(true)}>{projectIcon ? t("project.icon.change") : t("project.icon.choose")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{busy && <Spinner size={14} />}
|
||||
{error && (
|
||||
<div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-danger">
|
||||
@ -155,10 +203,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
failedSave ? void save(failedSave.themeColor) : void load()
|
||||
failedSave?.kind === "theme"
|
||||
? void save(failedSave.value as string | null)
|
||||
: failedSave?.kind === "icon"
|
||||
? void saveIcon(failedSave.value as ProjectIconRef | null)
|
||||
: void load()
|
||||
}
|
||||
>
|
||||
Réessayer
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -169,10 +221,11 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
id="project-context-settings-title"
|
||||
className="mb-2 text-sm font-semibold text-content"
|
||||
>
|
||||
Contexte du projet
|
||||
{t("project.context")}
|
||||
</h2>
|
||||
<ProjectContextPanel projectId={projectId} />
|
||||
</section>
|
||||
{iconPickerOpen && <ProjectIconPicker value={projectIcon} onApply={(icon) => void saveIcon(icon)} onCancel={() => setIconPickerOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
|
||||
describe("ProjectTabs add-project button (#46)", () => {
|
||||
it("renders the + button when projects are open", () => {
|
||||
renderTabs({ items: TABS });
|
||||
const btn = screen.getByRole("button", { name: "open projects panel" });
|
||||
const btn = screen.getByRole("button", { name: "ouvrir le panneau des projets" });
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn.textContent).toContain("+");
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
@ -45,9 +45,9 @@ describe("ProjectTabs add-project button (#46)", () => {
|
||||
it("still renders the + button with ZERO open projects (only way to add one)", () => {
|
||||
renderTabs({ items: [], activeTabId: null });
|
||||
// The empty placeholder is shown…
|
||||
expect(screen.getByText("No open tabs.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun onglet ouvert.")).toBeTruthy();
|
||||
// …and the add-project button is still present and enabled.
|
||||
const btn = screen.getByRole("button", { name: "open projects panel" });
|
||||
const btn = screen.getByRole("button", { name: "ouvrir le panneau des projets" });
|
||||
expect(btn).toBeTruthy();
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
@ -57,21 +57,21 @@ describe("ProjectTabs add-project button (#46)", () => {
|
||||
// ghost variant did not. Asserting the border class keeps the button from
|
||||
// silently reverting to the invisible style.
|
||||
renderTabs({ items: [] });
|
||||
const btn = screen.getByRole("button", { name: "open projects panel" });
|
||||
const btn = screen.getByRole("button", { name: "ouvrir le panneau des projets" });
|
||||
expect(btn.className).toContain("border");
|
||||
expect(btn.className).not.toContain("text-muted");
|
||||
});
|
||||
|
||||
it("reflects the panel-open state via aria-pressed", () => {
|
||||
const { onOpenProjectsPanel } = renderTabs({ projectsPanelOpen: true });
|
||||
const btn = screen.getByRole("button", { name: "open projects panel" });
|
||||
const btn = screen.getByRole("button", { name: "ouvrir le panneau des projets" });
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(onOpenProjectsPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the Projects panel on click, independent of any active project", () => {
|
||||
const { onOpenProjectsPanel } = renderTabs({ items: [], activeTabId: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "ouvrir le panneau des projets" }));
|
||||
expect(onOpenProjectsPanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -81,7 +81,7 @@ describe("ProjectTabs project activity dots (#159)", () => {
|
||||
renderTabs({ items: TABS });
|
||||
|
||||
const tab = screen.getByRole("tab", { name: /alpha/ });
|
||||
expect(within(tab).getByTitle("alpha : Prêt")).toBeTruthy();
|
||||
expect(within(tab).getByTitle(/alpha.*prêt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders idle and busy project activity states", () => {
|
||||
@ -90,7 +90,7 @@ describe("ProjectTabs project activity dots (#159)", () => {
|
||||
activityByProjectId: { a: "idle", b: "busy" },
|
||||
});
|
||||
|
||||
expect(screen.getByTitle("alpha : Prêt")).toBeTruthy();
|
||||
expect(screen.getByTitle("beta : Échange en cours")).toBeTruthy();
|
||||
expect(screen.getByTitle(/alpha.*prêt/i)).toBeTruthy();
|
||||
expect(screen.getByTitle(/beta.*échange en cours/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
|
||||
import type { TabItem } from "@/shared";
|
||||
import { Button, Tabs, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { ProjectActivityStatus } from "./projectActivity";
|
||||
import { accessibleProjectAccent } from "./projectTheme";
|
||||
|
||||
@ -41,6 +42,7 @@ export function ProjectTabs({
|
||||
themeColorByProjectId = {},
|
||||
className,
|
||||
}: ProjectTabsProps) {
|
||||
const { tr } = useI18n();
|
||||
const tabs = items.map((item) => ({
|
||||
...item,
|
||||
themeColor: themeColorByProjectId[item.id] ?? null,
|
||||
@ -63,7 +65,7 @@ export function ProjectTabs({
|
||||
)}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
||||
<p className="px-2 text-sm text-muted">{tr("Aucun onglet ouvert.", "No open tabs.")}</p>
|
||||
) : (
|
||||
<Tabs
|
||||
items={tabs}
|
||||
@ -80,9 +82,9 @@ export function ProjectTabs({
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onOpenProjectsPanel}
|
||||
aria-label="open projects panel"
|
||||
aria-label={tr("ouvrir le panneau des projets", "open projects panel")}
|
||||
aria-pressed={projectsPanelOpen}
|
||||
title="Ajouter un projet"
|
||||
title={tr("Ajouter un projet", "Add a project")}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
projectsPanelOpen && "ring-1 ring-border-strong",
|
||||
@ -101,10 +103,11 @@ function ProjectActivityDot({
|
||||
status: ProjectActivityStatus;
|
||||
label: string;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const title =
|
||||
status === "busy"
|
||||
? `${label} : Échange en cours`
|
||||
: `${label} : Prêt`;
|
||||
? tr(`${label} : échange en cours`, `${label}: conversation in progress`)
|
||||
: tr(`${label} : prêt`, `${label}: ready`);
|
||||
return (
|
||||
<span
|
||||
data-testid={`project-activity-${label}`}
|
||||
|
||||
@ -44,7 +44,7 @@ async function renderWithProject() {
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
@ -68,7 +68,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
|
||||
// A docked view is visible in the main window first…
|
||||
pickPlacement("Git", "Ancré à gauche");
|
||||
await screen.findByRole("complementary", { name: "left dock" });
|
||||
await screen.findByRole("complementary", { name: "Zone ancrée à gauche" });
|
||||
|
||||
// …then « Panneaux → Git → Fenêtre détachée » pops it out.
|
||||
pickPlacement("Git", "Fenêtre détachée");
|
||||
@ -78,7 +78,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
);
|
||||
// The slot is detached ⇒ nothing renders for Git in the main window.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("complementary", { name: "left dock" })).toBeNull(),
|
||||
expect(screen.queryByRole("complementary", { name: "Zone ancrée à gauche" })).toBeNull(),
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull();
|
||||
});
|
||||
|
||||
@ -41,7 +41,7 @@ async function renderWithProject() {
|
||||
</DIProvider>,
|
||||
);
|
||||
// Open the seeded project so panels have an active project to render for.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
@ -74,8 +74,8 @@ describe("ProjectsView view docking (#22)", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull(),
|
||||
);
|
||||
const leftDock = screen.getByRole("complementary", { name: "left dock" });
|
||||
expect(within(leftDock).getByLabelText("Git panel")).toBeTruthy();
|
||||
const leftDock = screen.getByRole("complementary", { name: "Zone ancrée à gauche" });
|
||||
expect(within(leftDock).getByLabelText("Git — panneau")).toBeTruthy();
|
||||
// The main surface is still present beside the dock (in-flow, not overlay).
|
||||
expect(screen.getByRole("main")).toBeTruthy();
|
||||
});
|
||||
@ -87,18 +87,18 @@ describe("ProjectsView view docking (#22)", () => {
|
||||
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Ancré à gauche — Git" }));
|
||||
|
||||
const leftDock = await screen.findByRole("complementary", { name: "left dock" });
|
||||
const leftHeader = within(leftDock).getByLabelText("Git panel");
|
||||
// Move it to the right dock from the docked header control.
|
||||
const leftDock = await screen.findByRole("complementary", { name: "Zone ancrée à gauche" });
|
||||
const leftHeader = within(leftDock).getByLabelText("Git — panneau");
|
||||
// Move it to the Zone ancrée à droite from the docked header control.
|
||||
fireEvent.click(
|
||||
within(leftHeader).getByRole("button", { name: "Ancré à droite — Git" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("complementary", { name: "left dock" })).toBeNull(),
|
||||
expect(screen.queryByRole("complementary", { name: "Zone ancrée à gauche" })).toBeNull(),
|
||||
);
|
||||
const rightDock = screen.getByRole("complementary", { name: "right dock" });
|
||||
expect(within(rightDock).getByLabelText("Git panel")).toBeTruthy();
|
||||
const rightDock = screen.getByRole("complementary", { name: "Zone ancrée à droite" });
|
||||
expect(within(rightDock).getByLabelText("Git — panneau")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("docks two different views side by side (left + right)", async () => {
|
||||
@ -121,9 +121,9 @@ describe("ProjectsView view docking (#22)", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const leftDock = await screen.findByRole("complementary", { name: "left dock" });
|
||||
const rightDock = await screen.findByRole("complementary", { name: "right dock" });
|
||||
expect(within(leftDock).getByLabelText("Git panel")).toBeTruthy();
|
||||
expect(within(rightDock).getByLabelText("Agents panel")).toBeTruthy();
|
||||
const leftDock = await screen.findByRole("complementary", { name: "Zone ancrée à gauche" });
|
||||
const rightDock = await screen.findByRole("complementary", { name: "Zone ancrée à droite" });
|
||||
expect(within(leftDock).getByLabelText("Git — panneau")).toBeTruthy();
|
||||
expect(within(rightDock).getByLabelText("Agents — panneau")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -57,7 +57,7 @@ describe("ProjectsView focus publishing (#47)", () => {
|
||||
await waitFor(() => expect(published).toContainEqual(null));
|
||||
|
||||
// Open the project → publishes the { id, name, root } snapshot.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
@ -70,7 +70,7 @@ describe("ProjectsView focus publishing (#47)", () => {
|
||||
);
|
||||
|
||||
// Close the tab → back to no active project → publishes `null`.
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fermer alpha" }));
|
||||
await waitFor(() =>
|
||||
expect(focusedProject.getFocusedProject()).resolves.toBeNull(),
|
||||
);
|
||||
|
||||
@ -144,10 +144,11 @@ async function openProjectAndWorkTab(label: string) {
|
||||
const li = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((node) => within(node).queryByText(label));
|
||||
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(li!).getByRole("button", { name: "Ouvrir" }));
|
||||
await screen.findByRole("tab");
|
||||
// Open the Work panel window from the View menu.
|
||||
openPanel("Work state");
|
||||
openPanel("Travail");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Détails" }));
|
||||
}
|
||||
|
||||
describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
@ -166,7 +167,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
|
||||
// Click the conversation row in the Work panel.
|
||||
const open = await screen.findByRole("button", {
|
||||
name: `open conversation ${CONV_ID}`,
|
||||
name: `ouvrir la conversation ${CONV_ID}`,
|
||||
});
|
||||
fireEvent.click(open);
|
||||
|
||||
@ -219,12 +220,12 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
// persists across activation (the inline welcome list is replaced by the
|
||||
// grid once a project is active).
|
||||
await screen.findByText("/p/a");
|
||||
openPanel("Projects");
|
||||
fireEvent.click(screen.getByLabelText("ouvrir le panneau des projets"));
|
||||
for (const root of ["/p/a", "/p/b"]) {
|
||||
const li = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((node) => within(node).queryByText(root));
|
||||
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(li!).getByRole("button", { name: "Ouvrir" }));
|
||||
}
|
||||
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
|
||||
|
||||
@ -240,7 +241,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
conversationId: CONV_ID,
|
||||
});
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Background task completed/i }),
|
||||
await screen.findByRole("button", { name: /Tâche de fond terminée/i }),
|
||||
);
|
||||
await screen.findByRole("button", { name: "← Retour aux terminaux" });
|
||||
await screen.findByText(`contenu du fil ${alpha.id}`);
|
||||
@ -290,7 +291,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
|
||||
await openProjectAndWorkTab("/p/a");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
||||
await screen.findByRole("button", { name: `ouvrir la conversation ${CONV_ID}` }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
|
||||
@ -312,7 +313,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
await screen.findByRole("tab");
|
||||
|
||||
// No conversation opened ⇒ the main area is the terminal grid, no viewer.
|
||||
@ -350,9 +351,9 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
});
|
||||
|
||||
const toast = await screen.findByRole("button", {
|
||||
name: /Appel Main -> DevBackend terminé/i,
|
||||
name: /Appel Main → DevBackend terminé/i,
|
||||
});
|
||||
expect(within(toast).getByText("alpha · Task task-ren")).toBeTruthy();
|
||||
expect(within(toast).getByText("alpha · Tâche task-ren")).toBeTruthy();
|
||||
|
||||
fireEvent.click(toast);
|
||||
|
||||
@ -361,7 +362,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Appel Main -> DevBackend terminé/i }),
|
||||
screen.queryByRole("button", { name: /Appel Main → DevBackend terminé/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@ -402,12 +403,12 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Appel Main -> DevBackend en échec/i,
|
||||
name: /Appel Main → DevBackend en échec/i,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Appel Main -> DevBackend annulé/i,
|
||||
name: /Appel Main → DevBackend annulé/i,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@ -428,7 +429,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
});
|
||||
|
||||
const toast = await screen.findByRole("button", {
|
||||
name: /Background task failed/i,
|
||||
name: /Tâche de fond en échec/i,
|
||||
});
|
||||
expect(within(toast).getByText("alpha · agent-ge · task-gen")).toBeTruthy();
|
||||
|
||||
@ -437,7 +438,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull();
|
||||
expect(await screen.findByText("Work")).toBeTruthy();
|
||||
expect((await screen.findAllByText("Travail")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not close an open viewer when a background-task toast has no conversation", async () => {
|
||||
@ -448,7 +449,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
|
||||
await openProjectAndWorkTab("/p/a");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
||||
await screen.findByRole("button", { name: `ouvrir la conversation ${CONV_ID}` }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
|
||||
@ -463,7 +464,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Background task failed/i }),
|
||||
await screen.findByRole("button", { name: /Tâche de fond en échec/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@ -50,7 +50,7 @@ async function renderWith(windowGateway: WindowGateway) {
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByRole("tablist")).getAllByRole("tab"),
|
||||
|
||||
@ -53,7 +53,6 @@ import { ConversationViewer } from "@/features/conversations";
|
||||
import {
|
||||
SettingsView,
|
||||
SETTINGS_SECTIONS,
|
||||
SETTINGS_SECTION_LABEL,
|
||||
type SettingsSection,
|
||||
} from "@/features/settings";
|
||||
import { GitGraphView } from "@/features/git";
|
||||
@ -75,6 +74,7 @@ import {
|
||||
type MenuBarMenu,
|
||||
} from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n, type TranslationKey } from "@/i18n";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useProjectActivity } from "./useProjectActivity";
|
||||
import { ProjectTabs } from "./ProjectTabs";
|
||||
@ -87,7 +87,6 @@ import {
|
||||
} from "./projectTheme";
|
||||
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
||||
import {
|
||||
PANEL_TITLE,
|
||||
floatingPanels,
|
||||
isDetached,
|
||||
isDockedTo,
|
||||
@ -203,38 +202,55 @@ function agentLabel(agents: Agent[], agentId: string): string {
|
||||
function projectToastName(
|
||||
projectNameById: ReadonlyMap<string, string>,
|
||||
projectId: string,
|
||||
tr: (fr: string, en: string) => string,
|
||||
): string {
|
||||
return projectNameById.get(projectId) ?? "Projet inconnu";
|
||||
return projectNameById.get(projectId) ?? tr("Projet inconnu", "Unknown project");
|
||||
}
|
||||
|
||||
function rendezvousToastStateLabel(state: string): string {
|
||||
function rendezvousToastStateLabel(state: string, tr: (fr: string, en: string) => string): string {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
case "delivered":
|
||||
return "terminé";
|
||||
return tr("terminé", "completed");
|
||||
case "failed":
|
||||
return "en échec";
|
||||
return tr("en échec", "failed");
|
||||
case "cancelled":
|
||||
return "annulé";
|
||||
return tr("annulé", "cancelled");
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFailureCause(failure: OpenProjectRestoreFailure): string {
|
||||
function backgroundToastStateLabel(state: string, tr: (fr: string, en: string) => string): string {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
case "delivered":
|
||||
return tr("terminée", "completed");
|
||||
case "failed":
|
||||
return tr("en échec", "failed");
|
||||
case "cancelled":
|
||||
return tr("annulée", "cancelled");
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFailureCause(failure: OpenProjectRestoreFailure, tr: (fr: string, en: string) => string): string {
|
||||
switch (failure.code) {
|
||||
case "notFound":
|
||||
return "Le projet n’est plus enregistré.";
|
||||
return tr("Le projet n’est plus enregistré.", "The project is no longer registered.");
|
||||
case "inaccessible":
|
||||
return "Le chemin est introuvable ou inaccessible.";
|
||||
return tr("Le chemin est introuvable ou inaccessible.", "The path cannot be found or accessed.");
|
||||
case "store":
|
||||
return "L’état du projet n’a pas pu être lu.";
|
||||
return tr("L’état du projet n’a pas pu être lu.", "The project state could not be read.");
|
||||
case "invalid":
|
||||
return "Les informations mémorisées pour ce projet sont invalides.";
|
||||
return tr("Les informations mémorisées pour ce projet sont invalides.", "The saved information for this project is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectsView() {
|
||||
const { t, tr } = useI18n();
|
||||
const panelTitle = (panel: PanelId) => t(`panel.${panel}` as TranslationKey);
|
||||
const vm = useProjects();
|
||||
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
|
||||
const {
|
||||
@ -465,11 +481,11 @@ export function ProjectsView() {
|
||||
taskId: event.taskId,
|
||||
state: event.state,
|
||||
title: labels
|
||||
? `Appel ${labels.requester} -> ${labels.target} ${rendezvousToastStateLabel(event.state)}`
|
||||
: `Background task ${event.state}`,
|
||||
projectName: projectToastName(projectNameById, event.projectId),
|
||||
? tr(`Appel ${labels.requester} → ${labels.target} ${rendezvousToastStateLabel(event.state, tr)}`, `Call ${labels.requester} → ${labels.target} ${rendezvousToastStateLabel(event.state, tr)}`)
|
||||
: tr(`Tâche de fond ${backgroundToastStateLabel(event.state, tr)}`, `Background task ${backgroundToastStateLabel(event.state, tr)}`),
|
||||
projectName: projectToastName(projectNameById, event.projectId, tr),
|
||||
subtitle: labels
|
||||
? `Task ${shortTaskId(event.taskId)}`
|
||||
? `${tr("Tâche", "Task")} ${shortTaskId(event.taskId)}`
|
||||
: `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`,
|
||||
...(event.conversationId
|
||||
? { conversationId: event.conversationId }
|
||||
@ -683,25 +699,25 @@ export function ProjectsView() {
|
||||
const items: MenuBarItem[] = [
|
||||
{
|
||||
id: "closed",
|
||||
label: "Fermé",
|
||||
label: t("placement.closed"),
|
||||
active: placement === "closed",
|
||||
onSelect: () => closePanel(panel),
|
||||
},
|
||||
{
|
||||
id: "floating",
|
||||
label: "Flottant",
|
||||
label: t("placement.floating"),
|
||||
active: placement === "floating",
|
||||
onSelect: () => setPlacement(panel, "floating"),
|
||||
},
|
||||
{
|
||||
id: "dock-left",
|
||||
label: "Ancré à gauche",
|
||||
label: t("placement.left"),
|
||||
active: isDockedTo(placement, "left"),
|
||||
onSelect: () => setPlacement(panel, { dock: "left" }),
|
||||
},
|
||||
{
|
||||
id: "dock-right",
|
||||
label: "Ancré à droite",
|
||||
label: t("placement.right"),
|
||||
active: isDockedTo(placement, "right"),
|
||||
onSelect: () => setPlacement(panel, { dock: "right" }),
|
||||
},
|
||||
@ -709,7 +725,7 @@ export function ProjectsView() {
|
||||
if (panel !== "projects") {
|
||||
items.push({
|
||||
id: "detached",
|
||||
label: "Fenêtre détachée",
|
||||
label: t("placement.detached"),
|
||||
active: isDetached(placement),
|
||||
disabled: !active,
|
||||
onSelect: () => detachPanel(panel),
|
||||
@ -720,11 +736,11 @@ export function ProjectsView() {
|
||||
|
||||
const nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({
|
||||
id: panel,
|
||||
label: PANEL_TITLE[panel],
|
||||
label: panelTitle(panel),
|
||||
disabled: panel === "projectSettings" && !active,
|
||||
title:
|
||||
panel === "projectSettings" && !active
|
||||
? "Ouvrez ou sélectionnez un projet pour accéder à ses paramètres."
|
||||
? tr("Ouvrez ou sélectionnez un projet pour accéder à ses paramètres.", "Open or select a project to access its settings.")
|
||||
: undefined,
|
||||
active: placementOf(placements, panel) !== "closed",
|
||||
onSelect: () => {},
|
||||
@ -732,7 +748,7 @@ export function ProjectsView() {
|
||||
}));
|
||||
const nativeSettingsItems: MenuBarItem[] = SETTINGS_SECTIONS.map((section) => ({
|
||||
id: section,
|
||||
label: SETTINGS_SECTION_LABEL[section],
|
||||
label: t(`settings.${section}` as TranslationKey),
|
||||
active: settingsSection === section,
|
||||
onSelect: () => {
|
||||
setSettingsSection(section);
|
||||
@ -744,19 +760,19 @@ export function ProjectsView() {
|
||||
const nativeMenus: MenuBarMenu[] = [
|
||||
{
|
||||
id: "panels",
|
||||
label: "Panneaux",
|
||||
label: t("menu.panels"),
|
||||
items: nativePanelItems,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Paramètres",
|
||||
label: t("menu.settings"),
|
||||
items: nativeSettingsItems,
|
||||
},
|
||||
];
|
||||
const menus: MenuBarMenu[] = [
|
||||
{
|
||||
id: "panels",
|
||||
label: "Panneaux",
|
||||
label: t("menu.panels"),
|
||||
// Native items first, plugin-contributed items after in their own
|
||||
// (discreetly-labelled) group (#43, F3, carnet §7.2).
|
||||
items: [...nativePanelItems, ...pluginPanelItems],
|
||||
@ -766,7 +782,7 @@ export function ProjectsView() {
|
||||
...pluginMenus.topLevelMenus,
|
||||
{
|
||||
id: "settings",
|
||||
label: "Paramètres",
|
||||
label: t("menu.settings"),
|
||||
// One entry per section (#68). The entries name sections and mark the open
|
||||
// one; closing lives in the view ("Fermer les paramètres"), so no label
|
||||
// alternates. Plugin-contributed items are appended after (#43, F3).
|
||||
@ -785,29 +801,29 @@ export function ProjectsView() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={submit} className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
New project
|
||||
{t("project.new")}
|
||||
</h3>
|
||||
<Input
|
||||
aria-label="project name"
|
||||
placeholder="Project name"
|
||||
aria-label={t("project.name")}
|
||||
placeholder={t("project.name")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label="project root"
|
||||
placeholder="/absolute/project/root"
|
||||
aria-label={t("project.root")}
|
||||
placeholder={tr("/chemin/absolu/du/projet", "/absolute/project/root")}
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="browse project folder"
|
||||
aria-label={t("project.browse")}
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Browse…
|
||||
{t("project.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@ -817,21 +833,21 @@ export function ProjectsView() {
|
||||
disabled={!canCreate}
|
||||
className="flex-1"
|
||||
>
|
||||
Create project
|
||||
{t("project.create")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void vm.refresh()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
{t("project.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Panel title="Known projects">
|
||||
<Panel title={t("project.known")}>
|
||||
{vm.projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">No projects yet.</p>
|
||||
<p className="text-sm text-muted">{t("project.empty")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.projects.map((p) => (
|
||||
@ -845,7 +861,7 @@ export function ProjectsView() {
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
|
||||
Open
|
||||
{t("project.open")}
|
||||
</Button>
|
||||
<ProjectActionsMenu
|
||||
projectName={p.name}
|
||||
@ -872,7 +888,7 @@ export function ProjectsView() {
|
||||
if (panel === "projects") return projectsManager;
|
||||
if (!active) {
|
||||
return (
|
||||
<p className="text-sm text-muted">Open a project to use this panel.</p>
|
||||
<p className="text-sm text-muted">{tr("Ouvrez un projet pour utiliser ce panneau.", "Open a project to use this panel.")}</p>
|
||||
);
|
||||
}
|
||||
const body = (
|
||||
@ -911,15 +927,15 @@ export function ProjectsView() {
|
||||
// text. The detach control is hidden for the main-window-only `projects` view.
|
||||
function DockControls({ panel }: { panel: PanelId }) {
|
||||
const placement = placementOf(placements, panel);
|
||||
const title = PANEL_TITLE[panel];
|
||||
const title = panelTitle(panel);
|
||||
// Active (current-placement) styling — visually distinct, still clickable.
|
||||
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Ancré à gauche"
|
||||
aria-label={`Ancré à gauche — ${title}`}
|
||||
title={tr("Ancré à gauche", "Docked left")}
|
||||
aria-label={`${tr("Ancré à gauche", "Docked left")} — ${title}`}
|
||||
aria-pressed={isDockedTo(placement, "left")}
|
||||
className={cn(isDockedTo(placement, "left") && activeClass)}
|
||||
onClick={() => setPlacement(panel, { dock: "left" })}
|
||||
@ -928,8 +944,8 @@ export function ProjectsView() {
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Ancré à droite"
|
||||
aria-label={`Ancré à droite — ${title}`}
|
||||
title={tr("Ancré à droite", "Docked right")}
|
||||
aria-label={`${tr("Ancré à droite", "Docked right")} — ${title}`}
|
||||
aria-pressed={isDockedTo(placement, "right")}
|
||||
className={cn(isDockedTo(placement, "right") && activeClass)}
|
||||
onClick={() => setPlacement(panel, { dock: "right" })}
|
||||
@ -938,8 +954,8 @@ export function ProjectsView() {
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Flottant"
|
||||
aria-label={`Flottant — ${title}`}
|
||||
title={tr("Flottant", "Floating")}
|
||||
aria-label={`${tr("Flottant", "Floating")} — ${title}`}
|
||||
aria-pressed={placement === "floating"}
|
||||
className={cn(placement === "floating" && activeClass)}
|
||||
onClick={() => setPlacement(panel, "floating")}
|
||||
@ -949,8 +965,8 @@ export function ProjectsView() {
|
||||
{panel !== "projects" && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Fenêtre détachée"
|
||||
aria-label={`Fenêtre détachée — ${title}`}
|
||||
title={tr("Fenêtre détachée", "Detached window")}
|
||||
aria-label={`${tr("Fenêtre détachée", "Detached window")} — ${title}`}
|
||||
aria-pressed={isDetached(placement)}
|
||||
disabled={!active}
|
||||
className={cn(isDetached(placement) && activeClass)}
|
||||
@ -961,8 +977,8 @@ export function ProjectsView() {
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Fermé"
|
||||
aria-label={`Fermé — ${title}`}
|
||||
title={tr("Fermé", "Closed")}
|
||||
aria-label={`${tr("Fermé", "Closed")} — ${title}`}
|
||||
onClick={() => closePanel(panel)}
|
||||
>
|
||||
×
|
||||
@ -977,12 +993,12 @@ export function ProjectsView() {
|
||||
return (
|
||||
<section
|
||||
key={panel}
|
||||
aria-label={`${PANEL_TITLE[panel]} panel`}
|
||||
aria-label={`${panelTitle(panel)} — ${tr("panneau", "panel")}`}
|
||||
className="flex min-h-0 flex-1 flex-col border-b border-border last:border-b-0"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-sm font-medium text-content">
|
||||
{PANEL_TITLE[panel]}
|
||||
{panelTitle(panel)}
|
||||
</span>
|
||||
<DockControls panel={panel} />
|
||||
</header>
|
||||
@ -1010,11 +1026,10 @@ export function ProjectsView() {
|
||||
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
<span>
|
||||
Le panneau {PANEL_TITLE[detachErrorPanel]} ne peut pas être détaché
|
||||
pour le moment.
|
||||
{tr("Le panneau", "The panel")} {panelTitle(detachErrorPanel)} {tr("ne peut pas être détaché pour le moment.", "cannot be detached right now.")}
|
||||
</span>
|
||||
<Button size="sm" onClick={() => detachPanel(detachErrorPanel)}>
|
||||
Réessayer
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -1055,7 +1070,7 @@ export function ProjectsView() {
|
||||
side="left"
|
||||
width={leftDockWidth}
|
||||
onResize={setLeftDockWidth}
|
||||
aria-label="left dock"
|
||||
aria-label={tr("Zone ancrée à gauche", "Left dock")}
|
||||
>
|
||||
{leftPanels.map(renderDockedView)}
|
||||
</DockRegion>
|
||||
@ -1133,7 +1148,7 @@ export function ProjectsView() {
|
||||
side="right"
|
||||
width={rightDockWidth}
|
||||
onResize={setRightDockWidth}
|
||||
aria-label="right dock"
|
||||
aria-label={tr("Zone ancrée à droite", "Right dock")}
|
||||
>
|
||||
{rightPanels.map(renderDockedView)}
|
||||
</DockRegion>
|
||||
@ -1145,7 +1160,7 @@ export function ProjectsView() {
|
||||
<FloatingWindow
|
||||
key={panel}
|
||||
open
|
||||
title={PANEL_TITLE[panel]}
|
||||
title={panelTitle(panel)}
|
||||
size={PANEL_SIZE[panel]}
|
||||
onClose={() => closePanel(panel)}
|
||||
>
|
||||
@ -1158,11 +1173,11 @@ export function ProjectsView() {
|
||||
|
||||
<FloatingWindow
|
||||
open={restoreDetailsOpen && vm.restoreFailures.length > 0}
|
||||
title="Projets non rouverts"
|
||||
title={tr("Projets non rouverts", "Projects not reopened")}
|
||||
size="md"
|
||||
onClose={() => setRestoreDetailsOpen(false)}
|
||||
>
|
||||
<ul className="flex flex-col gap-3" aria-label="restore failures">
|
||||
<ul className="flex flex-col gap-3" aria-label={tr("Échecs de restauration", "Restore failures")}>
|
||||
{vm.restoreFailures.map((failure) => (
|
||||
<li
|
||||
key={failure.projectId}
|
||||
@ -1170,7 +1185,7 @@ export function ProjectsView() {
|
||||
>
|
||||
<div className="text-sm font-medium text-content">{failure.name}</div>
|
||||
<div className="mt-1 break-all text-xs text-muted">{failure.root}</div>
|
||||
<p className="mt-2 text-sm text-muted">{restoreFailureCause(failure)}</p>
|
||||
<p className="mt-2 text-sm text-muted">{restoreFailureCause(failure, tr)}</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -1178,7 +1193,7 @@ export function ProjectsView() {
|
||||
loading={vm.busy}
|
||||
onClick={() => void vm.removeFromRestore(failure.projectId)}
|
||||
>
|
||||
Retirer de la reprise
|
||||
{tr("Retirer de la reprise", "Remove from restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
@ -1213,13 +1228,13 @@ export function ProjectsView() {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-content">
|
||||
{vm.restoreFailures.length === 1
|
||||
? "1 projet n’a pas pu être rouvert"
|
||||
: `${vm.restoreFailures.length} projets n’ont pas pu être rouverts`}
|
||||
? tr("1 projet n’a pas pu être rouvert", "1 project could not be reopened")
|
||||
: tr(`${vm.restoreFailures.length} projets n’ont pas pu être rouverts`, `${vm.restoreFailures.length} projects could not be reopened`)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Fermer la notification"
|
||||
aria-label={tr("Fermer la notification", "Close notification")}
|
||||
onClick={vm.dismissRestoreNotification}
|
||||
>
|
||||
×
|
||||
@ -1227,7 +1242,7 @@ export function ProjectsView() {
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setRestoreDetailsOpen(true)}>
|
||||
Voir les détails
|
||||
{tr("Voir les détails", "View details")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -1235,7 +1250,7 @@ export function ProjectsView() {
|
||||
loading={vm.busy}
|
||||
onClick={() => void vm.retryRestore()}
|
||||
>
|
||||
Réessayer
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1247,13 +1262,12 @@ export function ProjectsView() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm text-content">
|
||||
« {removalNotice.name} » a été retiré d'IdeA. Les fichiers sont restés
|
||||
sur le disque.
|
||||
{tr(`« ${removalNotice.name} » a été retiré d’IdeA. Les fichiers sont restés sur le disque.`, `“${removalNotice.name}” was removed from IdeA. Its files remain on disk.`)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Fermer la notification de retrait"
|
||||
aria-label={tr("Fermer la notification de retrait", "Close removal notification")}
|
||||
onClick={() => setRemovalNotice(null)}
|
||||
>
|
||||
×
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
|
||||
import type { Project } from "@/domain";
|
||||
import { Button, zIndex } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface RemoveProjectDialogProps {
|
||||
project: Project;
|
||||
@ -20,6 +21,7 @@ export function RemoveProjectDialog({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RemoveProjectDialogProps) {
|
||||
const { tr } = useI18n();
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -45,22 +47,21 @@ export function RemoveProjectDialog({
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Retirer « ${project.name} » d'IdeA ?`}
|
||||
aria-label={tr(`Retirer « ${project.name} » d’IdeA ?`, `Remove “${project.name}” from IdeA?`)}
|
||||
className="flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-content">
|
||||
Retirer « {project.name} » d'IdeA ?
|
||||
{tr(`Retirer « ${project.name} » d’IdeA ?`, `Remove “${project.name}” from IdeA?`)}
|
||||
</h3>
|
||||
<code className="break-all rounded-md bg-raised px-3 py-2 text-xs text-muted">
|
||||
{project.root}
|
||||
</code>
|
||||
<p className="text-sm text-muted">
|
||||
Le dossier et les fichiers resteront sur le disque.
|
||||
{tr("Le dossier et les fichiers resteront sur le disque.", "The folder and files will remain on disk.")}
|
||||
</p>
|
||||
{isOpen && (
|
||||
<p className="text-sm text-muted">
|
||||
Les vues de ce projet seront fermées et les opérations IdeA en cours seront
|
||||
interrompues.
|
||||
{tr("Les vues de ce projet seront fermées et les opérations IdeA en cours seront interrompues.", "This project's views will close and its active IdeA operations will be interrupted.")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
@ -76,16 +77,16 @@ export function RemoveProjectDialog({
|
||||
disabled={pending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
loading={pending}
|
||||
aria-label={pending ? "Retrait…" : undefined}
|
||||
aria-label={pending ? tr("Retrait…", "Removing…") : undefined}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{pending ? "Retrait…" : "Retirer d'IdeA"}
|
||||
{pending ? tr("Retrait…", "Removing…") : tr("Retirer d’IdeA", "Remove from IdeA")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -128,16 +128,16 @@ function renderWithPlugin(git: GitGateway) {
|
||||
async function waitForIdle() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement).disabled,
|
||||
(screen.getByRole("button", { name: "Rafraîchir" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
}
|
||||
|
||||
async function createProject(name: string, root: string) {
|
||||
await waitForIdle();
|
||||
fireEvent.change(screen.getByLabelText("project name"), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText("project root"), { target: { value: root } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
|
||||
fireEvent.change(screen.getByLabelText("nom du projet"), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText("racine du projet"), { target: { value: root } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer le projet" }));
|
||||
}
|
||||
|
||||
function panelsMenuItem(): HTMLButtonElement {
|
||||
|
||||
107
frontend/src/features/projects/projectIcons.tsx
Normal file
107
frontend/src/features/projects/projectIcons.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ProjectIconRef, ProjectIconSet } from "@/ports";
|
||||
|
||||
export interface ProjectIconCatalogEntry extends ProjectIconRef {
|
||||
label: string;
|
||||
glyph: string;
|
||||
keywords: readonly string[];
|
||||
}
|
||||
|
||||
const icon = (set: ProjectIconSet, name: string, label: string, glyph: string, keywords: string[] = []): ProjectIconCatalogEntry => ({
|
||||
set, name, label, glyph, keywords: [label, name, ...keywords].map((word) => word.toLowerCase()),
|
||||
});
|
||||
|
||||
/** Product-owned, versioned catalogue. Search metadata deliberately stays English. */
|
||||
export const PROJECT_ICON_CATALOG: readonly ProjectIconCatalogEntry[] = [
|
||||
icon("devicon", "c", "C", "C", ["language"]),
|
||||
icon("devicon", "cplusplus", "C++", "C++", ["cpp language"]),
|
||||
icon("devicon", "csharp", "C#", "C#", ["dotnet language"]),
|
||||
icon("devicon", "java", "Java", "J", ["language jvm"]),
|
||||
icon("devicon", "javascript", "JavaScript", "JS", ["language js"]),
|
||||
icon("devicon", "typescript", "TypeScript", "TS", ["language ts"]),
|
||||
icon("devicon", "python", "Python", "Py", ["language"]),
|
||||
icon("devicon", "rust", "Rust", "Rs", ["language cargo"]),
|
||||
icon("devicon", "go", "Go", "Go", ["language golang"]),
|
||||
icon("devicon", "kotlin", "Kotlin", "Kt", ["language jvm"]),
|
||||
icon("devicon", "swift", "Swift", "Sw", ["language apple ios"]),
|
||||
icon("devicon", "php", "PHP", "PHP", ["language"]),
|
||||
icon("devicon", "ruby", "Ruby", "Rb", ["language rails"]),
|
||||
icon("devicon", "android", "Android", "A", ["platform mobile"]),
|
||||
icon("devicon", "windows", "Windows", "⊞", ["platform microsoft"]),
|
||||
icon("devicon", "apple", "macOS / Apple", "●", ["platform mac ios"]),
|
||||
icon("devicon", "linux", "Linux", "L", ["platform tux"]),
|
||||
icon("devicon", "ubuntu", "Ubuntu", "U", ["linux distribution distro"]),
|
||||
icon("devicon", "debian", "Debian", "D", ["linux distribution distro"]),
|
||||
icon("devicon", "fedora", "Fedora", "F", ["linux distribution distro"]),
|
||||
icon("devicon", "archlinux", "Arch Linux", "A", ["linux distribution distro"]),
|
||||
icon("devicon", "redhat", "Red Hat", "RH", ["linux distribution distro"]),
|
||||
icon("simple-icons", "github", "GitHub", "GH", ["git tool repository"]),
|
||||
icon("simple-icons", "gitlab", "GitLab", "GL", ["git tool repository"]),
|
||||
icon("simple-icons", "docker", "Docker", "D", ["container tool"]),
|
||||
icon("simple-icons", "kubernetes", "Kubernetes", "K8s", ["container cloud tool"]),
|
||||
icon("simple-icons", "react", "React", "⚛", ["frontend framework"]),
|
||||
icon("simple-icons", "vuedotjs", "Vue", "V", ["frontend framework"]),
|
||||
icon("simple-icons", "angular", "Angular", "A", ["frontend framework"]),
|
||||
icon("simple-icons", "nodedotjs", "Node.js", "N", ["javascript runtime"]),
|
||||
icon("simple-icons", "postgresql", "PostgreSQL", "PG", ["database sql"]),
|
||||
icon("simple-icons", "redis", "Redis", "R", ["database cache"]),
|
||||
icon("simple-icons", "amazonwebservices", "AWS", "AWS", ["cloud amazon"]),
|
||||
icon("simple-icons", "googlecloud", "Google Cloud", "GC", ["cloud gcp"]),
|
||||
icon("simple-icons", "figma", "Figma", "Fi", ["design tool"]),
|
||||
icon("lucide", "star", "Star", "★", ["favorite symbol"]),
|
||||
icon("lucide", "sparkles", "Sparkles", "✦", ["magic symbol"]),
|
||||
icon("lucide", "arrow-up-right", "Arrow up right", "↗", ["direction symbol"]),
|
||||
icon("lucide", "arrow-right", "Arrow right", "→", ["direction symbol"]),
|
||||
icon("lucide", "rocket", "Rocket", "▲", ["launch space symbol"]),
|
||||
icon("lucide", "code-2", "Code", "</>", ["development brackets symbol"]),
|
||||
icon("lucide", "terminal", "Terminal", ">_", ["cli shell tool"]),
|
||||
icon("lucide", "database", "Database", "DB", ["storage server"]),
|
||||
icon("lucide", "globe-2", "Globe", "◎", ["web world symbol"]),
|
||||
icon("lucide", "gamepad-2", "Game", "◆", ["gaming symbol"]),
|
||||
icon("idea", "idea-mark", "IdeA", "IA", ["agent ai"]),
|
||||
];
|
||||
|
||||
export function findProjectIcon(ref: ProjectIconRef | null | undefined): ProjectIconCatalogEntry | null {
|
||||
return ref ? PROJECT_ICON_CATALOG.find((entry) => entry.set === ref.set && entry.name === ref.name) ?? null : null;
|
||||
}
|
||||
|
||||
export function ProjectIcon({ icon: ref, size = 32, className, style, decorative = false }: {
|
||||
icon: ProjectIconRef;
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
const entry = findProjectIcon(ref);
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role={decorative ? undefined : "img"}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : entry.label}
|
||||
title={decorative ? undefined : entry.label}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
width: size, height: size, fontSize: Math.max(11, size * 0.38), fontWeight: 800,
|
||||
lineHeight: 1, letterSpacing: "-0.04em", ...style,
|
||||
}}
|
||||
>{entry.glyph}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const PROJECT_ICON_CHANGED_EVENT = "idea:project-icon-changed";
|
||||
export interface ProjectIconChanged { projectId: string; icon: ProjectIconRef | null }
|
||||
|
||||
export function publishProjectIconChanged(change: ProjectIconChanged): void {
|
||||
window.dispatchEvent(new CustomEvent(PROJECT_ICON_CHANGED_EVENT, { detail: change }));
|
||||
}
|
||||
|
||||
export function onProjectIconChanged(handler: (change: ProjectIconChanged) => void): () => void {
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ProjectIconChanged>).detail;
|
||||
if (detail?.projectId) handler(detail);
|
||||
};
|
||||
window.addEventListener(PROJECT_ICON_CHANGED_EVENT, listener);
|
||||
return () => window.removeEventListener(PROJECT_ICON_CHANGED_EVENT, listener);
|
||||
}
|
||||
@ -54,7 +54,7 @@ async function waitForIdle() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement
|
||||
screen.getByRole("button", { name: "Rafraîchir" }) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
@ -63,13 +63,13 @@ async function waitForIdle() {
|
||||
/** Fills the create form and submits it (assumes the form is idle/enabled). */
|
||||
async function createProject(name: string, root: string) {
|
||||
await waitForIdle();
|
||||
fireEvent.change(screen.getByLabelText("project name"), {
|
||||
fireEvent.change(screen.getByLabelText("nom du projet"), {
|
||||
target: { value: name },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("project root"), {
|
||||
fireEvent.change(screen.getByLabelText("racine du projet"), {
|
||||
target: { value: root },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer le projet" }));
|
||||
}
|
||||
|
||||
function tablist() {
|
||||
@ -84,7 +84,7 @@ function tablist() {
|
||||
*/
|
||||
function openPanel(panelTitle: string) {
|
||||
if (panelTitle === "Projects") {
|
||||
fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "ouvrir le panneau des projets" }));
|
||||
return;
|
||||
}
|
||||
if (panelTitle === "Project context") panelTitle = "Paramètres du projet";
|
||||
@ -105,9 +105,9 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
await waitForIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
const menu = screen.getByRole("menu", { name: "Panneaux" });
|
||||
expect(within(menu).queryByRole("button", { name: "Projects" })).toBeNull();
|
||||
expect(within(menu).queryByRole("button", { name: "Project context" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "open projects panel" })).toBeTruthy();
|
||||
expect(within(menu).queryByRole("button", { name: "Projets" })).toBeNull();
|
||||
expect(within(menu).queryByRole("button", { name: "Contexte du projet" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "ouvrir le panneau des projets" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies each saved project colour again when switching tabs", async () => {
|
||||
@ -120,7 +120,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Ouvrir" })[0]);
|
||||
const scope = await screen.findByTestId("active-project-theme-scope");
|
||||
await waitFor(() =>
|
||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"),
|
||||
@ -130,7 +130,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
const betaRow = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((item) => within(item).queryByText("beta"))!;
|
||||
fireEvent.click(within(betaRow).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(betaRow).getByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"),
|
||||
);
|
||||
@ -138,7 +138,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
const gammaRow = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((item) => within(item).queryByText("gamma"))!;
|
||||
fireEvent.click(within(gammaRow).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(gammaRow).getByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe(""),
|
||||
);
|
||||
@ -188,7 +188,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
expect(within(dialog).getByText("/missing/b")).toBeTruthy();
|
||||
expect(within(dialog).getByText("Le chemin est introuvable ou inaccessible.")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "close Projets non rouverts" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "fermer Projets non rouverts" }));
|
||||
project.setOpenProjectRestoreFailure(beta.id, null);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
|
||||
|
||||
@ -253,9 +253,9 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fermer alpha" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("No open tabs.")).toBeTruthy());
|
||||
await waitFor(() => expect(screen.getByText("Aucun onglet ouvert.")).toBeTruthy());
|
||||
expect((await project.readOpenProjectRestoreState()).entries).toEqual([]);
|
||||
});
|
||||
|
||||
@ -269,7 +269,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fermer alpha" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"could not persist close",
|
||||
@ -285,23 +285,23 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d’IdeA…" }));
|
||||
|
||||
const dialog = screen.getByRole("alertdialog", {
|
||||
name: "Retirer « alpha » d'IdeA ?",
|
||||
name: "Retirer « alpha » d’IdeA ?",
|
||||
});
|
||||
expect(within(dialog).getByText("/p/a")).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText("Le dossier et les fichiers resteront sur le disque."),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retirer d’IdeA" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("/p/a")).toBeNull());
|
||||
expect(screen.getByText("/p/b")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"« alpha » a été retiré d'IdeA. Les fichiers sont restés sur le disque.",
|
||||
"« alpha » a été retiré d’IdeA. Les fichiers sont restés sur le disque.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
await expect(project.listProjects()).resolves.toEqual([
|
||||
@ -317,24 +317,24 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Ouvrir" })[0]);
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
openPanel("Projects");
|
||||
const betaRow = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((item) => within(item).queryByText("beta"))!;
|
||||
fireEvent.click(within(betaRow).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(betaRow).getByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() => expect(screen.getByRole("tab", { name: "beta" })).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour beta" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d’IdeA…" }));
|
||||
const dialog = screen.getByRole("alertdialog", {
|
||||
name: "Retirer « beta » d'IdeA ?",
|
||||
name: "Retirer « beta » d’IdeA ?",
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByText(/Les vues de ce projet seront fermées/),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retirer d’IdeA" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("tab", { name: "beta" })).toBeNull());
|
||||
expect(screen.getByRole("tab", { name: "alpha" }).getAttribute("aria-selected")).toBe(
|
||||
@ -352,11 +352,11 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d’IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d’IdeA" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog", {
|
||||
name: "Retirer « alpha » d'IdeA ?",
|
||||
name: "Retirer « alpha » d’IdeA ?",
|
||||
});
|
||||
expect(within(dialog).getByRole("alert").textContent).toBe(
|
||||
"Impossible de retirer ce projet d'IdeA. Le projet est toujours enregistré. Réessayez.",
|
||||
@ -380,8 +380,8 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d’IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d’IdeA" }));
|
||||
|
||||
const pending = screen.getByRole("button", { name: "Retrait…" });
|
||||
expect((pending as HTMLButtonElement).disabled).toBe(true);
|
||||
@ -405,19 +405,19 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d’IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d’IdeA" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(screen.getByText("No projects yet.")).toBeTruthy();
|
||||
expect(screen.getByText(/« alpha » a été retiré d'IdeA/)).toBeTruthy();
|
||||
expect(screen.getByText("Aucun projet.")).toBeTruthy();
|
||||
expect(screen.getByText(/« alpha » a été retiré d.IdeA/)).toBeTruthy();
|
||||
await expect(project.listProjects()).resolves.not.toContainEqual(alpha);
|
||||
});
|
||||
|
||||
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText("No projects yet.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun projet.")).toBeTruthy();
|
||||
|
||||
await createProject("alpha", "/home/me/alpha");
|
||||
|
||||
@ -451,7 +451,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
const openInList = (name: string) =>
|
||||
within(
|
||||
screen.getAllByRole("listitem").find((li) => within(li).queryByText(name))!,
|
||||
).getByRole("button", { name: "Open" });
|
||||
).getByRole("button", { name: "Ouvrir" });
|
||||
|
||||
fireEvent.click(openInList("alpha"));
|
||||
await waitFor(() =>
|
||||
@ -481,12 +481,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
|
||||
openPanel("Work state");
|
||||
openPanel("Travail");
|
||||
|
||||
expect(await screen.findByText("No agent work state.")).toBeTruthy();
|
||||
expect(await screen.findByText("Aucun état de travail d'agent.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches the active project via the tab-bar + button → Projects panel (#26)", async () => {
|
||||
@ -500,16 +500,16 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
const alphaLi = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((n) => within(n).queryByText("alpha"))!;
|
||||
fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(alphaLi).getByRole("button", { name: "Ouvrir" }));
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
|
||||
// The "+" button opens the Projects panel (floating); pick beta from its
|
||||
// known-projects list — no verbose "Projet : …" selector anymore.
|
||||
fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "ouvrir le panneau des projets" }));
|
||||
const betaLi = await waitFor(() =>
|
||||
screen.getAllByRole("listitem").find((n) => within(n).queryByText("beta"))!,
|
||||
);
|
||||
fireEvent.click(within(betaLi).getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(within(betaLi).getByRole("button", { name: "Ouvrir" }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
|
||||
const betaTab = screen
|
||||
@ -523,21 +523,21 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
await waitForIdle();
|
||||
|
||||
// The project surface (create form) is visible; profiles settings are not.
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
|
||||
expect(screen.getByLabelText("nom du projet")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Paramètres des profils IA")).toBeNull();
|
||||
|
||||
// Settings → AI Profiles swaps the main area to the profiles settings.
|
||||
openMenuItem("Paramètres", "Profils IA");
|
||||
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||
expect(await screen.findByLabelText("Paramètres des profils IA")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("nom du projet")).toBeNull();
|
||||
|
||||
// #68: closing is an explicit action in the view, not an alternating menu
|
||||
// label — the label never scaled past one section.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fermer les paramètres" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("nom du projet")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
|
||||
expect(screen.queryByLabelText("Paramètres des profils IA")).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates between Settings sections from the menu and the nav column (#68)", async () => {
|
||||
@ -546,14 +546,14 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
|
||||
// The Settings menu now names sections; Deployment opens directly.
|
||||
openMenuItem("Paramètres", "Déploiement");
|
||||
expect(await screen.findByLabelText("deployment settings")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
|
||||
expect(await screen.findByLabelText("paramètres de déploiement")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Paramètres des profils IA")).toBeNull();
|
||||
|
||||
// The internal nav column switches sections without leaving Settings.
|
||||
const nav = screen.getByRole("navigation", { name: "settings sections" });
|
||||
const nav = screen.getByRole("navigation", { name: "sections des paramètres" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: "Profils IA" }));
|
||||
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("deployment settings")).toBeNull();
|
||||
expect(await screen.findByLabelText("Paramètres des profils IA")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("paramètres de déploiement")).toBeNull();
|
||||
});
|
||||
|
||||
it("closing a tab removes it from the tab bar", async () => {
|
||||
@ -562,10 +562,10 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
|
||||
await screen.findByRole("tab");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fermer alpha" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No open tabs.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun onglet ouvert.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -593,7 +593,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
// Open alpha so the panels become active. With a project open the main area
|
||||
// is the terminal grid — panels now live behind the menu bar.
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
await waitFor(() =>
|
||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
@ -601,40 +601,40 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
// File → Projects… opens the projects manager window — form inputs visible.
|
||||
openPanel("Projects");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("nom du projet")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// View → Agents — agent form visible; project form window is replaced.
|
||||
openPanel("Agents");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("agent name")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("Nom de l’agent")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||
expect(screen.queryByLabelText("nom du projet")).toBeNull();
|
||||
|
||||
// View → Context — shared project context editor visible.
|
||||
openPanel("Project context");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("project context")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("contexte du projet")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("agent name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Nom de l’agent")).toBeNull();
|
||||
|
||||
// View → Templates — the "New template" button visible.
|
||||
openPanel("Templates");
|
||||
openPanel("Modèles");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
|
||||
expect(screen.getByRole("button", { name: "Créer un modèle" })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("agent name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Nom de l’agent")).toBeNull();
|
||||
|
||||
// View → Git — commit message input visible.
|
||||
openPanel("Git");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("commit message")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("message de commit")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// File → Projects… again — form accessible once more.
|
||||
openPanel("Projects");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||
expect(screen.getByLabelText("nom du projet")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
@ -645,17 +645,17 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
openPanel("Project context");
|
||||
|
||||
const editor = (await screen.findByLabelText(
|
||||
"project context",
|
||||
"contexte du projet",
|
||||
)) as HTMLTextAreaElement;
|
||||
expect(editor.value).toBe("# Existing context");
|
||||
|
||||
fireEvent.change(editor, { target: { value: "# Shared\n\nUse pnpm." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
await expect(project.readProjectContext(created.id)).resolves.toBe(
|
||||
@ -669,15 +669,15 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
await project.createProject("alpha", "/p/a");
|
||||
renderView(project);
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
|
||||
openPanel("Paramètres du projet");
|
||||
|
||||
expect(await screen.findByText("Apparence")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Couleur du projet")).toBeTruthy();
|
||||
expect(screen.getByText("Contexte du projet")).toBeTruthy();
|
||||
expect(screen.getByLabelText("project context")).toBeTruthy();
|
||||
expect(screen.getAllByText("Contexte du projet").length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("contexte du projet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables project settings while no project is active", async () => {
|
||||
@ -697,15 +697,15 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
await waitForIdle();
|
||||
|
||||
// Root field starts empty.
|
||||
const rootInput = screen.getByLabelText("project root") as HTMLInputElement;
|
||||
const rootInput = screen.getByLabelText("racine du projet") as HTMLInputElement;
|
||||
expect(rootInput.value).toBe("");
|
||||
|
||||
// Click Browse… — the mock returns "/home/user/mock-project".
|
||||
fireEvent.click(screen.getByRole("button", { name: "browse project folder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "parcourir le dossier du projet" }));
|
||||
|
||||
// Wait for the async pickFolder to fill the field.
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("project root") as HTMLInputElement).value).toBe(
|
||||
expect((screen.getByLabelText("racine du projet") as HTMLInputElement).value).toBe(
|
||||
"/home/user/mock-project",
|
||||
);
|
||||
});
|
||||
@ -726,10 +726,10 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/ghost");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/not found/i);
|
||||
expect(screen.getByText("No open tabs.")).toBeTruthy();
|
||||
expect(screen.getByText("Aucun onglet ouvert.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,12 +7,13 @@ import type {
|
||||
} from "@/domain";
|
||||
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
||||
import { Button, Field, Input } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
type Preset = "hourly" | "daily" | "weekly" | "custom";
|
||||
|
||||
const DAYS: Array<[ScheduledTaskWeekday, string]> = [
|
||||
["monday", "Lun"], ["tuesday", "Mar"], ["wednesday", "Mer"],
|
||||
["thursday", "Jeu"], ["friday", "Ven"], ["saturday", "Sam"], ["sunday", "Dim"],
|
||||
const DAYS: Array<[ScheduledTaskWeekday, string, string]> = [
|
||||
["monday", "Lun", "Mon"], ["tuesday", "Mar", "Tue"], ["wednesday", "Mer", "Wed"],
|
||||
["thursday", "Jeu", "Thu"], ["friday", "Ven", "Fri"], ["saturday", "Sam", "Sat"], ["sunday", "Dim", "Sun"],
|
||||
];
|
||||
|
||||
const control =
|
||||
@ -35,15 +36,19 @@ export function exactCommand(input: ScheduledTaskDefinitionInput): string {
|
||||
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
|
||||
}
|
||||
|
||||
export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
|
||||
type Tr = (french: string, english: string) => string;
|
||||
const fr: Tr = (french) => french;
|
||||
|
||||
export function scheduleLabel(schedule: ScheduledTaskSchedule, tr: Tr = fr): string {
|
||||
if (schedule.kind === "interval") {
|
||||
return schedule.everyMinutes === 60
|
||||
? "Toutes les heures"
|
||||
: `Toutes les ${schedule.everyMinutes} minutes`;
|
||||
? tr("Toutes les heures", "Hourly")
|
||||
: tr(`Toutes les ${schedule.everyMinutes} minutes`, `Every ${schedule.everyMinutes} minutes`);
|
||||
}
|
||||
if (schedule.kind === "daily") return `Chaque jour à ${schedule.time}`;
|
||||
const labels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
|
||||
return `Chaque semaine (${labels.join(", ")}) à ${schedule.time}`;
|
||||
if (schedule.kind === "daily") return tr(`Chaque jour à ${schedule.time}`, `Every day at ${schedule.time}`);
|
||||
const frLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
|
||||
const enLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[2] ?? day);
|
||||
return tr(`Chaque semaine (${frLabels.join(", ")}) à ${schedule.time}`, `Every week (${enLabels.join(", ")}) at ${schedule.time}`);
|
||||
}
|
||||
|
||||
export function ScheduledTaskForm({
|
||||
@ -57,6 +62,7 @@ export function ScheduledTaskForm({
|
||||
onCancel: () => void;
|
||||
onSubmit: (input: ScheduledTaskDefinitionInput) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [name, setName] = useState(task?.name ?? "");
|
||||
const [description, setDescription] = useState(task?.description ?? "");
|
||||
const [command, setCommand] = useState(task?.command.command ?? "");
|
||||
@ -116,22 +122,22 @@ export function ScheduledTaskForm({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
|
||||
Cette tâche pourra modifier ou supprimer des fichiers automatiquement.
|
||||
{tr("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.", "This task will be able to edit or delete files automatically.")}
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[9rem_1fr]">
|
||||
<dt className="text-muted">Commande exacte</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
|
||||
<dt className="text-muted">Dossier</dt><dd><code>{input.command.cwd ?? "."}</code> (relatif au projet)</dd>
|
||||
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(input.schedule)}</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
|
||||
<dt className="text-muted">Fuseau horaire</dt><dd>{input.timezone}</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; aucun accès en écriture hors projet</dd>
|
||||
<dt className="text-muted">{tr("Commande exacte", "Exact command")}</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
|
||||
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd><code>{input.command.cwd ?? "."}</code> ({tr("relatif au projet", "relative to project")})</dd>
|
||||
<dt className="text-muted">{tr("Fréquence", "Frequency")}</dt><dd>{scheduleLabel(input.schedule, tr)}</dd>
|
||||
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{tr("Calculée par IdeA lors de l’activation", "Calculated by IdeA when enabled")}</dd>
|
||||
<dt className="text-muted">{tr("Fuseau horaire", "Time zone")}</dt><dd>{input.timezone}</dd>
|
||||
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Lecture, modification et suppression dans le projet", "Read, edit, and delete within the project")}</dd>
|
||||
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture ; aucun accès en écriture hors projet", "Project — read/write; no write access outside the project")}</dd>
|
||||
</dl>
|
||||
<p className="text-xs text-muted">IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.</p>
|
||||
<p className="text-xs text-muted">{tr("IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.", "IdeA must be running and the project must be open to trigger this task.")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => setReview(false)}>Retour</Button>
|
||||
<Button onClick={() => setReview(false)}>{tr("Retour", "Back")}</Button>
|
||||
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
|
||||
{task?.approval === "pending" ? "Approuver et activer" : task ? "Enregistrer" : "Créer la tâche"}
|
||||
{task?.approval === "pending" ? tr("Approuver et activer", "Approve and enable") : task ? tr("Enregistrer", "Save") : tr("Créer la tâche", "Create task")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -141,45 +147,45 @@ export function ScheduledTaskForm({
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Nom">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label="Dossier de travail" hint="Chemin relatif au projet; « . » utilise sa racine.">
|
||||
<Field label={tr("Nom", "Name")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label={tr("Dossier de travail", "Working directory")} hint={tr("Chemin relatif au projet ; « . » utilise sa racine.", "Path relative to the project; “.” uses its root.")}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={cwd} onChange={(e) => setCwd(e.target.value)} />}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={task?.approval === "pending" ? "Motif de la demande" : "Description (facultative)"}>
|
||||
<Field label={task?.approval === "pending" ? tr("Motif de la demande", "Request reason") : tr("Description (facultative)", "Description (optional)")}>
|
||||
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={2} className={`${control} h-auto py-2`} value={description} onChange={(e) => setDescription(e.target.value)} />}
|
||||
</Field>
|
||||
<Field label="Commande" hint="Exécutable exact, sans interpolation shell.">
|
||||
<Field label={tr("Commande", "Command")} hint={tr("Exécutable exact, sans interpolation shell.", "Exact executable, without shell interpolation.")}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npm" />}
|
||||
</Field>
|
||||
<Field label="Arguments" hint="Un argument littéral par ligne.">
|
||||
<Field label={tr("Arguments", "Arguments")} hint={tr("Un argument littéral par ligne.", "One literal argument per line.")}>
|
||||
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={3} className={`${control} h-auto py-2 font-mono`} value={args} onChange={(e) => setArgs(e.target.value)} placeholder={'run\nbuild'} />}
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Planification">
|
||||
<Field label={tr("Planification", "Schedule")}>
|
||||
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
|
||||
<option value="hourly">Toutes les heures</option><option value="daily">Chaque jour</option>
|
||||
<option value="weekly">Chaque semaine</option><option value="custom">Personnalisée</option>
|
||||
<option value="hourly">{tr("Toutes les heures", "Hourly")}</option><option value="daily">{tr("Chaque jour", "Daily")}</option>
|
||||
<option value="weekly">{tr("Chaque semaine", "Weekly")}</option><option value="custom">{tr("Personnalisée", "Custom")}</option>
|
||||
</select>}
|
||||
</Field>
|
||||
{preset === "custom" ? (
|
||||
<Field label="Intervalle (minutes)" error={Number(minutes) < 5 ? "Minimum : 5 minutes." : undefined}>
|
||||
<Field label={tr("Intervalle (minutes)", "Interval (minutes)")} error={Number(minutes) < 5 ? tr("Minimum : 5 minutes.", "Minimum: 5 minutes.") : undefined}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={5} value={minutes} onChange={(e) => setMinutes(e.target.value)} />}
|
||||
</Field>
|
||||
) : preset === "daily" || preset === "weekly" ? (
|
||||
<Field label="Heure locale">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
|
||||
<Field label={tr("Heure locale", "Local time")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
|
||||
) : <div />}
|
||||
</div>
|
||||
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">Jours</legend><div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(([key, label]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{label}</label>)}
|
||||
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">{tr("Jours", "Days")}</legend><div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(([key, frLabel, enLabel]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{tr(frLabel, enLabel)}</label>)}
|
||||
</div></fieldset>}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Fuseau horaire" hint="Identifiant IANA, par exemple Europe/Paris.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
|
||||
<Field label="Limite de durée (minutes)" hint="Vide : aucune limite.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
|
||||
<Field label={tr("Fuseau horaire", "Time zone")} hint={tr("Identifiant IANA, par exemple Europe/Paris.", "IANA identifier, for example Europe/Paris.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
|
||||
<Field label={tr("Limite de durée (minutes)", "Time limit (minutes)")} hint={tr("Vide : aucune limite.", "Empty: no limit.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">Permissions et sandbox</div><p className="mt-1 text-muted">Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.</p></div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />Activée</label>
|
||||
<div className="flex justify-end gap-2"><Button onClick={onCancel}>Annuler</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? "Vérifier avant activation" : "Enregistrer"}</Button></div>
|
||||
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">{tr("Permissions et bac à sable", "Permissions and sandbox")}</div><p className="mt-1 text-muted">{tr("Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet ; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.", "Project — read/write. The command can edit or delete files in the project; writes outside the project are denied. Network is not isolated by this V1 profile.")}</p></div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />{tr("Activée", "Enabled")}</label>
|
||||
<div className="flex justify-end gap-2"><Button onClick={onCancel}>{tr("Annuler", "Cancel")}</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? tr("Vérifier avant activation", "Review before enabling") : tr("Enregistrer", "Save")}</Button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,40 +4,44 @@ import type { BackgroundCompletion, ScheduledProjectTask } from "@/domain";
|
||||
import { Button, FloatingWindow, Panel, Spinner, cn } from "@/shared";
|
||||
import { exactCommand, scheduleLabel, ScheduledTaskForm } from "./ScheduledTaskForm";
|
||||
import { useScheduledTasks } from "./useScheduledTasks";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { IdeaLanguage } from "@/ports";
|
||||
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
type Editor = { kind: "create" } | { kind: "edit"; task: ScheduledProjectTask };
|
||||
|
||||
function dateTime(value?: number | null): string {
|
||||
if (value == null) return "Indisponible";
|
||||
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(value);
|
||||
function dateTime(value: number | null | undefined, language: IdeaLanguage, tr: Tr): string {
|
||||
if (value == null) return tr("Indisponible", "Unavailable");
|
||||
return new Intl.DateTimeFormat(language === "fr" ? "fr-FR" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(value);
|
||||
}
|
||||
|
||||
function duration(run: BackgroundCompletion): string {
|
||||
function duration(run: BackgroundCompletion, tr: Tr): string {
|
||||
if (run.createdAtMs && run.updatedAtMs >= run.createdAtMs && !["running", "pending", "cancelling"].includes(run.status)) {
|
||||
const seconds = Math.round((run.updatedAtMs - run.createdAtMs) / 1000);
|
||||
return seconds < 60 ? `${seconds} s` : `${Math.round(seconds / 60)} min`;
|
||||
}
|
||||
return run.status === "running" || run.status === "pending" || run.status === "cancelling"
|
||||
? "En cours"
|
||||
: "Terminée";
|
||||
? tr("En cours", "Running")
|
||||
: tr("Terminée", "Completed");
|
||||
}
|
||||
|
||||
function displayStatus(task: ScheduledProjectTask, run?: BackgroundCompletion) {
|
||||
if (task.approval === "pending") return { label: "À approuver", tone: "warning" } as const;
|
||||
if (run && ["running", "pending", "cancelling"].includes(run.status)) return { label: "En cours", tone: "primary" } as const;
|
||||
function displayStatus(task: ScheduledProjectTask, tr: Tr, run?: BackgroundCompletion) {
|
||||
if (task.approval === "pending") return { label: tr("À approuver", "Approval required"), tone: "warning" } as const;
|
||||
if (run && ["running", "pending", "cancelling"].includes(run.status)) return { label: tr("En cours", "Running"), tone: "primary" } as const;
|
||||
if (run?.status === "failed" || run?.status === "cancelFailed") {
|
||||
const inaccessible = `${run.summary ?? ""} ${run.stderrTail ?? ""}`.toLowerCase().includes("inaccessible");
|
||||
return { label: inaccessible ? "Projet inaccessible" : "Échec", tone: "danger" } as const;
|
||||
return { label: inaccessible ? tr("Projet inaccessible", "Project unavailable") : tr("Échec", "Failed"), tone: "danger" } as const;
|
||||
}
|
||||
if (!task.enabled) return { label: "Suspendue", tone: "muted" } as const;
|
||||
return { label: "Active", tone: "success" } as const;
|
||||
if (!task.enabled) return { label: tr("Suspendue", "Paused"), tone: "muted" } as const;
|
||||
return { label: tr("Active", "Active"), tone: "success" } as const;
|
||||
}
|
||||
|
||||
function actorLabel(task: ScheduledProjectTask, names: ReadonlyMap<string, string>): string {
|
||||
function actorLabel(task: ScheduledProjectTask, names: ReadonlyMap<string, string>, tr: Tr): string {
|
||||
if (task.createdBy.kind === "agent") {
|
||||
return `Créée par ${names.get(task.createdBy.agentId) ?? task.createdBy.agentId}`;
|
||||
return `${tr("Créée par", "Created by")} ${names.get(task.createdBy.agentId) ?? task.createdBy.agentId}`;
|
||||
}
|
||||
return task.createdBy.kind === "human" ? "Créée par vous" : "Créée par IdeA";
|
||||
return task.createdBy.kind === "human" ? tr("Créée par vous", "Created by you") : tr("Créée par IdeA", "Created by IdeA");
|
||||
}
|
||||
|
||||
export function ScheduledTasksPanel({
|
||||
@ -48,6 +52,7 @@ export function ScheduledTasksPanel({
|
||||
onOpenWork?: () => void;
|
||||
}) {
|
||||
const vm = useScheduledTasks(projectId);
|
||||
const { language, tr } = useI18n();
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledProjectTask | null>(null);
|
||||
const [approveTarget, setApproveTarget] = useState<ScheduledProjectTask | null>(null);
|
||||
@ -63,24 +68,24 @@ export function ScheduledTasksPanel({
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-content">Tâches planifiées</h2>
|
||||
<p className="mt-1 text-xs text-muted">IdeA doit fonctionner et le projet rester ouvert pour déclencher les tâches. Les occurrences manquées ne sont pas rattrapées en rafale.</p>
|
||||
<h2 className="text-base font-semibold text-content">{tr("Tâches planifiées", "Scheduled tasks")}</h2>
|
||||
<p className="mt-1 text-xs text-muted">{tr("IdeA doit fonctionner et le projet rester ouvert pour déclencher les tâches. Les occurrences manquées ne sont pas rattrapées en rafale.", "IdeA must be running and the project must remain open to trigger tasks. Missed runs are not replayed in a burst.")}</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setEditor({ kind: "create" })}>Nouvelle tâche planifiée</Button>
|
||||
<Button variant="primary" onClick={() => setEditor({ kind: "create" })}>{tr("Nouvelle tâche planifiée", "New scheduled task")}</Button>
|
||||
</div>
|
||||
|
||||
{vm.error && <div role="alert" className="flex items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 p-3 text-sm text-danger"><span><strong>{vm.error.toLowerCase().includes("inaccessible") ? "Projet inaccessible" : "Erreur"}</strong> — Impossible de charger ou modifier les tâches planifiées. {vm.error}</span><Button size="sm" onClick={() => void vm.refresh()}>Réessayer</Button></div>}
|
||||
{vm.error && <div role="alert" className="flex items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 p-3 text-sm text-danger"><span><strong>{vm.error.toLowerCase().includes("inaccessible") ? tr("Projet inaccessible", "Project unavailable") : tr("Erreur", "Error")}</strong> — {tr("Impossible de charger ou modifier les tâches planifiées.", "Unable to load or update scheduled tasks.")} {vm.error}</span><Button size="sm" onClick={() => void vm.refresh()}>{tr("Réessayer", "Retry")}</Button></div>}
|
||||
{vm.tasks.some((task) => {
|
||||
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
|
||||
return run?.status === "failed" || run?.status === "cancelFailed";
|
||||
}) && <div role="status" className="rounded-md border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">Une tâche planifiée a échoué. Utilisez « Voir les détails » sur la tâche concernée.</div>}
|
||||
}) && <div role="status" className="rounded-md border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{tr("Une tâche planifiée a échoué. Utilisez « Voir les détails » sur la tâche concernée.", "A scheduled task failed. Use “View details” on that task.")}</div>}
|
||||
{vm.loading && vm.tasks.length === 0 ? <div className="flex justify-center p-8"><Spinner /></div> : vm.tasks.length === 0 ? (
|
||||
<Panel><p className="text-sm font-medium text-content">Aucune tâche planifiée pour ce projet</p><p className="mt-1 text-sm text-muted">Créez une tâche pour automatiser une commande récurrente sans utiliser le cron du système.</p></Panel>
|
||||
<Panel><p className="text-sm font-medium text-content">{tr("Aucune tâche planifiée pour ce projet", "No scheduled tasks for this project")}</p><p className="mt-1 text-sm text-muted">{tr("Créez une tâche pour automatiser une commande récurrente sans utiliser le cron du système.", "Create a task to automate a recurring command without using system cron.")}</p></Panel>
|
||||
) : (
|
||||
<ul className="space-y-3" aria-label="Tâches planifiées du projet">
|
||||
<ul className="space-y-3" aria-label={tr("Tâches planifiées du projet", "Project scheduled tasks")}>
|
||||
{vm.tasks.map((task) => {
|
||||
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
|
||||
const status = displayStatus(task, run);
|
||||
const status = displayStatus(task, tr, run);
|
||||
const requestingAgentId = task.createdBy.kind === "agent" ? task.createdBy.agentId : null;
|
||||
const pendingAgent = task.approval === "pending" && requestingAgentId !== null;
|
||||
const running = run && ["running", "pending", "cancelling"].includes(run.status);
|
||||
@ -88,57 +93,57 @@ export function ScheduledTasksPanel({
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="font-medium text-content">{task.name}</span><span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", status.tone === "danger" && "bg-danger/10 text-danger", status.tone === "warning" && "bg-warning/10 text-warning", status.tone === "success" && "bg-success/10 text-success", status.tone === "primary" && "bg-primary/10 text-primary", status.tone === "muted" && "bg-raised text-muted")}>{status.label}</span></div>
|
||||
<p className="mt-1 text-xs text-muted">{actorLabel(task, vm.agentNames)} · {scheduleLabel(task.schedule)} · {task.timezone}</p>
|
||||
<p className="mt-1 text-xs text-muted">{actorLabel(task, vm.agentNames, tr)} · {scheduleLabel(task.schedule)} · {task.timezone}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pendingAgent ? <><Button size="sm" variant="primary" onClick={() => setApproveTarget(task)}>Approuver et activer</Button><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier avant d’activer</Button><Button size="sm" variant="danger" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, false)}>Refuser</Button></> : <><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier</Button><Button size="sm" disabled={task.approval !== "approved" || Boolean(running)} loading={vm.busyId === task.id} onClick={() => void vm.runNow(task)}>Exécuter maintenant</Button><Button size="sm" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, !task.enabled)}>{task.enabled ? "Suspendre" : "Reprendre"}</Button><Button size="sm" variant="danger" onClick={() => setDeleteTarget(task)}>Supprimer</Button></>}
|
||||
{pendingAgent ? <><Button size="sm" variant="primary" onClick={() => setApproveTarget(task)}>{tr("Approuver et activer", "Approve and enable")}</Button><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>{tr("Modifier avant d’activer", "Edit before enabling")}</Button><Button size="sm" variant="danger" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, false)}>{tr("Refuser", "Reject")}</Button></> : <><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>{tr("Modifier", "Edit")}</Button><Button size="sm" disabled={task.approval !== "approved" || Boolean(running)} loading={vm.busyId === task.id} onClick={() => void vm.runNow(task)}>{tr("Exécuter maintenant", "Run now")}</Button><Button size="sm" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, !task.enabled)}>{task.enabled ? tr("Suspendre", "Pause") : tr("Reprendre", "Resume")}</Button><Button size="sm" variant="danger" onClick={() => setDeleteTarget(task)}>{tr("Supprimer", "Delete")}</Button></>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingAgent && <div className="mt-3 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm"><p className="font-medium">Demandée par {vm.agentNames.get(requestingAgentId) ?? requestingAgentId}</p><p className="mt-1 text-muted">{dateTime(task.createdAtMs)}{task.description ? ` · Motif : ${task.description}` : " · Aucun motif fourni"}</p><p className="mt-2 text-xs">Cette tâche ne s’exécutera pas avant votre approbation.</p></div>}
|
||||
{pendingAgent && <div className="mt-3 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm"><p className="font-medium">{tr("Demandée par", "Requested by")} {vm.agentNames.get(requestingAgentId) ?? requestingAgentId}</p><p className="mt-1 text-muted">{dateTime(task.createdAtMs, language, tr)}{task.description ? ` · ${tr("Motif", "Reason")} : ${task.description}` : ` · ${tr("Aucun motif fourni", "No reason provided")}`}</p><p className="mt-2 text-xs">{tr("Cette tâche ne s’exécutera pas avant votre approbation.", "This task will not run until you approve it.")}</p></div>}
|
||||
|
||||
<dl className="mt-3 grid gap-x-4 gap-y-1 text-xs sm:grid-cols-[8rem_1fr_8rem_1fr]">
|
||||
<dt className="text-muted">Commande</dt><dd className="break-all font-mono text-content">{exactCommand({ name: task.name, command: task.command, schedule: task.schedule, timezone: task.timezone, enabled: task.enabled })}</dd>
|
||||
<dt className="text-muted">Dossier</dt><dd className="font-mono">{task.command.cwd ?? "."} (projet)</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>{dateTime(task.nextRunAtMs)}</dd>
|
||||
<dt className="text-muted">Dernière exécution</dt><dd>{dateTime(task.lastRunAtMs)}</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Modifier/supprimer dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; réseau non isolé en V1</dd>
|
||||
<dt className="text-muted">Limite de durée</dt><dd>{task.timeoutMs ? `${Math.round(task.timeoutMs / 60_000)} min` : "Aucune"}</dd>
|
||||
<dt className="text-muted">Déclenchement</dt><dd>{run?.origin?.kind === "scheduled" ? "Planifié" : run ? "Manuel" : "Aucune exécution"}</dd>
|
||||
<dt className="text-muted">{tr("Commande", "Command")}</dt><dd className="break-all font-mono text-content">{exactCommand({ name: task.name, command: task.command, schedule: task.schedule, timezone: task.timezone, enabled: task.enabled })}</dd>
|
||||
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd className="font-mono">{task.command.cwd ?? "."} ({tr("projet", "project")})</dd>
|
||||
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{dateTime(task.nextRunAtMs, language, tr)}</dd>
|
||||
<dt className="text-muted">{tr("Dernière exécution", "Last run")}</dt><dd>{dateTime(task.lastRunAtMs, language, tr)}</dd>
|
||||
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Modifier/supprimer dans le projet", "Edit/delete within the project")}</dd>
|
||||
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture ; réseau non isolé en V1", "Project — read/write; network not isolated in V1")}</dd>
|
||||
<dt className="text-muted">{tr("Limite de durée", "Time limit")}</dt><dd>{task.timeoutMs ? `${Math.round(task.timeoutMs / 60_000)} min` : tr("Aucune", "None")}</dd>
|
||||
<dt className="text-muted">{tr("Déclenchement", "Trigger")}</dt><dd>{run?.origin?.kind === "scheduled" ? tr("Planifié", "Scheduled") : run ? tr("Manuel", "Manual") : tr("Aucune exécution", "No run")}</dd>
|
||||
</dl>
|
||||
{task.lastTaskId && <div className="mt-3 flex flex-wrap items-center gap-2"><Button size="sm" variant="ghost" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>{expanded === task.id ? "Masquer les détails" : "Voir les détails"}</Button>{onOpenWork && <Button size="sm" variant="ghost" onClick={onOpenWork}>Ouvrir la tâche de fond {task.lastTaskId.slice(0, 8)}</Button>}</div>}
|
||||
{expanded === task.id && <div className="mt-2 rounded-md bg-raised p-3 text-xs"><p><span className="text-muted">BackgroundTask :</span> {task.lastTaskId}</p>{run ? <><p className="mt-1"><span className="text-muted">Statut :</span> {run.status} · {duration(run)} · code de sortie {run.exitCode ?? "indisponible"}</p><p className="mt-1"><span className="text-muted">Permissions réellement utilisées :</span> sandbox projet — lecture/écriture</p>{run.summary && <p className="mt-2 whitespace-pre-wrap">{run.summary}</p>}{run.stdoutTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-canvas p-2">{run.stdoutTail}</pre>}{run.stderrTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-danger/5 p-2 text-danger">{run.stderrTail}</pre>}</> : <p className="mt-1 text-muted">Les détails de cette exécution ne sont plus présents dans le WorkState courant.</p>}</div>}
|
||||
{task.lastTaskId && <div className="mt-3 flex flex-wrap items-center gap-2"><Button size="sm" variant="ghost" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>{expanded === task.id ? tr("Masquer les détails", "Hide details") : tr("Voir les détails", "View details")}</Button>{onOpenWork && <Button size="sm" variant="ghost" onClick={onOpenWork}>{tr("Ouvrir la tâche de fond", "Open background task")} {task.lastTaskId.slice(0, 8)}</Button>}</div>}
|
||||
{expanded === task.id && <div className="mt-2 rounded-md bg-raised p-3 text-xs"><p><span className="text-muted">BackgroundTask :</span> {task.lastTaskId}</p>{run ? <><p className="mt-1"><span className="text-muted">{tr("Statut", "Status")} :</span> {run.status} · {duration(run, tr)} · {tr("code de sortie", "exit code")} {run.exitCode ?? tr("indisponible", "unavailable")}</p><p className="mt-1"><span className="text-muted">{tr("Permissions réellement utilisées", "Permissions actually used")} :</span> {tr("sandbox projet — lecture/écriture", "project sandbox — read/write")}</p>{run.summary && <p className="mt-2 whitespace-pre-wrap">{run.summary}</p>}{run.stdoutTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-canvas p-2">{run.stdoutTail}</pre>}{run.stderrTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-danger/5 p-2 text-danger">{run.stderrTail}</pre>}</> : <p className="mt-1 text-muted">{tr("Les détails de cette exécution ne sont plus présents dans le WorkState courant.", "Details for this run are no longer available in the current WorkState.")}</p>}</div>}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<FloatingWindow open={editor !== null} title={editor?.kind === "edit" ? (editor.task.approval === "pending" ? "Modifier avant d’activer" : "Modifier la tâche planifiée") : "Nouvelle tâche planifiée"} size="lg" onClose={() => setEditor(null)}>
|
||||
<FloatingWindow open={editor !== null} title={editor?.kind === "edit" ? (editor.task.approval === "pending" ? tr("Modifier avant d’activer", "Edit before enabling") : tr("Modifier la tâche planifiée", "Edit scheduled task")) : tr("Nouvelle tâche planifiée", "New scheduled task")} size="lg" onClose={() => setEditor(null)}>
|
||||
{editor && <ScheduledTaskForm task={editor.kind === "edit" ? editor.task : undefined} saving={vm.busyId === (editor.kind === "edit" ? editor.task.id : "new")} onCancel={() => setEditor(null)} onSubmit={(input) => void (editor.kind === "edit" ? vm.update(editor.task, input) : vm.create(input)).then((saved) => { if (saved) setEditor(null); })} />}
|
||||
</FloatingWindow>
|
||||
|
||||
<FloatingWindow open={deleteTarget !== null} title="Supprimer la tâche planifiée" size="sm" onClose={() => setDeleteTarget(null)}>
|
||||
<FloatingWindow open={deleteTarget !== null} title={tr("Supprimer la tâche planifiée", "Delete scheduled task")} size="sm" onClose={() => setDeleteTarget(null)}>
|
||||
{deleteTarget && (() => {
|
||||
const run = deleteTarget.lastTaskId ? vm.runsById.get(deleteTarget.lastTaskId) : undefined;
|
||||
const running = run && ["running", "pending", "cancelling"].includes(run.status);
|
||||
return <div className="space-y-4"><p className="text-sm">Supprimer définitivement cette tâche planifiée ?</p>{running ? <><p className="text-xs text-muted">Une exécution est en cours. La première action retire la planification mais laisse cette exécution se terminer.</p><div className="flex flex-col gap-2"><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer après l’exécution</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget, true)}>Arrêter puis supprimer</Button><Button onClick={() => setDeleteTarget(null)}>Annuler</Button></div></> : <div className="flex justify-end gap-2"><Button onClick={() => setDeleteTarget(null)}>Annuler</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer définitivement</Button></div>}</div>;
|
||||
return <div className="space-y-4"><p className="text-sm">{tr("Supprimer définitivement cette tâche planifiée ?", "Permanently delete this scheduled task?")}</p>{running ? <><p className="text-xs text-muted">{tr("Une exécution est en cours. La première action retire la planification mais laisse cette exécution se terminer.", "A run is in progress. The first action removes the schedule but lets this run finish.")}</p><div className="flex flex-col gap-2"><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>{tr("Supprimer après l’exécution", "Delete after run")}</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget, true)}>{tr("Arrêter puis supprimer", "Stop and delete")}</Button><Button onClick={() => setDeleteTarget(null)}>{tr("Annuler", "Cancel")}</Button></div></> : <div className="flex justify-end gap-2"><Button onClick={() => setDeleteTarget(null)}>{tr("Annuler", "Cancel")}</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>{tr("Supprimer définitivement", "Delete permanently")}</Button></div>}</div>;
|
||||
})()}
|
||||
</FloatingWindow>
|
||||
|
||||
<FloatingWindow open={approveTarget !== null} title="Approuver la tâche planifiée" size="md" onClose={() => setApproveTarget(null)}>
|
||||
<FloatingWindow open={approveTarget !== null} title={tr("Approuver la tâche planifiée", "Approve scheduled task")} size="md" onClose={() => setApproveTarget(null)}>
|
||||
{approveTarget && <div className="space-y-4">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">Cette tâche pourra modifier ou supprimer des fichiers automatiquement.</div>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">{tr("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.", "This task will be able to edit or delete files automatically.")}</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[8rem_1fr]">
|
||||
<dt className="text-muted">Commande exacte</dt><dd className="break-all font-mono">{exactCommand({ name: approveTarget.name, command: approveTarget.command, schedule: approveTarget.schedule, timezone: approveTarget.timezone, enabled: true })}</dd>
|
||||
<dt className="text-muted">Dossier</dt><dd>{approveTarget.command.cwd ?? "."} (relatif au projet)</dd>
|
||||
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(approveTarget.schedule)}</dd>
|
||||
<dt className="text-muted">Fuseau</dt><dd>{approveTarget.timezone}</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture</dd>
|
||||
<dt className="text-muted">{tr("Commande exacte", "Exact command")}</dt><dd className="break-all font-mono">{exactCommand({ name: approveTarget.name, command: approveTarget.command, schedule: approveTarget.schedule, timezone: approveTarget.timezone, enabled: true })}</dd>
|
||||
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd>{approveTarget.command.cwd ?? "."} ({tr("relatif au projet", "relative to project")})</dd>
|
||||
<dt className="text-muted">{tr("Fréquence", "Frequency")}</dt><dd>{scheduleLabel(approveTarget.schedule, tr)}</dd>
|
||||
<dt className="text-muted">{tr("Fuseau", "Time zone")}</dt><dd>{approveTarget.timezone}</dd>
|
||||
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{tr("Calculée par IdeA lors de l’activation", "Calculated by IdeA when enabled")}</dd>
|
||||
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Lecture, modification et suppression dans le projet", "Read, edit, and delete within the project")}</dd>
|
||||
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture", "Project — read/write")}</dd>
|
||||
</dl>
|
||||
<div className="flex justify-end gap-2"><Button onClick={() => setApproveTarget(null)}>Annuler</Button><Button variant="primary" loading={vm.busyId === approveTarget.id} onClick={() => void vm.setEnabled(approveTarget, true).then((ok) => { if (ok) setApproveTarget(null); })}>Approuver et activer</Button></div>
|
||||
<div className="flex justify-end gap-2"><Button onClick={() => setApproveTarget(null)}>{tr("Annuler", "Cancel")}</Button><Button variant="primary" loading={vm.busyId === approveTarget.id} onClick={() => void vm.setEnabled(approveTarget, true).then((ok) => { if (ok) setApproveTarget(null); })}>{tr("Approuver et activer", "Approve and enable")}</Button></div>
|
||||
</div>}
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
|
||||
@ -28,6 +28,9 @@ import { useRef, useState } from "react";
|
||||
import type { ServerExposureMode } from "@/domain";
|
||||
import { Button, Field, Input, Panel, cn } from "@/shared";
|
||||
import { useDeployment } from "./useDeployment";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
type Tr = (french: string, english: string) => string;
|
||||
|
||||
interface ModeOption {
|
||||
mode: ServerExposureMode;
|
||||
@ -36,37 +39,35 @@ interface ModeOption {
|
||||
}
|
||||
|
||||
/** The three exposure choices, in increasing order of reach. */
|
||||
const MODE_OPTIONS: ModeOption[] = [
|
||||
const modeOptions = (tr: Tr): ModeOption[] => [
|
||||
{
|
||||
mode: "localOnly",
|
||||
title: "Cet ordinateur uniquement",
|
||||
title: tr("Cet ordinateur uniquement", "This computer only"),
|
||||
description:
|
||||
"Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.",
|
||||
tr("Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.", "Use IdeA only on this computer. Remote devices cannot connect."),
|
||||
},
|
||||
{
|
||||
mode: "remoteProxyLocal",
|
||||
title: "Accès distant, proxy sur cet ordinateur",
|
||||
title: tr("Accès distant, proxy sur cet ordinateur", "Remote access, proxy on this computer"),
|
||||
description:
|
||||
"À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.",
|
||||
tr("À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.", "Use when the HTTPS proxy runs on the same machine as IdeA Desktop."),
|
||||
},
|
||||
{
|
||||
mode: "remoteProxyOtherMachine",
|
||||
title: "Accès distant, proxy sur une autre machine",
|
||||
title: tr("Accès distant, proxy sur une autre machine", "Remote access, proxy on another machine"),
|
||||
description:
|
||||
"À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.",
|
||||
tr("À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.", "Use when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy."),
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
stopped: "Arrêté",
|
||||
starting: "Démarrage…",
|
||||
running: "En cours d'exécution",
|
||||
stopping: "Arrêt…",
|
||||
failed: "Échec",
|
||||
};
|
||||
const stateLabel = (state: string, tr: Tr): string => ({
|
||||
stopped: tr("Arrêté", "Stopped"), starting: tr("Démarrage…", "Starting…"),
|
||||
running: tr("En cours d'exécution", "Running"), stopping: tr("Arrêt…", "Stopping…"), failed: tr("Échec", "Failed"),
|
||||
}[state] ?? state);
|
||||
|
||||
/** Copy-to-clipboard button; degrades to disabled where the API is absent. */
|
||||
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||
const { tr } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const supported =
|
||||
typeof navigator !== "undefined" && Boolean(navigator.clipboard);
|
||||
@ -86,7 +87,7 @@ function CopyButton({ value, label }: { value: string; label: string }) {
|
||||
);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copié" : "Copier"}
|
||||
{copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@ -105,14 +106,15 @@ function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string
|
||||
|
||||
export function DeploymentSettings() {
|
||||
const vm = useDeployment();
|
||||
const { tr } = useI18n();
|
||||
// "Modifier le port" (#89) jumps into Accès réseau — imperative focus is the
|
||||
// simplest correct answer for a cross-panel affordance on one scrolled page.
|
||||
const portInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!vm.ready || !vm.settings) {
|
||||
return (
|
||||
<Panel aria-label="deployment settings" title="Déploiement">
|
||||
<p className="text-sm text-muted">Chargement…</p>
|
||||
<Panel aria-label={tr("paramètres de déploiement", "deployment settings")} title={tr("Déploiement", "Deployment")}>
|
||||
<p className="text-sm text-muted">{tr("Chargement…", "Loading…")}</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -124,10 +126,10 @@ export function DeploymentSettings() {
|
||||
const transitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<div aria-label="deployment settings" className="flex flex-col gap-4">
|
||||
<div aria-label={tr("paramètres de déploiement", "deployment settings")} className="flex flex-col gap-4">
|
||||
{/* ── Status ────────────────────────────────────────────────────────── */}
|
||||
<Panel
|
||||
title="Serveur"
|
||||
title={tr("Serveur", "Server")}
|
||||
actions={
|
||||
running || status.state === "stopping" ? (
|
||||
<Button
|
||||
@ -135,7 +137,7 @@ export function DeploymentSettings() {
|
||||
onClick={() => void vm.stop()}
|
||||
disabled={vm.busy || transitioning}
|
||||
>
|
||||
Arrêter
|
||||
{tr("Arrêter", "Stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@ -143,7 +145,7 @@ export function DeploymentSettings() {
|
||||
onClick={() => void vm.start()}
|
||||
disabled={vm.busy || transitioning}
|
||||
>
|
||||
Démarrer
|
||||
{tr("Démarrer", "Start")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@ -159,17 +161,17 @@ export function DeploymentSettings() {
|
||||
(status.state === "stopped" || transitioning) && "bg-faint",
|
||||
)}
|
||||
/>
|
||||
<span className="text-content">{STATE_LABEL[status.state]}</span>
|
||||
<span className="text-content">{stateLabel(status.state, tr)}</span>
|
||||
</p>
|
||||
|
||||
{status.localUrl && (
|
||||
<p className="text-xs text-muted">
|
||||
URL locale : <code className="font-mono text-content">{status.localUrl}</code>
|
||||
{tr("URL locale", "Local URL")} : <code className="font-mono text-content">{status.localUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
{status.publicUrl && (
|
||||
<p className="text-xs text-muted">
|
||||
URL publique : <code className="font-mono text-content">{status.publicUrl}</code>
|
||||
{tr("URL publique", "Public URL")} : <code className="font-mono text-content">{status.publicUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -181,7 +183,7 @@ export function DeploymentSettings() {
|
||||
status.error.code === "PORT_IN_USE" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
Le serveur n'a pas démarré automatiquement. {status.error.message}
|
||||
{tr("Le serveur n'a pas démarré automatiquement.", "The server did not start automatically.")} {status.error.message}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@ -195,14 +197,14 @@ export function DeploymentSettings() {
|
||||
portInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
Modifier le port
|
||||
{tr("Modifier le port", "Change port")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void vm.start()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Réessayer
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -224,18 +226,17 @@ export function DeploymentSettings() {
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
aria-label="Démarrer le serveur au lancement d'IdeA"
|
||||
aria-label={tr("Démarrer le serveur au lancement d'IdeA", "Start the server when IdeA launches")}
|
||||
checked={settings.autoStart}
|
||||
disabled={vm.autoStartBusy}
|
||||
onChange={(e) => void vm.setAutoStart(e.target.checked)}
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm text-content">
|
||||
Démarrer le serveur au lancement d'IdeA
|
||||
{tr("Démarrer le serveur au lancement d'IdeA", "Start the server when IdeA launches")}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
IdeA utilisera les réglages réseau enregistrés ci-dessous au
|
||||
prochain démarrage de l'application desktop.
|
||||
{tr("IdeA utilisera les réglages réseau enregistrés ci-dessous au prochain démarrage de l'application desktop.", "IdeA will use the network settings saved below the next time the desktop application starts.")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@ -243,14 +244,14 @@ export function DeploymentSettings() {
|
||||
</Panel>
|
||||
|
||||
{/* ── Exposure (ticket #78 — "Accès réseau" per UX) ────────────────── */}
|
||||
<Panel title="Accès réseau">
|
||||
<Panel title={tr("Accès réseau", "Network access")}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="mode d'accès réseau"
|
||||
aria-label={tr("mode d'accès réseau", "network access mode")}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{MODE_OPTIONS.map((option) => {
|
||||
{modeOptions(tr).map((option) => {
|
||||
const selected = settings.mode === option.mode;
|
||||
return (
|
||||
<label
|
||||
@ -280,7 +281,7 @@ export function DeploymentSettings() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Field label="Port">
|
||||
<Field label={tr("Port", "Port")}>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
ref={portInputRef}
|
||||
@ -293,12 +294,12 @@ export function DeploymentSettings() {
|
||||
</Field>
|
||||
|
||||
{remote && (
|
||||
<Field label="Origine publique" hint="Exemple : https://idea.example.com">
|
||||
<Field label={tr("Origine publique", "Public origin")} hint={tr("Exemple : https://idea.example.com", "Example: https://idea.example.com")}>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
placeholder="https://idea.example.com"
|
||||
placeholder={tr("https://idea.example.com", "https://idea.example.com")}
|
||||
value={settings.publicOrigin ?? ""}
|
||||
onChange={(e) => vm.setPublicOrigin(e.target.value)}
|
||||
/>
|
||||
@ -310,8 +311,8 @@ export function DeploymentSettings() {
|
||||
<>
|
||||
{/* Addresses come from the backend probe — never invented here. */}
|
||||
<Field
|
||||
label="Adresse LAN d'écoute"
|
||||
hint="L'adresse de cette machine à laquelle le proxy se connectera."
|
||||
label={tr("Adresse LAN d'écoute", "LAN listen address")}
|
||||
hint={tr("L'adresse de cette machine à laquelle le proxy se connectera.", "The address on this machine that the proxy will connect to.")}
|
||||
>
|
||||
{({ id, describedBy }) =>
|
||||
vm.candidateLanAddresses.length > 0 ? (
|
||||
@ -322,7 +323,7 @@ export function DeploymentSettings() {
|
||||
value={settings.lanBindAddress ?? ""}
|
||||
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
||||
>
|
||||
<option value="">Sélectionner une adresse…</option>
|
||||
<option value="">{tr("Sélectionner une adresse…", "Select an address…")}</option>
|
||||
{vm.candidateLanAddresses.map((address) => (
|
||||
<option key={address} value={address}>
|
||||
{address}
|
||||
@ -333,7 +334,7 @@ export function DeploymentSettings() {
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
placeholder="192.168.1.42"
|
||||
placeholder={tr("192.168.1.42", "192.168.1.42")}
|
||||
value={settings.lanBindAddress ?? ""}
|
||||
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
||||
/>
|
||||
@ -342,14 +343,14 @@ export function DeploymentSettings() {
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="IP/CIDR du proxy autorisé"
|
||||
hint="Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA."
|
||||
label={tr("IP/CIDR du proxy autorisé", "Authorized proxy IP/CIDR")}
|
||||
hint={tr("Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA.", "This is not IdeA's listen address. It is the machine authorized to contact IdeA.")}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
placeholder="203.0.113.7 or 203.0.113.0/24"
|
||||
placeholder={tr("203.0.113.7 ou 203.0.113.0/24", "203.0.113.7 or 203.0.113.0/24")}
|
||||
value={settings.trustedProxies.join(", ")}
|
||||
onChange={(e) => vm.setTrustedProxies(e.target.value)}
|
||||
/>
|
||||
@ -357,8 +358,7 @@ export function DeploymentSettings() {
|
||||
</Field>
|
||||
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode.
|
||||
Sinon, le proxy peut expirer sans erreur IdeA.
|
||||
{tr("Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode. Sinon, le proxy peut expirer sans erreur IdeA.", "If your proxy is not on this computer, choose this mode. Otherwise the proxy may time out without an IdeA error.")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@ -380,16 +380,16 @@ export function DeploymentSettings() {
|
||||
|
||||
{/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */}
|
||||
{remote && (
|
||||
<Panel title="Configuration du proxy">
|
||||
<Panel title={tr("Configuration du proxy", "Proxy configuration")}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted">
|
||||
Faites pointer votre proxy inverse HTTPS vers cet upstream.
|
||||
{tr("Faites pointer votre proxy inverse HTTPS vers cet upstream.", "Point your HTTPS reverse proxy to this upstream.")}
|
||||
</p>
|
||||
{vm.upstreamUrl ? (
|
||||
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copier l'URL upstream" />
|
||||
<ReadOnlyValue value={vm.upstreamUrl} copyLabel={tr("copier l'URL upstream", "copy upstream URL")} />
|
||||
) : (
|
||||
<p className="text-xs text-faint">
|
||||
Complétez les paramètres ci-dessus pour obtenir l'URL upstream.
|
||||
{tr("Complétez les paramètres ci-dessus pour obtenir l'URL upstream.", "Complete the settings above to obtain the upstream URL.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -397,11 +397,10 @@ export function DeploymentSettings() {
|
||||
)}
|
||||
|
||||
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
|
||||
<Panel title="Appairage">
|
||||
<Panel title={tr("Appairage", "Pairing")}>
|
||||
<p className="text-sm text-muted">
|
||||
L'appairage est géré dans{" "}
|
||||
<span className="text-content">Paramètres → Appareils</span>, où
|
||||
vous pouvez générer un code et révoquer des appareils.
|
||||
{tr("L'appairage est géré dans", "Pairing is managed in")}{" "}
|
||||
<span className="text-content">{tr("Paramètres → Appareils", "Settings → Devices")}</span>, {tr("où vous pouvez générer un code et révoquer des appareils.", "where you can generate a code and revoke devices.")}
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
37
frontend/src/features/settings/GeneralSettings.tsx
Normal file
37
frontend/src/features/settings/GeneralSettings.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { Panel, Spinner } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { IdeaLanguage } from "@/ports";
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function select(next: IdeaLanguage) {
|
||||
if (next === language || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setLanguage(next);
|
||||
} catch {
|
||||
setError(t("settings.language.saveError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={t("settings.language.title")} className="flex flex-col gap-4 p-4">
|
||||
<fieldset disabled={busy} className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-semibold text-content">{t("settings.language.legend")}</legend>
|
||||
<div className="flex gap-5">
|
||||
{(["fr", "en"] as const).map((value) => <label key={value} className="flex items-center gap-2 text-sm text-content"><input type="radio" name="idea-language" value={value} checked={language === value} onChange={() => void select(value)} />{t(`settings.language.${value}`)}</label>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted">{t("settings.language.help")}</p>
|
||||
</fieldset>
|
||||
{busy && <Spinner size={14} />}
|
||||
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -27,12 +27,15 @@ import { DevicesScreen } from "@/features/devices";
|
||||
import { PluginsPanel } from "@/features/plugins";
|
||||
import { DeploymentSettings } from "./DeploymentSettings";
|
||||
import { TerminalSettings } from "./TerminalSettings";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** The Settings sections, in menu/nav order. */
|
||||
export type SettingsSection = "aiProfiles" | "terminal" | "deployment" | "devices" | "plugins";
|
||||
export type SettingsSection = "general" | "aiProfiles" | "terminal" | "deployment" | "devices" | "plugins";
|
||||
|
||||
/** Human labels, shared by the nav column and the `Settings` menu (ticket #78 — French). */
|
||||
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
|
||||
general: "Général",
|
||||
aiProfiles: "Profils IA",
|
||||
terminal: "Terminal",
|
||||
deployment: "Déploiement",
|
||||
@ -42,6 +45,7 @@ export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
|
||||
|
||||
/** Section order — the single source of truth for both nav and menu. */
|
||||
export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
"general",
|
||||
"aiProfiles",
|
||||
"terminal",
|
||||
"deployment",
|
||||
@ -64,10 +68,15 @@ export function SettingsView({
|
||||
onAssignPluginCapabilities,
|
||||
onClose,
|
||||
}: SettingsViewProps) {
|
||||
const { t } = useI18n();
|
||||
const sectionLabels: Record<SettingsSection, string> = {
|
||||
general: t("settings.general"), aiProfiles: t("settings.aiProfiles"), terminal: t("settings.terminal"),
|
||||
deployment: t("settings.deployment"), devices: t("settings.devices"), plugins: t("settings.plugins"),
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<nav
|
||||
aria-label="settings sections"
|
||||
aria-label={t("settings.sections")}
|
||||
className="flex w-52 shrink-0 flex-col gap-1 border-r border-border p-3"
|
||||
>
|
||||
{SETTINGS_SECTIONS.map((id) => {
|
||||
@ -85,21 +94,23 @@ export function SettingsView({
|
||||
: "text-muted hover:bg-raised hover:text-content",
|
||||
)}
|
||||
>
|
||||
{SETTINGS_SECTION_LABEL[id]}
|
||||
{sectionLabels[id]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
<Button size="sm" variant="ghost" className="w-full" onClick={onClose}>
|
||||
Fermer les paramètres
|
||||
{t("settings.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
{section === "aiProfiles" ? (
|
||||
{section === "general" ? (
|
||||
<GeneralSettings />
|
||||
) : section === "aiProfiles" ? (
|
||||
<ProfilesSettings />
|
||||
) : section === "terminal" ? (
|
||||
<TerminalSettings />
|
||||
|
||||
@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import type { DefaultAgentTerminalInterface } from "@/ports";
|
||||
import { Panel, Spinner } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const choices: Array<{ value: DefaultAgentTerminalInterface; label: string }> = [
|
||||
{ value: "nativeTui", label: "TUI native" },
|
||||
@ -11,6 +12,7 @@ const choices: Array<{ value: DefaultAgentTerminalInterface; label: string }> =
|
||||
|
||||
export function TerminalSettings() {
|
||||
const { userSettings } = useGateways();
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState<DefaultAgentTerminalInterface>("nativeTui");
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -27,7 +29,7 @@ export function TerminalSettings() {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Impossible de charger la préférence de terminal.");
|
||||
if (!cancelled) setError(t("settings.terminal.loadError"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBusy(false);
|
||||
@ -35,7 +37,7 @@ export function TerminalSettings() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userSettings]);
|
||||
}, [userSettings, t]);
|
||||
|
||||
async function select(next: DefaultAgentTerminalInterface) {
|
||||
const previous = value;
|
||||
@ -49,7 +51,7 @@ export function TerminalSettings() {
|
||||
setValue(saved.defaultAgentTerminalInterface ?? "nativeTui");
|
||||
} catch {
|
||||
setValue(previous);
|
||||
setError("Impossible d’enregistrer la préférence de terminal.");
|
||||
setError(t("settings.terminal.saveError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -59,7 +61,7 @@ export function TerminalSettings() {
|
||||
<Panel title="Terminal" className="flex flex-col gap-4 p-4">
|
||||
<fieldset disabled={busy} className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-semibold text-content">
|
||||
Interface de terminal par défaut
|
||||
{t("settings.terminal.default")}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
{choices.map((choice) => (
|
||||
@ -76,7 +78,7 @@ export function TerminalSettings() {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Utilisée à l'ouverture d'un nouveau terminal. Les terminaux déjà ouverts ne sont pas modifiés.
|
||||
{t("settings.terminal.help")}
|
||||
</p>
|
||||
</fieldset>
|
||||
{busy && <Spinner size={14} />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user