From 1a1429f10ec239f1b767e436da79987a2c04b218 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 21:53:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(domain,application,infrastructure,backend,?= =?UTF-8?q?web-server,app-tauri,frontend):=20lot=20utilisateur=20#325=20#3?= =?UTF-8?q?26=20=E2=80=94=20ic=C3=B4nes=20projet=20avec=20watermark=20CLI?= =?UTF-8?q?=20custom=20et=20localisation=20FR/EN=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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. --- crates/app-tauri/src/commands.rs | 7 +- crates/application/src/settings.rs | 98 +++++- .../application/tests/structured_launch_d3.rs | 2 + crates/backend/src/dto.rs | 87 +++++ crates/domain/src/lib.rs | 5 +- crates/domain/src/settings.rs | 90 +++++ crates/domain/tests/settings.rs | 93 +++++ crates/infrastructure/tests/settings.rs | 40 ++- crates/web-server/src/lib.rs | 35 +- frontend/src/adapters/http/index.ts | 2 +- .../adapters/http/requestResponseGateways.ts | 4 +- frontend/src/adapters/mock/index.ts | 20 +- frontend/src/adapters/project.test.ts | 13 + frontend/src/adapters/project.ts | 4 +- frontend/src/adapters/userSettings.test.ts | 15 + frontend/src/adapters/userSettings.ts | 2 +- frontend/src/app/App.tsx | 12 +- frontend/src/app/ViewWindow.test.tsx | 8 +- frontend/src/app/ViewWindow.tsx | 6 +- frontend/src/app/main.tsx | 3 + .../features/agents/AgentLimitBadge.test.tsx | 20 +- .../src/features/agents/AgentLimitBadge.tsx | 30 +- frontend/src/features/agents/AgentsPanel.tsx | 186 +++++----- .../agents/CustomAgentChatView.test.tsx | 188 +++++----- .../features/agents/CustomAgentChatView.tsx | 196 ++++++----- .../agents/ModelServerLaunchBadge.test.tsx | 14 +- .../agents/ModelServerLaunchBadge.tsx | 31 +- .../features/agents/ResumeProjectPanel.tsx | 33 +- frontend/src/features/agents/agents.test.tsx | 122 +++---- .../features/agents/resumeProject.test.tsx | 12 +- .../features/appExit/AppExitConfirmDialog.tsx | 40 ++- .../conversations/ConversationViewer.test.tsx | 6 +- .../conversations/ConversationViewer.tsx | 73 ++-- .../src/features/devices/ConfirmDialog.tsx | 4 +- .../src/features/devices/DevicesScreen.tsx | 49 +-- .../src/features/devices/PairingCodePanel.tsx | 20 +- .../src/features/devices/formatActivity.ts | 28 +- .../features/embedder/EmbedderSettings.tsx | 79 +++-- .../src/features/embedder/embedder.test.tsx | 46 +-- .../first-run/FirstRunWizard.test.tsx | 332 +++++++++--------- .../src/features/first-run/FirstRunWizard.tsx | 82 +++-- .../features/first-run/OpenCodeModeFields.tsx | 126 +++---- .../first-run/ProfilesSettings.test.tsx | 50 +-- .../features/first-run/ProfilesSettings.tsx | 90 ++--- frontend/src/features/git/GitGraphView.tsx | 44 +-- frontend/src/features/git/GitPanel.tsx | 45 +-- frontend/src/features/git/git.test.tsx | 44 +-- .../src/features/git/gitGraphView.test.tsx | 18 +- .../LayoutGrid.cellControlsLayering.test.tsx | 24 +- .../features/layout/LayoutGrid.chat.test.tsx | 10 +- .../src/features/layout/LayoutGrid.test.tsx | 6 +- frontend/src/features/layout/LayoutGrid.tsx | 145 ++++---- .../src/features/layout/LayoutTabs.test.tsx | 12 +- frontend/src/features/layout/LayoutTabs.tsx | 18 +- .../layout/layoutTabsGitGraph.test.tsx | 16 +- .../src/features/layout/setCellAgent.test.tsx | 4 +- .../features/layout/singletonAgent.test.tsx | 6 +- frontend/src/features/memory/MemoryEditor.tsx | 40 ++- frontend/src/features/memory/MemoryPanel.tsx | 24 +- frontend/src/features/memory/memory.test.tsx | 48 +-- .../model-servers/ModelServersPanel.tsx | 164 +++++---- .../model-servers/modelServers.test.tsx | 90 ++--- .../src/features/permissions/McpPanel.tsx | 6 +- .../McpToolPermissionsPanel.test.tsx | 12 +- .../permissions/McpToolPermissionsPanel.tsx | 88 +++-- .../features/permissions/PermissionsPanel.tsx | 159 +++++---- .../src/features/permissions/mcpToolGroups.ts | 19 +- .../features/permissions/permissions.test.tsx | 48 +-- .../features/plugins/PluginConfirmDialog.tsx | 4 +- .../plugins/PluginLayoutCellView.test.tsx | 4 +- .../features/plugins/PluginLayoutFallback.tsx | 23 +- .../plugins/PluginLayoutSelectorSection.tsx | 8 +- .../src/features/plugins/PluginsPanel.tsx | 84 +++-- .../plugins/ProjectPluginSettings.test.tsx | 2 +- .../features/plugins/ProjectPluginsPanel.tsx | 37 +- .../src/features/plugins/plugins.test.tsx | 10 +- .../features/projects/ProjectActionsMenu.tsx | 10 +- .../features/projects/ProjectContextPanel.tsx | 8 +- .../features/projects/ProjectIconPicker.tsx | 42 +++ .../src/features/projects/ProjectLauncher.tsx | 20 +- .../projects/ProjectSettingsPanel.test.tsx | 53 ++- .../projects/ProjectSettingsPanel.tsx | 77 +++- .../features/projects/ProjectTabs.test.tsx | 18 +- .../src/features/projects/ProjectTabs.tsx | 13 +- .../projects/ProjectsView.detach.test.tsx | 6 +- .../projects/ProjectsView.docking.test.tsx | 26 +- .../projects/ProjectsView.focus.test.tsx | 4 +- .../projects/ProjectsView.ls7.test.tsx | 35 +- .../projects/ProjectsView.reconcile.test.tsx | 2 +- .../src/features/projects/ProjectsView.tsx | 158 +++++---- .../features/projects/RemoveProjectDialog.tsx | 17 +- .../projects/pluginGitRepository.test.tsx | 8 +- .../src/features/projects/projectIcons.tsx | 107 ++++++ .../src/features/projects/projects.test.tsx | 152 ++++---- .../scheduled-tasks/ScheduledTaskForm.tsx | 80 +++-- .../scheduled-tasks/ScheduledTasksPanel.tsx | 103 +++--- .../features/settings/DeploymentSettings.tsx | 107 +++--- .../src/features/settings/GeneralSettings.tsx | 37 ++ .../src/features/settings/SettingsView.tsx | 21 +- .../features/settings/TerminalSettings.tsx | 12 +- frontend/src/features/skills/SkillEditor.tsx | 38 +- frontend/src/features/skills/SkillsPanel.tsx | 58 +-- frontend/src/features/skills/skills.test.tsx | 34 +- .../src/features/templates/TemplateEditor.tsx | 89 +++-- .../src/features/templates/TemplatesPanel.tsx | 32 +- .../templates/templatePresentation.ts | 26 +- .../src/features/templates/templates.test.tsx | 136 +++---- .../src/features/tickets/SprintManager.tsx | 66 ++-- .../features/tickets/SprintPicker.test.tsx | 28 +- .../src/features/tickets/SprintPicker.tsx | 21 +- .../features/tickets/TicketAssistantPanel.tsx | 24 +- .../src/features/tickets/TicketDetail.tsx | 146 ++++---- .../src/features/tickets/TicketFacetsBar.tsx | 51 ++- .../features/tickets/TicketPicker.test.tsx | 48 +-- .../src/features/tickets/TicketPicker.tsx | 31 +- .../src/features/tickets/TicketsPanel.tsx | 97 +++-- ...ketFiltersPersistence.integration.test.tsx | 8 +- frontend/src/features/tickets/ticketMeta.tsx | 36 +- .../src/features/tickets/tickets.test.tsx | 292 +++++++-------- frontend/src/features/web/PairingScreen.tsx | 17 +- .../src/features/web/ProjectFilesPanel.tsx | 32 +- frontend/src/features/web/TerminalKeyBar.tsx | 6 +- .../src/features/web/WebAgentCell.test.tsx | 4 +- frontend/src/features/web/WebAgentCell.tsx | 4 +- frontend/src/features/web/WebApp.test.tsx | 8 +- frontend/src/features/web/WebApp.tsx | 27 +- .../features/web/WebDesktopWorkspace.test.tsx | 2 +- frontend/src/features/web/WebMenus.test.tsx | 100 ++---- frontend/src/features/web/WebMobile.test.tsx | 2 +- .../src/features/web/WebProjectsPanel.tsx | 24 +- frontend/src/features/web/WebWorkspace.tsx | 166 +++++---- .../features/web/WebWorkspaceLive.test.tsx | 71 ++-- .../features/web/tickets/WebConfirmDialog.tsx | 4 +- .../features/web/tickets/WebSprintsView.tsx | 61 ++-- .../features/web/tickets/WebTicketCreate.tsx | 30 +- .../features/web/tickets/WebTicketDetail.tsx | 101 +++--- .../web/tickets/WebTicketPickerSheet.tsx | 21 +- .../web/tickets/WebTicketsSprints.test.tsx | 24 +- .../features/web/tickets/WebTicketsView.tsx | 63 ++-- .../features/web/tickets/webTicketLabels.tsx | 40 ++- .../workstate/ProjectWorkStatePanel.tsx | 186 +++++----- .../src/features/workstate/workstate.test.tsx | 177 +++++----- frontend/src/i18n/I18nProvider.tsx | 93 +++++ frontend/src/i18n/catalog.ts | 197 +++++++++++ frontend/src/i18n/i18n.test.tsx | 37 ++ frontend/src/i18n/index.ts | 2 + frontend/src/i18n/visibleSourceScan.test.ts | 54 +++ frontend/src/plugins/runtime/services.test.ts | 5 +- frontend/src/ports/index.ts | 15 +- frontend/src/shared/ui/DockRegion.test.tsx | 6 +- frontend/src/shared/ui/DockRegion.tsx | 6 +- .../src/shared/ui/FloatingWindow.test.tsx | 6 +- frontend/src/shared/ui/FloatingWindow.tsx | 6 +- frontend/src/shared/ui/Tabs.tsx | 4 +- frontend/src/shared/ui/ui.test.tsx | 4 +- frontend/src/test/setupI18n.ts | 12 + frontend/vitest.config.ts | 1 + 157 files changed, 4531 insertions(+), 3129 deletions(-) create mode 100644 crates/domain/tests/settings.rs create mode 100644 frontend/src/adapters/userSettings.test.ts create mode 100644 frontend/src/features/projects/ProjectIconPicker.tsx create mode 100644 frontend/src/features/projects/projectIcons.tsx create mode 100644 frontend/src/features/settings/GeneralSettings.tsx create mode 100644 frontend/src/i18n/I18nProvider.tsx create mode 100644 frontend/src/i18n/catalog.ts create mode 100644 frontend/src/i18n/i18n.test.tsx create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/visibleSourceScan.test.ts create mode 100644 frontend/src/test/setupI18n.ts diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 2c45b2b5..7ff722c8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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) diff --git a/crates/application/src/settings.rs b/crates/application/src/settings.rs index e063297a..d6af6ee6 100644 --- a/crates/application/src/settings.rs +++ b/crates/application/src/settings.rs @@ -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>, + icon: Option>, ) -> Result { 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); + + #[async_trait] + impl ProjectSettingsStore for MemoryProjectSettings { + async fn load(&self, _root: &ProjectPath) -> Result { + 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") + ); + } +} diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 6643554b..60abc8d8 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -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(); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 83ad2bab..b70f1999 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3266,12 +3266,16 @@ pub struct UserSettingsDto { /// Default interface for newly opened agent terminals. #[serde(default)] pub default_agent_terminal_interface: Option, + /// Preferred interface language. Absence preserves the product default. + #[serde(default)] + pub language: Option, } impl From 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 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 for ProjectIconRefDto { + fn from(value: domain::ProjectIconRef) -> Self { + Self { + set: value.set(), + name: value.name().to_owned(), + } + } +} + +impl TryFrom for domain::ProjectIconRef { + type Error = String; + + fn try_from(value: ProjectIconRefDto) -> Result { + 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, + /// Optional validated project icon. + #[serde(default)] + pub icon: Option, } impl From 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>, + /// Missing leaves the icon unchanged; null restores the product default. + pub icon: Option>, } 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::(object, "themeColor") .map_err(serde::de::Error::custom)?; + let icon = take_optional_value::(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::(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::(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(); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index b63f7d39..8ef8187c 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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, diff --git a/crates/domain/src/settings.rs b/crates/domain/src/settings.rs index 96e90f51..2dbbaecb 100644 --- a/crates/domain/src/settings.rs +++ b/crates/domain/src/settings.rs @@ -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, + /// Preferred interface language. Absence preserves the product default. + #[serde(default)] + pub language: Option, +} + +/// 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) -> Result { + 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 for ProjectIconRef { + type Error = String; + + fn try_from(value: UncheckedProjectIconRef) -> Result { + 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, + /// Optional validated icon used as the project's visual identity. + #[serde(default)] + pub icon: Option, } diff --git a/crates/domain/tests/settings.rs b/crates/domain/tests/settings.rs new file mode 100644 index 00000000..22938408 --- /dev/null +++ b/crates/domain/tests/settings.rs @@ -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::(&json).unwrap()["language"], + wire + ); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + settings + ); + } + + assert!(serde_json::from_str::(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::(value).unwrap(), + settings + ); + } +} + +#[test] +fn project_icon_rejects_unknown_sets_and_unstable_or_oversized_names() { + assert!( + serde_json::from_str::(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()); +} diff --git a/crates/infrastructure/tests/settings.rs b/crates/infrastructure/tests/settings.rs index e6f5947c..c3955d17 100644 --- a/crates/infrastructure/tests/settings.rs +++ b/crates/infrastructure/tests/settings.rs @@ -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] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 92d95258..82caf0e7 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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] diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index e54566d7..40ffa9be 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -166,7 +166,7 @@ class HttpUserSettingsGateway implements UserSettingsGateway { return this.http.invoke("read_user_settings"); } - updateUserSettings(settings: UserSettings): Promise { + updateUserSettings(settings: Partial): Promise { return this.http.invoke("update_user_settings", { request: settings }); } } diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 2e4e35f3..4fea0753 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -141,9 +141,9 @@ export class HttpProjectGateway implements ProjectGateway { readProjectSettings(projectId: string): Promise { return this.http.invoke("read_project_settings", { projectId }); } - updateProjectSettings(projectId: string, settings: ProjectSettings): Promise { + updateProjectSettings(projectId: string, settings: Partial): Promise { return this.http.invoke("update_project_settings", { - request: { projectId, themeColor: settings.themeColor }, + request: { projectId, ...settings }, }); } listProjectFiles(projectId: string, path: string): Promise { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index d7235ae8..98016b6b 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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, ): Promise { 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 { return { ...this.settings }; } - async updateUserSettings(settings: UserSettings): Promise { - this.settings = { ...settings }; + async updateUserSettings(settings: Partial): Promise { + this.settings = { ...this.settings, ...settings }; return { ...this.settings }; } } diff --git a/frontend/src/adapters/project.test.ts b/frontend/src/adapters/project.test.ts index 4fb7ec88..6d4f12b3 100644 --- a/frontend/src/adapters/project.test.ts +++ b/frontend/src/adapters/project.test.ts @@ -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" }, + }, + }); + }); }); diff --git a/frontend/src/adapters/project.ts b/frontend/src/adapters/project.ts index e8b9ffa5..216dee9c 100644 --- a/frontend/src/adapters/project.ts +++ b/frontend/src/adapters/project.ts @@ -84,10 +84,10 @@ export class TauriProjectGateway implements ProjectGateway { updateProjectSettings( projectId: string, - settings: ProjectSettings, + settings: Partial, ): Promise { return invoke("update_project_settings", { - request: { projectId, themeColor: settings.themeColor }, + request: { projectId, ...settings }, }); } diff --git a/frontend/src/adapters/userSettings.test.ts b/frontend/src/adapters/userSettings.test.ts new file mode 100644 index 00000000..5072fcbc --- /dev/null +++ b/frontend/src/adapters/userSettings.test.ts @@ -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" } }); + }); +}); diff --git a/frontend/src/adapters/userSettings.ts b/frontend/src/adapters/userSettings.ts index a5bf807f..47dfadc3 100644 --- a/frontend/src/adapters/userSettings.ts +++ b/frontend/src/adapters/userSettings.ts @@ -7,7 +7,7 @@ export class TauriUserSettingsGateway implements UserSettingsGateway { return invoke("read_user_settings"); } - updateUserSettings(settings: UserSettings): Promise { + updateUserSettings(settings: Partial): Promise { return invoke("update_user_settings", { request: settings }); } } diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 19fd7383..3bd3b872 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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(null); const [error, setError] = useState(null); @@ -78,14 +80,14 @@ export function App() { {health ? ( <> - backend ● v{health.version} ·{" "} - {events.length} events + {tr("backend", "backend")} ● v{health.version} ·{" "} + {events.length} {tr("événements", "events")} ) : error ? ( - backend unavailable + {tr("backend indisponible", "backend unavailable")} ) : ( - checking… + {tr("vérification…", "checking…")} )} @@ -96,7 +98,7 @@ export function App() {
{error && ( -

Error: {error}

+

{tr("Erreur :", "Error:")} {error}

)} diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 71260870..23ca8df2 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -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(), ); }); }); diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx index 499a6b67..70348801 100644 --- a/frontend/src/app/ViewWindow.tsx +++ b/frontend/src/app/ViewWindow.tsx @@ -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( @@ -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(undefined); @@ -298,12 +300,12 @@ function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) { ) : (

- 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.")}

)} {loading && ( -

Chargement des plugins...

+

{tr("Chargement des plugins…", "Loading plugins…")}

)}
diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index a415314a..efc9b764 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -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( + {isWeb && webSurfaceParams ? ( ) : isWeb ? ( @@ -51,6 +53,7 @@ ReactDOM.createRoot(root).render( ) : ( )} + , diff --git a/frontend/src/features/agents/AgentLimitBadge.test.tsx b/frontend/src/features/agents/AgentLimitBadge.test.tsx index 70bb5441..d12201d9 100644 --- a/frontend/src/features/agents/AgentLimitBadge.test.tsx +++ b/frontend/src/features/agents/AgentLimitBadge.test.tsx @@ -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); }); }); diff --git a/frontend/src/features/agents/AgentLimitBadge.tsx b/frontend/src/features/agents/AgentLimitBadge.tsx index 16e9f52a..349a898d 100644 --- a/frontend/src/features/agents/AgentLimitBadge.tsx +++ b/frontend/src/features/agents/AgentLimitBadge.tsx @@ -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 ( - heure inconnue + {tr("heure inconnue", "unknown time")} 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")} )} {resumeFireAt !== undefined && ( - - reprise dans {formatCountdown(resumeFireAt - now)} + + {tr("reprise dans", "resuming in")} {formatCountdown(resumeFireAt - now)} )} diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 8a20ef99..7cd3ecc2 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -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 ( - + {vm.error && (

- Name + {tr("Nom", "Name")} 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")} ({ value: t.id, label: t.name })), ]} /> {selectedTemplate && (

- Nom initial:{" "} + {tr("Nom initial", "Initial name")}: {" "} {selectedTemplate.defaultAgentName || selectedTemplate.name}
- Modèle appliqué:{" "} + {tr("Modèle appliqué", "Applied model")}: {" "} - {templateProfileLabel(selectedTemplate, vm.profiles)} + {templateProfileLabel(selectedTemplate, vm.profiles, tr)}
{selectedTemplateMissingProfile && (

- 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.")}

)}
@@ -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 && ( - (no profiles configured — you can still enter an id) + {tr("(aucun profil configuré — vous pouvez saisir un identifiant)", "(no profiles configured — you can still enter an id)")} )} {vm.profiles.length > 0 ? ( ({ value: p.id, label: profileLabel(p), @@ -368,8 +369,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { ) : ( setNewProfileId(e.target.value)} className="min-w-32" @@ -381,19 +382,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {/* ── Agent list ── */}
{vm.agents.length === 0 ? ( -

No agents yet.

+

{tr("Aucun agent pour le moment.", "No agents yet.")}

) : (
    {vm.agents.map((a) => { @@ -438,27 +439,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {a.name} {a.isOrchestrator && ( - Writer contexte + {tr("Responsable du contexte", "Context writer")} )} {agentDrift && ( - update available + {tr("mise à jour disponible", "update available")} )} {delegationSource && ( @@ -470,36 +471,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {profileName} {live && ( - running in IdeA · {live.sessionId ?? live.nodeId} + {tr("actif dans IdeA", "running in IdeA")} · {live.sessionId ?? live.nodeId} )} {isSelected && (

    - Rôle dans le projet + {tr("Rôle dans le projet", "Role in project")}

    - 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.")}

    )} @@ -531,7 +531,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { engine abandons the conversation history → confirmation. */} {vm.profiles.length > 0 && ( { @@ -562,13 +562,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} {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")} ) : ( )}
@@ -653,12 +653,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {

- Context — {selectedAgent.name} + {tr("Contexte", "Context")} — {selectedAgent.name}

{selectedAgent.contextPath}