diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index f4363feb..0b1549d1 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -344,6 +344,7 @@ fn progress_entity_key(progress: &ReplyProgressDto) -> Option { fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { match chunk { ReplyChunk::UserPrompt { + at_ms: _, text, attachments, attachment_refs, @@ -360,8 +361,8 @@ fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { }) .sum::() } - ReplyChunk::TextDelta { text } => text.len(), - ReplyChunk::ToolActivity { label } => label.len(), + ReplyChunk::TextDelta { text, .. } => text.len(), + ReplyChunk::ToolActivity { label, .. } => label.len(), ReplyChunk::Progress { progress } => { progress.label.len() + progress.text.as_ref().map_or(0, String::len) @@ -369,8 +370,8 @@ fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { + progress.native_event.as_ref().map_or(0, String::len) + progress.tool_name.as_ref().map_or(0, String::len) } - ReplyChunk::Final { content } => content.len(), - ReplyChunk::Error { message } => message.len(), + ReplyChunk::Final { content, .. } => content.len(), + ReplyChunk::Error { message, .. } => message.len(), } } @@ -386,40 +387,57 @@ fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { /// too. Every content-bearing event still maps to exactly one chunk. #[must_use] pub fn chunk_from_event(event: ReplyEvent) -> Option { + chunk_from_event_at(event, None) +} + +/// Maps a domain reply event while preserving the backend acceptance time. +#[must_use] +pub fn chunk_from_event_at(event: ReplyEvent, at_ms: Option) -> Option { match event { - ReplyEvent::Progress { progress } => Some(ReplyChunk::Progress { - progress: progress.into(), - }), - ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }), + ReplyEvent::Progress { progress } => { + let mut progress: ReplyProgressDto = progress.into(); + progress.occurred_at_ms = at_ms; + Some(ReplyChunk::Progress { progress }) + } + ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text, at_ms }), ReplyEvent::ToolActivity { label } => Some(ReplyChunk::Progress { - progress: ReplyProgress::new( - ReplyProgressSource::ProviderNative, - ReplyProgressKind::Tool, - ReplyProgressStage::Info, - label.clone(), - ) - .with_tool_name(label) - .into(), + progress: { + let mut progress: ReplyProgressDto = ReplyProgress::new( + ReplyProgressSource::ProviderNative, + ReplyProgressKind::Tool, + ReplyProgressStage::Info, + label.clone(), + ) + .with_tool_name(label) + .into(); + progress.occurred_at_ms = at_ms; + progress + }, }), - ReplyEvent::Error { message } => Some(ReplyChunk::Error { message }), + ReplyEvent::Error { message } => Some(ReplyChunk::Error { message, at_ms }), ReplyEvent::Final { content } => { if content.trim().is_empty() { Some(ReplyChunk::Error { message: "Réponse vide du modèle.".to_owned(), + at_ms, }) } else { - Some(ReplyChunk::Final { content }) + Some(ReplyChunk::Final { content, at_ms }) } } ReplyEvent::Announcement { text } => Some(ReplyChunk::Progress { - progress: ReplyProgress::new( - ReplyProgressSource::ProviderNative, - ReplyProgressKind::Message, - ReplyProgressStage::Delta, - "message intermédiaire", - ) - .with_text(text) - .into(), + progress: { + let mut progress: ReplyProgressDto = ReplyProgress::new( + ReplyProgressSource::ProviderNative, + ReplyProgressKind::Message, + ReplyProgressStage::Delta, + "message intermédiaire", + ) + .with_text(text) + .into(); + progress.occurred_at_ms = at_ms; + progress + }, }), ReplyEvent::Heartbeat => None, ReplyEvent::RateLimited { .. } => None, @@ -432,5 +450,20 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option { pub fn unfinished_turn_chunk() -> ReplyChunk { ReplyChunk::Error { message: MODEL_RESPONSE_NOT_FINALIZED_MESSAGE.to_owned(), + at_ms: None, } } + +/// Assigns an acceptance timestamp to a transport-created chunk. +#[must_use] +pub fn with_chunk_timestamp(mut chunk: ReplyChunk, at_ms: Option) -> ReplyChunk { + match &mut chunk { + ReplyChunk::UserPrompt { at_ms: value, .. } + | ReplyChunk::TextDelta { at_ms: value, .. } + | ReplyChunk::ToolActivity { at_ms: value, .. } + | ReplyChunk::Final { at_ms: value, .. } + | ReplyChunk::Error { at_ms: value, .. } => *value = at_ms, + ReplyChunk::Progress { progress } => progress.occurred_at_ms = at_ms, + } + chunk +} diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index f9796e60..8cf30f0f 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -66,7 +66,7 @@ use crate::dto::{ MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, - ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, @@ -86,8 +86,8 @@ use crate::dto::{ UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectOrchestratorRequestDto, UpdateProjectOrchestratorResponseDto, UpdateProjectPermissionsRequestDto, - UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, - WriteTerminalRequestDto, + UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto, + UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto, WriteTerminalRequestDto, }; use crate::embedded_server::{ EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, @@ -815,6 +815,66 @@ pub async fn update_project_context( .map_err(ErrorDto::from) } +/// Reads global machine-local IdeA preferences. +#[tauri::command] +pub async fn read_user_settings(state: State<'_, AppState>) -> Result { + state + .read_user_settings + .execute() + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Replaces global machine-local IdeA preferences. +#[tauri::command] +pub async fn update_user_settings( + request: UserSettingsDto, + state: State<'_, AppState>, +) -> Result { + state + .update_user_settings + .execute(request.into()) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Reads project-owned settings without creating a legacy-missing file. +#[tauri::command] +pub async fn read_project_settings( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .read_project_settings + .execute(&project) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Validates and replaces project-owned settings. +#[tauri::command] +pub async fn update_project_settings( + request: UpdateProjectSettingsRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let theme_color = request + .theme_color + .map(domain::ThemeColor::new) + .transpose() + .map_err(|message| ErrorDto::from(AppError::Invalid(message)))?; + state + .update_project_settings + .execute(&project, domain::ProjectSettings { theme_color }) + .await + .map(Into::into) + .map_err(Into::into) +} + /// `get_project_permissions` — read `.ideai/permissions.json`. /// /// # Errors @@ -2198,7 +2258,11 @@ pub async fn stop_live_agent( } state .stop_live_agent - .execute(StopLiveAgentInput { project, agent_id }) + .execute(StopLiveAgentInput { + project, + agent_id, + reason: request.reason, + }) .await .map(StopLiveAgentResponseDto::from) .map_err(ErrorDto::from) @@ -2372,6 +2436,10 @@ pub async fn launch_agent( mcp_runtime, allow_structured_alongside_pty: false, require_structured: request.cell_kind == Some(CellKind::Chat), + terminal_interface: request.cell_kind.map(|kind| match kind { + CellKind::Pty => domain::DefaultAgentTerminalInterface::NativeTui, + CellKind::Chat => domain::DefaultAgentTerminalInterface::IdeaCli, + }), fresh_start: request.fresh_start, managed_temp_exposure: application::ManagedTempExposureRequest::default(), }) @@ -2781,6 +2849,7 @@ pub async fn agent_send( let _ = state.chat_bridge.send_output( &sid, ReplyChunk::UserPrompt { + at_ms: u64::try_from(state.clock.now_millis()).ok(), text: prompt.clone(), attachments: attachment_labels, attachment_refs, @@ -2793,6 +2862,7 @@ pub async fn agent_send( // still running. let (tap_tx, tap_rx) = std::sync::mpsc::channel(); let tap_bridge = std::sync::Arc::clone(&state.chat_bridge); + let tap_clock = std::sync::Arc::clone(&state.clock); let tap_sid = sid; let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle()); let tap_pump = std::thread::spawn(move || { @@ -2803,7 +2873,9 @@ pub async fn agent_send( guard.mark_alive(); } } - let Some(chunk) = crate::chat::chunk_from_event(event) else { + let Some(chunk) = + crate::chat::chunk_from_event_at(event, u64::try_from(tap_clock.now_millis()).ok()) + else { continue; }; if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { @@ -2842,6 +2914,7 @@ pub async fn agent_send( // `Final` event (or stream end / superseded channel), then detaches *only its // own* generation so a concurrent re-attach is never torn down. let bridge = std::sync::Arc::clone(&state.chat_bridge); + let clock = std::sync::Arc::clone(&state.clock); // Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent, host // cell and engine conversation once from the live registry; a `RateLimited` turn // event then feeds the service (détecter→planifier). Without a conversation id the @@ -2879,7 +2952,10 @@ pub async fn agent_send( let _ = bridge.send_output_settled( &sid, &mut settler, - ReplyChunk::Error { message }, + ReplyChunk::Error { + message, + at_ms: u64::try_from(clock.now_millis()).ok(), + }, ); break 'logical_turn; } @@ -2920,7 +2996,9 @@ pub async fn agent_send( } // Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire // chunk; skip them while still draining so the turn runs to its `Final`. - let Some(chunk) = crate::chat::chunk_from_event(event) else { + let Some(chunk) = + crate::chat::chunk_from_event_at(event, u64::try_from(clock.now_millis()).ok()) + else { continue; }; if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { @@ -2946,7 +3024,10 @@ pub async fn agent_send( let _ = bridge.send_output_settled( &sid, &mut settler, - ReplyChunk::Error { message }, + ReplyChunk::Error { + message, + at_ms: u64::try_from(clock.now_millis()).ok(), + }, ); } backend::StructuredHumanTurnTransition::Cancelled => { @@ -2971,6 +3052,7 @@ pub async fn agent_send( ReplyChunk::Error { message: "Le redémarrage n'a pas fourni de session de remplacement." .to_owned(), + at_ms: u64::try_from(clock.now_millis()).ok(), }, ); break; @@ -2991,6 +3073,7 @@ pub async fn agent_send( &mut settler, ReplyChunk::Error { message: error.to_string(), + at_ms: u64::try_from(clock.now_millis()).ok(), }, ); break; @@ -2998,7 +3081,10 @@ pub async fn agent_send( } } if !terminal_visible { - let chunk = crate::chat::unfinished_turn_chunk(); + let chunk = crate::chat::with_chunk_timestamp( + crate::chat::unfinished_turn_chunk(), + u64::try_from(clock.now_millis()).ok(), + ); let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } if let Some(guard) = &turn_guard { @@ -3421,7 +3507,11 @@ pub async fn cancel_agent_chat( } state .stop_live_agent - .execute(StopLiveAgentInput { project, agent_id }) + .execute(StopLiveAgentInput { + project, + agent_id, + reason: Some("fermeture de la session chat".to_owned()), + }) .await .map(|_| ()) .map_err(ErrorDto::from) diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 074da388..be909afa 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -263,6 +263,10 @@ pub fn run() { commands::list_projects, commands::read_project_context, commands::update_project_context, + commands::read_user_settings, + commands::update_user_settings, + commands::read_project_settings, + commands::update_project_settings, commands::get_project_permissions, commands::update_project_permissions, commands::update_agent_permissions, diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index 7ef65da1..5a00e0dc 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -48,17 +48,22 @@ fn sid() -> SessionId { } fn delta(s: &str) -> ReplyChunk { - ReplyChunk::TextDelta { text: s.to_owned() } + ReplyChunk::TextDelta { + at_ms: None, + text: s.to_owned(), + } } fn final_chunk(s: &str) -> ReplyChunk { ReplyChunk::Final { + at_ms: None, content: s.to_owned(), } } fn error_chunk(s: &str) -> ReplyChunk { ReplyChunk::Error { + at_ms: None, message: s.to_owned(), } } @@ -70,6 +75,7 @@ fn progress_chunk( ) -> ReplyChunk { ReplyChunk::Progress { progress: ReplyProgressDto { + occurred_at_ms: None, progress_id: None, source: ReplyProgressSourceDto::ProviderNative, kind, @@ -91,6 +97,7 @@ fn progress_chunk_with_id( ) -> ReplyChunk { ReplyChunk::Progress { progress: ReplyProgressDto { + occurred_at_ms: None, progress_id: Some(progress_id.to_owned()), source: ReplyProgressSourceDto::ProviderNative, kind, @@ -107,6 +114,7 @@ fn progress_chunk_with_id( fn chunk_bytes(chunk: &ReplyChunk) -> usize { match chunk { ReplyChunk::UserPrompt { + at_ms: _, text, attachments, attachment_refs, @@ -123,13 +131,13 @@ fn chunk_bytes(chunk: &ReplyChunk) -> usize { }) .sum::() } - ReplyChunk::TextDelta { text } => text.len(), - ReplyChunk::ToolActivity { label } => label.len(), + ReplyChunk::TextDelta { text, .. } => text.len(), + ReplyChunk::ToolActivity { label, .. } => label.len(), ReplyChunk::Progress { progress } => { progress.label.len() + progress.text.as_ref().map_or(0, String::len) } - ReplyChunk::Final { content } => content.len(), - ReplyChunk::Error { message } => message.len(), + ReplyChunk::Final { content, .. } => content.len(), + ReplyChunk::Error { message, .. } => message.len(), } } @@ -137,11 +145,32 @@ fn chunk_bytes(chunk: &ReplyChunk) -> usize { // chunk_from_event — exhaustive mapping (zone 6) // --------------------------------------------------------------------------- +#[test] +fn accepted_reply_events_keep_the_backend_timestamp() { + let chunk = app_tauri_lib::chat::chunk_from_event_at( + ReplyEvent::Final { + content: "done".to_owned(), + }, + Some(42_424), + ) + .unwrap(); + assert_eq!( + chunk, + ReplyChunk::Final { + at_ms: Some(42_424), + content: "done".to_owned() + } + ); +} + #[test] fn chunk_from_event_maps_text_delta() { assert_eq!( chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }), - Some(ReplyChunk::TextDelta { text: "hi".into() }) + Some(ReplyChunk::TextDelta { + at_ms: None, + text: "hi".into() + }) ); } @@ -202,6 +231,7 @@ fn chunk_from_event_maps_final() { content: "done".into() }), Some(ReplyChunk::Final { + at_ms: None, content: "done".into() }) ); @@ -236,7 +266,10 @@ fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec { ReplyChunk::ToolActivity { + at_ms: None, label: progress.label, } } @@ -499,6 +536,7 @@ fn scrollback_accumulates_every_routed_chunk_in_order() { vec![ delta("x"), ReplyChunk::ToolActivity { + at_ms: None, label: "runs".into() }, final_chunk("x"), @@ -756,15 +794,15 @@ fn scrollback_drops_oldest_whole_chunks_when_byte_cap_is_exceeded() { assert_eq!(scrollback.len(), 4); assert!(matches!( scrollback.first(), - Some(ReplyChunk::TextDelta { text }) if text.starts_with("02:") + Some(ReplyChunk::TextDelta { text, .. }) if text.starts_with("02:") )); assert!(matches!( scrollback.last(), - Some(ReplyChunk::TextDelta { text }) if text.starts_with("05:") + Some(ReplyChunk::TextDelta { text, .. }) if text.starts_with("05:") )); assert!(scrollback.iter().all(|chunk| matches!( chunk, - ReplyChunk::TextDelta { text } if text.len() == chunk_size + ReplyChunk::TextDelta { text, .. } if text.len() == chunk_size ))); } @@ -781,6 +819,7 @@ fn truncated_scrollback_replays_valid_unmodified_chunks_in_order() { bridge.send_output( &session, ReplyChunk::ToolActivity { + at_ms: None, label: "tool".into(), }, ); @@ -793,6 +832,7 @@ fn truncated_scrollback_replays_valid_unmodified_chunks_in_order() { &scrollback[(MAX_CHAT_SCROLLBACK_CHUNKS - 2)..], &[ ReplyChunk::ToolActivity { + at_ms: None, label: "tool".into() }, final_chunk("done") diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 63436af9..0547f437 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -444,7 +444,7 @@ fn project_work_state_dto_serialises_conversations_camelcase() { source: TicketWorkSource::Agent { agent_id: requester, }, - at_ms: 1_700, + at_ms: Some(1_700), text_preview: "Analyse le module".to_owned(), text_len: 17, }], @@ -715,6 +715,7 @@ fn stop_live_agent_request_deserialises_camelcase() { #[test] fn stop_live_agent_response_serialises_camelcase_with_kind() { use app_tauri_lib::dto::StopLiveAgentResponseDto; + use application::terminal::StoppedAgentRuntime; use application::{LiveSessionKind, StopLiveAgentOutput}; let agent = AgentId::from_uuid(Uuid::from_u128(11)); @@ -722,17 +723,21 @@ fn stop_live_agent_response_serialises_camelcase_with_kind() { let session = SessionId::from_uuid(Uuid::from_u128(13)); let dto = StopLiveAgentResponseDto::from(StopLiveAgentOutput { agent_id: agent, - node_id: node, - session_id: session, - kind: LiveSessionKind::Structured, - runtime_state: LiveRuntimeState::Stopped, + stopped: vec![StoppedAgentRuntime { + agent_id: agent, + node_id: node, + session_id: session, + kind: LiveSessionKind::Structured, + state: LiveRuntimeState::Stopped, + }], + already_stopped: false, }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["agentId"], agent.to_string()); - assert_eq!(v["sessionId"], session.to_string()); - assert_eq!(v["kind"], "structured"); - assert_eq!(v["runtimeState"], "stopped"); - assert!(v.get("nodeId").is_none()); + assert_eq!(v["stopped"][0]["sessionId"], session.to_string()); + assert_eq!(v["stopped"][0]["kind"], "structured"); + assert_eq!(v["stopped"][0]["runtimeState"], "stopped"); + assert_eq!(v["alreadyStopped"], false); assert!(v.get("session_id").is_none()); } diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index d0314d7b..ac82c7d1 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -26,17 +26,22 @@ use uuid::Uuid; #[test] fn reply_chunk_user_prompt_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::UserPrompt { + at_ms: None, text: "run tests".into(), attachments: Vec::new(), attachment_refs: Vec::new(), }) .unwrap(); - assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" })); + assert_eq!( + v, + json!({ "kind": "userPrompt", "text": "run tests", "atMs": null }) + ); } #[test] fn reply_chunk_user_prompt_serialises_attachment_labels() { let v = serde_json::to_value(ReplyChunk::UserPrompt { + at_ms: None, text: "describe this".into(), attachments: vec!["clipboard.png".into()], attachment_refs: vec![ChatPromptAttachmentDto { @@ -52,6 +57,7 @@ fn reply_chunk_user_prompt_serialises_attachment_labels() { v, json!({ "kind": "userPrompt", + "atMs": null, "text": "describe this", "attachments": ["clipboard.png"], "attachmentRefs": [{ @@ -68,25 +74,50 @@ fn reply_chunk_user_prompt_serialises_attachment_labels() { #[test] fn reply_chunk_text_delta_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::TextDelta { + at_ms: None, text: "hello".into(), }) .unwrap(); - assert_eq!(v, json!({ "kind": "textDelta", "text": "hello" })); + assert_eq!( + v, + json!({ "kind": "textDelta", "text": "hello", "atMs": null }) + ); +} + +#[test] +fn legacy_reply_chunk_without_timestamp_deserialises_as_none() { + let chunk: ReplyChunk = serde_json::from_value(json!({ + "kind": "final", + "content": "legacy" + })) + .unwrap(); + assert_eq!( + chunk, + ReplyChunk::Final { + at_ms: None, + content: "legacy".into() + } + ); } #[test] fn reply_chunk_tool_activity_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::ToolActivity { + at_ms: None, label: "reads file".into(), }) .unwrap(); - assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" })); + assert_eq!( + v, + json!({ "kind": "toolActivity", "label": "reads file", "atMs": null }) + ); } #[test] fn reply_chunk_progress_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::Progress { progress: ReplyProgressDto { + occurred_at_ms: None, progress_id: Some("codex:item:tool0".into()), source: ReplyProgressSourceDto::IdeaLocal, kind: ReplyProgressKindDto::Mcp, @@ -105,6 +136,7 @@ fn reply_chunk_progress_serialises_exact_camel_case() { "kind": "progress", "progress": { "source": "ideaLocal", + "occurredAtMs": null, "kind": "mcp", "progressId": "codex:item:tool0", "stage": "started", @@ -119,21 +151,26 @@ fn reply_chunk_progress_serialises_exact_camel_case() { #[test] fn reply_chunk_final_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::Final { + at_ms: None, content: "done".into(), }) .unwrap(); - assert_eq!(v, json!({ "kind": "final", "content": "done" })); + assert_eq!( + v, + json!({ "kind": "final", "content": "done", "atMs": null }) + ); } #[test] fn reply_chunk_error_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::Error { + at_ms: None, message: "Réponse vide du modèle.".into(), }) .unwrap(); assert_eq!( v, - json!({ "kind": "error", "message": "Réponse vide du modèle." }) + json!({ "kind": "error", "message": "Réponse vide du modèle.", "atMs": null }) ); } @@ -141,6 +178,7 @@ fn reply_chunk_error_serialises_exact_camel_case() { fn reply_chunk_round_trips_through_json_for_every_variant() { for chunk in [ ReplyChunk::UserPrompt { + at_ms: None, text: "u".into(), attachments: vec!["clipboard.png".into()], attachment_refs: vec![ChatPromptAttachmentDto { @@ -152,12 +190,17 @@ fn reply_chunk_round_trips_through_json_for_every_variant() { .into(), }], }, - ReplyChunk::TextDelta { text: "x".into() }, + ReplyChunk::TextDelta { + at_ms: None, + text: "x".into(), + }, ReplyChunk::ToolActivity { + at_ms: None, label: "runs".into(), }, ReplyChunk::Progress { progress: ReplyProgressDto { + occurred_at_ms: None, progress_id: None, source: ReplyProgressSourceDto::ProviderNative, kind: ReplyProgressKindDto::Turn, @@ -170,9 +213,11 @@ fn reply_chunk_round_trips_through_json_for_every_variant() { }, }, ReplyChunk::Final { + at_ms: None, content: "y".into(), }, ReplyChunk::Error { + at_ms: None, message: "empty".into(), }, ] { @@ -190,6 +235,7 @@ fn reply_chunk_deserialises_from_camel_case_wire_payload() { assert_eq!( back, ReplyChunk::UserPrompt { + at_ms: None, text: "hi".into(), attachments: Vec::new(), attachment_refs: Vec::new() @@ -366,12 +412,17 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { conversation_id: Some("conv-1".into()), scrollback: vec![ ReplyChunk::UserPrompt { + at_ms: None, text: "Hi".into(), attachments: Vec::new(), attachment_refs: Vec::new(), }, - ReplyChunk::TextDelta { text: "Hi".into() }, + ReplyChunk::TextDelta { + at_ms: None, + text: "Hi".into(), + }, ReplyChunk::Final { + at_ms: None, content: "Hi".into(), }, ], @@ -383,9 +434,9 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { "sessionId": "sess-1", "conversationId": "conv-1", "scrollback": [ - { "kind": "userPrompt", "text": "Hi" }, - { "kind": "textDelta", "text": "Hi" }, - { "kind": "final", "content": "Hi" }, + { "kind": "userPrompt", "text": "Hi", "atMs": null }, + { "kind": "textDelta", "text": "Hi", "atMs": null }, + { "kind": "final", "content": "Hi", "atMs": null }, ], }) ); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index faefe20e..8b64276f 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -19,6 +19,7 @@ use domain::ports::{ FileSystem, FsError, IdGenerator, LocalPath, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore, SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore, + UserSettingsStore, }; use domain::profile::{ resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, @@ -27,12 +28,12 @@ use domain::profile::{ use domain::sandbox::{compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxPlan}; use domain::{ bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, - ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions, - Handoff, HandoffStore, ManagedTempKind, ManifestEntry, MarkdownDoc, MemoryIndexEntry, - MemoryType, NetworkPolicy, NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, - PluginMcpServerSpec, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext, - ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind, - TerminalSession, HANDOFF_SUMMARY_MAX_CHARS, + ContextInjection, ConversationId, ConversationParty, DefaultAgentTerminalInterface, + DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManagedTempKind, ManifestEntry, + MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy, NodeId, + OrchestrationCapabilitySnapshot, PermissionProjector, PluginMcpServerSpec, ProfileId, Project, + ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, + SessionId, SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS, }; use domain::live_state::WorkStatus; @@ -1123,6 +1124,7 @@ impl ChangeAgentProfile { mcp_runtime: None, allow_structured_alongside_pty: false, require_structured: false, + terminal_interface: None, fresh_start: false, managed_temp_exposure: ManagedTempExposureRequest::default(), }) @@ -1310,6 +1312,8 @@ pub struct LaunchAgentInput { /// /// Historical callers leave this `false`, preserving the PTY launch contract. pub require_structured: bool, + /// Explicit human terminal interface. `None` lets the global preference apply. + pub terminal_interface: Option, /// Cold restart request: ignore the cell's previous conversation id and create /// a new provider/CLI conversation for this activation. pub fresh_start: bool, @@ -1803,6 +1807,8 @@ pub struct LaunchAgent { /// Optional for legacy tests/callers; runtime composition wires the shared /// project-plugin resolver used by the capability assignment use cases. plugin_mcp: Option>, + /// Machine-local preference source, wired only on the human launcher. + user_settings: Option>, /// Explicit intent for structured profiles when structured ports are absent. structured_routing_mode: StructuredRoutingMode, } @@ -1855,6 +1861,7 @@ impl LaunchAgent { managed_temp_allocator: None, managed_temp_releaser: None, plugin_mcp: None, + user_settings: None, structured_routing_mode: StructuredRoutingMode::HumanPtyFallback, } } @@ -1866,6 +1873,13 @@ impl LaunchAgent { self } + /// Wires machine-local preferences for human-facing launches. + #[must_use] + pub fn with_user_settings(mut self, settings: Arc) -> Self { + self.user_settings = Some(settings); + self + } + /// Replaces the default native-skill resolver with the shared effective /// resolver used by runtime context injection and `idea_skill_read`. #[must_use] @@ -2279,7 +2293,16 @@ impl LaunchAgent { input.agent_id, profile.id ))); } + let preferred_interface = match input.terminal_interface { + some @ Some(_) => some, + None => match &self.user_settings { + Some(settings) => settings.load().await?.default_agent_terminal_interface, + None => None, + }, + }; let wants_structured = input.require_structured + || preferred_interface == Some(DefaultAgentTerminalInterface::IdeaCli) + && profile.structured_adapter.is_some() || matches!( self.structured_routing_mode, StructuredRoutingMode::RequireStructured diff --git a/crates/application/src/conversation/paginate.rs b/crates/application/src/conversation/paginate.rs index 4328d183..80d0da1d 100644 --- a/crates/application/src/conversation/paginate.rs +++ b/crates/application/src/conversation/paginate.rs @@ -56,7 +56,7 @@ pub struct TurnView { /// Identifiant stable du tour (sert aussi d'ancre de pagination). pub id: TurnId, /// Horodatage (epoch millisecondes). - pub at_ms: u64, + pub at_ms: Option, /// Nature du tour (invite, réponse, activité outillée). pub role: TurnRole, /// Origine (humain ou agent délégant). diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index afcdb29a..89f2940e 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -35,6 +35,7 @@ pub mod plugin; pub mod project; pub mod project_plugin; pub mod remote; +pub mod settings; pub mod skill; pub mod slash_command; pub mod sprints; @@ -219,6 +220,9 @@ pub use project_plugin::{ ProjectPluginSettingsView, SetProjectPluginEnabled, }; pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput}; +pub use settings::{ + ReadProjectSettings, ReadUserSettings, UpdateProjectSettings, UpdateUserSettings, +}; pub use skill::{ AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver, CreateSkill, CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills, @@ -275,7 +279,7 @@ pub use workstate::{ ConversationTurnWorkPreview, ConversationWorkSummary, GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState, - ReconcileLiveStateInput, StopAgentExecutionCascade, StopLiveAgent, StopLiveAgentInput, - StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput, - LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, + ReconcileLiveStateInput, StopAgentExecutionCascade, StopAgentRuntime, StopLiveAgent, + StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, + UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, }; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 02b33673..3cc2c68d 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -2744,6 +2744,7 @@ impl OrchestratorService { mcp_runtime: None, allow_structured_alongside_pty: false, require_structured: false, + terminal_interface: None, fresh_start: false, managed_temp_exposure: ManagedTempExposureRequest::default(), }) @@ -3638,6 +3639,7 @@ impl OrchestratorService { .and_then(|p| p.runtime_for(project, agent_id)), allow_structured_alongside_pty: false, require_structured: false, + terminal_interface: None, fresh_start: false, managed_temp_exposure: ManagedTempExposureRequest::default(), }) @@ -3722,6 +3724,7 @@ impl OrchestratorService { .and_then(|p| p.runtime_for(project, agent_id)), allow_structured_alongside_pty: true, require_structured: true, + terminal_interface: None, fresh_start: false, managed_temp_exposure: ManagedTempExposureRequest::default(), }) diff --git a/crates/application/src/settings.rs b/crates/application/src/settings.rs new file mode 100644 index 00000000..c1069eda --- /dev/null +++ b/crates/application/src/settings.rs @@ -0,0 +1,72 @@ +//! Read/update use cases for global and project settings. + +use crate::AppError; +use domain::{IdeaUserSettings, Project, ProjectSettings, ProjectSettingsStore, UserSettingsStore}; +use std::sync::Arc; + +/// Reads global machine-local user settings. +pub struct ReadUserSettings { + store: Arc, +} +impl ReadUserSettings { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + /// Loads the current settings without writing defaults. + pub async fn execute(&self) -> Result { + self.store.load().await.map_err(Into::into) + } +} +/// Replaces global machine-local user settings. +pub struct UpdateUserSettings { + store: Arc, +} +impl UpdateUserSettings { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + /// Persists and returns the accepted document. + pub async fn execute(&self, settings: IdeaUserSettings) -> Result { + self.store.save(&settings).await?; + Ok(settings) + } +} +/// Reads the settings owned by one project. +pub struct ReadProjectSettings { + store: Arc, +} +impl ReadProjectSettings { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + /// Loads settings for `project` without creating a missing file. + pub async fn execute(&self, project: &Project) -> Result { + self.store.load(&project.root).await.map_err(Into::into) + } +} +/// Replaces the settings owned by one project. +pub struct UpdateProjectSettings { + store: Arc, +} +impl UpdateProjectSettings { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + /// Persists and returns the accepted project settings. + pub async fn execute( + &self, + project: &Project, + settings: ProjectSettings, + ) -> Result { + self.store.save(&project.root, &settings).await?; + Ok(settings) + } +} diff --git a/crates/application/src/terminal/usecases.rs b/crates/application/src/terminal/usecases.rs index 30b13a59..f9367295 100644 --- a/crates/application/src/terminal/usecases.rs +++ b/crates/application/src/terminal/usecases.rs @@ -290,42 +290,57 @@ pub async fn stop_live_agent_runtime_in_project( close: &CloseTerminal, ) -> Result, AppError> { let mut stopped = Vec::new(); + let mut first_error = None; let snapshots = live.live_agent_snapshots_for(project.id, &agent_id); for snapshot in snapshots { live.mark_runtime_state(snapshot, LiveRuntimeState::Stopping); - match snapshot.kind { + let removed = match snapshot.kind { LiveSessionKind::Structured => { if let Some(removed) = live.structured.remove_runtime(&snapshot.session_id) { - removed - .session - .shutdown() - .await - .map_err(|e| AppError::Process(e.to_string()))?; - close + if let Err(error) = removed.session.shutdown().await { + first_error.get_or_insert_with(|| AppError::Process(error.to_string())); + } + if let Err(error) = close .release_managed_temp_handle(removed.managed_temp_handle) - .await?; + .await + { + first_error.get_or_insert(error); + } + true + } else { + false } } LiveSessionKind::Pty => { - close + if let Err(error) = close .execute(CloseTerminalInput { session_id: snapshot.session_id, }) - .await?; + .await + { + first_error.get_or_insert(error); + false + } else { + true + } } + }; + if removed { + live.mark_runtime_state(snapshot, LiveRuntimeState::Stopped); + stopped.push(StoppedAgentRuntime { + agent_id: snapshot.agent_id, + session_id: snapshot.session_id, + node_id: snapshot.node_id, + kind: snapshot.kind, + state: LiveRuntimeState::Stopped, + }); } - live.mark_runtime_state(snapshot, LiveRuntimeState::Stopped); - stopped.push(StoppedAgentRuntime { - agent_id: snapshot.agent_id, - session_id: snapshot.session_id, - node_id: snapshot.node_id, - kind: snapshot.kind, - state: LiveRuntimeState::Stopped, - }); } - - Ok(stopped) + match first_error { + Some(error) => Err(error), + None => Ok(stopped), + } } /// The platform default interactive shell. diff --git a/crates/application/src/workstate/actions.rs b/crates/application/src/workstate/actions.rs index e2f19591..180c4ce1 100644 --- a/crates/application/src/workstate/actions.rs +++ b/crates/application/src/workstate/actions.rs @@ -11,13 +11,13 @@ use std::sync::Arc; use async_trait::async_trait; use domain::input::InputMediator; +use domain::live_state::WorkStatus; use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId}; use crate::error::AppError; -use crate::orchestrator::OrchestratorService; +use crate::orchestrator::{LiveStateProvider, OrchestratorService}; use crate::terminal::{ - stop_live_agent_runtime_in_project, CloseTerminal, LiveRuntimeState, LiveSessionKind, - LiveSessions, + stop_live_agent_runtime_in_project, CloseTerminal, LiveSessionKind, LiveSessions, }; /// Input for [`AttachLiveAgent::execute`]. @@ -100,35 +100,33 @@ impl AttachLiveAgent { } } -/// Input for [`StopLiveAgent::execute`]. +/// Input for [`StopAgentRuntime::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StopLiveAgentInput { /// The owning project (boundary/symmetry; the registries are global by agent). pub project: Project, /// The agent whose live session is stopped. pub agent_id: AgentId, + /// Optional human-readable stop reason. + pub reason: Option, } /// Output of [`StopLiveAgent::execute`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StopLiveAgentOutput { /// The stopped agent. pub agent_id: AgentId, /// The id of the session that was torn down. - pub session_id: SessionId, - /// Last known layout node hosting the runtime view. - pub node_id: NodeId, - /// Runtime family that owned the session. - pub kind: LiveSessionKind, - /// Runtime lifecycle projection after the stop request. - pub runtime_state: LiveRuntimeState, + pub stopped: Vec, + /// Whether no live runtime remained to stop. + pub already_stopped: bool, } /// Best-effort stopper for execution resources owned by one live agent turn. /// /// Implementations must be idempotent: a missing, already-finished or already-stopped /// child resource is a successful no-op. Failures are deliberately not propagated by -/// [`StopLiveAgent`], because the product contract is "stop the agent; cascade related +/// [`StopAgentRuntime`], because the product contract is "stop the agent; cascade related /// work best-effort". #[async_trait] pub trait StopAgentExecutionCascade: Send + Sync { @@ -143,16 +141,17 @@ pub trait StopAgentExecutionCascade: Send + Sync { /// provider conversation/context survives the stop. A PTY session has no finer /// turn boundary, so it is torn down through the existing [`CloseTerminal`] /// primitive (registry removal + process kill). -pub struct StopLiveAgent { +pub struct StopAgentRuntime { live: Arc, close: Arc, input: Option>, waits: Option>, cascade: Option>, restart_rendezvous: Option>, + live_state: Option>, } -impl StopLiveAgent { +impl StopAgentRuntime { /// Builds the use case from the live-session registry and the close primitive. #[must_use] pub fn new(live: Arc, close: Arc) -> Self { @@ -163,9 +162,17 @@ impl StopLiveAgent { waits: None, cascade: None, restart_rendezvous: None, + live_state: None, } } + /// Wires the project-scoped live-state projection updated after a stop. + #[must_use] + pub fn with_live_state(mut self, live_state: Arc) -> Self { + self.live_state = Some(live_state); + self + } + /// Wires cancellation helpers used for user-driven stop cascades. /// /// Stopping A while A waits on B should stop B too. The orchestrator service is @@ -216,14 +223,32 @@ impl StopLiveAgent { if let Some(mediator) = &self.input { mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id)); } - if let Some(out) = self - .cancel_structured_turn_in_project(&input.project, input.agent_id) - .await? - { - return Ok(out); + let stopped = stop_live_agent_runtime_in_project( + &input.project, + input.agent_id, + &self.live, + &self.close, + ) + .await?; + if let Some(provider) = &self.live_state { + if let Some(update) = provider.live_state_for(&input.project.root) { + let _ = update + .execute(crate::UpdateLiveStateInput { + agent_id: input.agent_id, + ticket: None, + intent: input.reason.unwrap_or_else(|| "arrêté".to_owned()), + status: WorkStatus::Idle, + progress: None, + last_delegation: None, + }) + .await; + } } - self.stop_one_in_project(&input.project, input.agent_id) - .await + Ok(StopLiveAgentOutput { + agent_id: input.agent_id, + already_stopped: stopped.is_empty(), + stopped, + }) } async fn stop_dependencies( @@ -246,87 +271,11 @@ impl StopLiveAgent { if let Some(mediator) = &self.input { mediator.preempt(RuntimeAgentKey::new(project.id, dep)); } - if self - .cancel_structured_turn_in_project(project, dep) - .await? - .is_none() - { - let _ = self.stop_one_in_project(project, dep).await; - } + let _ = stop_live_agent_runtime_in_project(project, dep, &self.live, &self.close).await; } Ok(()) } - - async fn cancel_structured_turn_in_project( - &self, - project: &Project, - agent_id: AgentId, - ) -> Result, AppError> { - let Some(snapshot) = self - .live - .live_agent_snapshots_for(project.id, &agent_id) - .into_iter() - .find(|snapshot| snapshot.kind == LiveSessionKind::Structured) - else { - return Ok(None); - }; - let Some(session) = self - .live - .structured - .session_for_agent_in_project(project.id, &agent_id) - else { - return Ok(None); - }; - self.live - .mark_runtime_state(snapshot, LiveRuntimeState::Stopping); - session - .cancel_current_turn() - .await - .map_err(|err| AppError::Process(err.to_string()))?; - self.live - .mark_runtime_state(snapshot, LiveRuntimeState::Stopped); - Ok(Some(StopLiveAgentOutput { - agent_id: snapshot.agent_id, - session_id: snapshot.session_id, - node_id: snapshot.node_id, - kind: snapshot.kind, - runtime_state: LiveRuntimeState::Stopped, - })) - } - - async fn stop_one_in_project( - &self, - project: &Project, - agent_id: AgentId, - ) -> Result { - stop_live_agent_runtime_in_project(project, agent_id, &self.live, &self.close) - .await? - .into_iter() - .next() - .map(|stopped| StopLiveAgentOutput { - agent_id: stopped.agent_id, - session_id: stopped.session_id, - node_id: stopped.node_id, - kind: stopped.kind, - runtime_state: stopped.state, - }) - .or_else(|| { - self.live - .runtime_status_for(project.id, &agent_id) - .filter(|status| { - matches!( - status.state, - LiveRuntimeState::Stopping | LiveRuntimeState::Stopped - ) - }) - .map(|status| StopLiveAgentOutput { - agent_id: status.agent_id, - session_id: status.session_id, - node_id: status.node_id, - kind: status.kind, - runtime_state: status.state, - }) - }) - .ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}"))) - } } + +/// Compatibility name kept for existing driving adapters. +pub type StopLiveAgent = StopAgentRuntime; diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 13287123..6245053e 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -11,7 +11,7 @@ mod reconcile; pub use actions::{ AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopAgentExecutionCascade, - StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, + StopAgentRuntime, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, }; pub use live::{ GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput, @@ -172,7 +172,7 @@ pub struct ConversationTurnWorkPreview { /// Origin of the turn (human operator or a delegating agent). pub source: TicketWorkSource, /// Timestamp (epoch milliseconds) of the turn. - pub at_ms: u64, + pub at_ms: Option, /// Bounded, whitespace-normalised excerpt of the turn text. pub text_preview: String, /// Character length of the **original** (un-truncated) turn text. diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 7a7622e6..486b1a8f 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -1501,6 +1501,7 @@ fn launch_input_with_project(agent_id: AgentId, project: Project) -> LaunchAgent mcp_runtime: None, allow_structured_alongside_pty: false, require_structured: false, + terminal_interface: None, fresh_start: false, managed_temp_exposure: ManagedTempExposureRequest::default(), } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 59adee6a..bddab91f 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -4585,8 +4585,8 @@ async fn p6b_user_ask_records_prompt_then_response_pair() { "response is sourced from the target (architect)" ); // Clock stamp came from the injected Clock. - assert_eq!(prompt.at_ms, 123); - assert_eq!(response.at_ms, 123); + assert_eq!(prompt.at_ms, Some(123)); + assert_eq!(response.at_ms, Some(123)); } /// Round-trip with an **agent** requester (A) resolves the `A↔B` conversation and the diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index cb55fad8..6643554b 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -40,13 +40,16 @@ use domain::ports::{ ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, - RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, UserSettingsStore, }; use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; -use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId}; +use domain::{ + DefaultAgentTerminalInterface, IdeaUserSettings, MemoryIndexEntry, NodeId, PtySize, SessionId, + SessionKind, SkillId, +}; use uuid::Uuid; use application::{ @@ -772,6 +775,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput { mcp_runtime: None, allow_structured_alongside_pty: false, require_structured: false, + terminal_interface: None, fresh_start: false, managed_temp_exposure: ManagedTempExposureRequest::default(), } @@ -858,10 +862,103 @@ fn launch_fixture_with_mode( } } +struct FixedUserSettings(IdeaUserSettings); + +#[async_trait] +impl UserSettingsStore for FixedUserSettings { + async fn load(&self) -> Result { + Ok(self.0.clone()) + } + async fn save(&self, _settings: &IdeaUserSettings) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Clone)] +struct MutableUserSettings(Arc>); + +#[async_trait] +impl UserSettingsStore for MutableUserSettings { + async fn load(&self) -> Result { + Ok(self.0.lock().unwrap().clone()) + } + async fn save(&self, settings: &IdeaUserSettings) -> Result<(), StoreError> { + *self.0.lock().unwrap() = settings.clone(); + Ok(()) + } +} + +fn launch_fixture_with_preference(interface: DefaultAgentTerminalInterface) -> LaunchFixture { + let mut fixture = launch_fixture_with_mode( + structured_profile(pid(9)), + FakeFactory::new(500, Some("engine-conv")), + StructuredRoutingMode::HumanPtyFallback, + ); + let launch = Arc::try_unwrap(fixture.launch) + .ok() + .expect("fixture owns its launcher") + .with_user_settings(Arc::new(FixedUserSettings(IdeaUserSettings { + default_agent_terminal_interface: Some(interface), + }))); + fixture.launch = Arc::new(launch); + fixture +} + // =========================================================================== // 1. Routage LaunchAgent : chemin structuré // =========================================================================== +#[tokio::test] +async fn global_idea_cli_preference_applies_to_a_new_human_launch() { + let f = launch_fixture_with_preference(DefaultAgentTerminalInterface::IdeaCli); + let out = f.launch.execute(launch_input(f.agent.id)).await.unwrap(); + assert!(out.structured.is_some()); + assert_eq!(f.factory.start_count(), 1); + assert_eq!(f.pty.spawn_count(), 0); +} + +#[tokio::test] +async fn explicit_native_tui_overrides_the_global_idea_cli_preference() { + let f = launch_fixture_with_preference(DefaultAgentTerminalInterface::IdeaCli); + let mut input = launch_input(f.agent.id); + input.terminal_interface = Some(DefaultAgentTerminalInterface::NativeTui); + let out = f.launch.execute(input).await.unwrap(); + assert!(out.structured.is_none()); + assert_eq!(f.factory.start_count(), 0); + assert_eq!(f.pty.spawn_count(), 1); +} + +#[tokio::test] +async fn changing_global_preference_does_not_replace_an_existing_terminal() { + let mut fixture = launch_fixture_with_mode( + structured_profile(pid(9)), + FakeFactory::new(500, Some("engine-conv")), + StructuredRoutingMode::HumanPtyFallback, + ); + let settings = MutableUserSettings(Arc::new(Mutex::new(IdeaUserSettings::default()))); + let launch = Arc::try_unwrap(fixture.launch) + .ok() + .unwrap() + .with_user_settings(Arc::new(settings.clone())); + fixture.launch = Arc::new(launch); + + let mut input = launch_input(fixture.agent.id); + input.node_id = Some(nid(3)); + let first = fixture.launch.execute(input.clone()).await.unwrap(); + assert!(first.structured.is_none()); + settings + .save(&IdeaUserSettings { + default_agent_terminal_interface: Some(DefaultAgentTerminalInterface::IdeaCli), + }) + .await + .unwrap(); + + let second = fixture.launch.execute(input).await.unwrap(); + assert_eq!(second.session.id, first.session.id); + assert_eq!(fixture.pty.spawn_count(), 1); + assert_eq!(fixture.factory.start_count(), 0); +} + /// Profil structuré + factory câblée ⇒ `factory.start` appelé une fois, session /// enregistrée dans `StructuredSessions`, AUCUN `pty.spawn`, `AgentLaunched` publié, /// `output.structured = Some(descriptor)` avec les bons champs. diff --git a/crates/application/tests/workstate_actions.rs b/crates/application/tests/workstate_actions.rs index 38e84849..5411fab8 100644 --- a/crates/application/tests/workstate_actions.rs +++ b/crates/application/tests/workstate_actions.rs @@ -330,15 +330,17 @@ async fn stop_pty_kills_and_removes_session() { .execute(StopLiveAgentInput { project: project(), agent_id: a, + reason: None, }) .await .unwrap(); assert_eq!(out.agent_id, a); - assert_eq!(out.session_id, sid(1)); - assert_eq!(out.node_id, nid(100)); - assert_eq!(out.kind, LiveSessionKind::Pty); - assert_eq!(out.runtime_state, LiveRuntimeState::Stopped); + assert!(!out.already_stopped); + assert_eq!(out.stopped[0].session_id, sid(1)); + assert_eq!(out.stopped[0].node_id, nid(100)); + assert_eq!(out.stopped[0].kind, LiveSessionKind::Pty); + assert_eq!(out.stopped[0].state, LiveRuntimeState::Stopped); // Delegated to the close primitive: process killed and registry emptied. assert_eq!(f.pty_port.kills(), vec![sid(1)]); assert!(f.pty.is_empty(), "live session removed from the registry"); @@ -346,7 +348,7 @@ async fn stop_pty_kills_and_removes_session() { } #[tokio::test] -async fn stop_structured_cancels_turn_and_keeps_session() { +async fn stop_structured_shuts_down_and_removes_session() { let f = fixture(); let a = aid(10); let (shutdown_called, cancel_called) = insert_structured(&f.structured, sid(2), a, nid(100)); @@ -356,26 +358,24 @@ async fn stop_structured_cancels_turn_and_keeps_session() { .execute(StopLiveAgentInput { project: project(), agent_id: a, + reason: None, }) .await .unwrap(); - assert_eq!(out.session_id, sid(2)); - assert_eq!(out.kind, LiveSessionKind::Structured); - assert_eq!(out.runtime_state, LiveRuntimeState::Stopped); + assert_eq!(out.stopped[0].session_id, sid(2)); + assert_eq!(out.stopped[0].kind, LiveSessionKind::Structured); + assert_eq!(out.stopped[0].state, LiveRuntimeState::Stopped); + assert!(!cancel_called.load(Ordering::SeqCst)); assert!( - cancel_called.load(Ordering::SeqCst), - "session.cancel_current_turn() was called" + shutdown_called.load(Ordering::SeqCst), + "session.shutdown() must be called" ); - assert!( - !shutdown_called.load(Ordering::SeqCst), - "session.shutdown() must not be called" - ); - assert!(!f.structured.is_empty(), "live session is preserved"); + assert!(f.structured.is_empty(), "live session is removed"); assert_eq!( f.structured .session_id_for_agent_in_project(project().id, &a), - Some(sid(2)) + None ); // No PTY was touched. assert!(f.pty_port.kills().is_empty()); @@ -395,21 +395,46 @@ async fn stop_structured_runs_execution_cascade_best_effort() { .execute(StopLiveAgentInput { project: project(), agent_id: a, + reason: None, }) .await .unwrap(); - assert_eq!(out.session_id, sid(2)); + assert_eq!(out.stopped[0].session_id, sid(2)); assert_eq!(*cascade.calls.lock().unwrap(), vec![(project().id, a)]); assert!( - cancel_called.load(Ordering::SeqCst), - "session.cancel_current_turn() was called" + !cancel_called.load(Ordering::SeqCst), + "session.cancel_current_turn() is not the runtime stop primitive" ); assert!( - !shutdown_called.load(Ordering::SeqCst), - "session.shutdown() must not be called" + shutdown_called.load(Ordering::SeqCst), + "session.shutdown() must be called" ); - assert!(!f.structured.is_empty(), "live session is preserved"); + assert!(f.structured.is_empty(), "live session is removed"); +} + +#[tokio::test] +async fn stop_removes_every_runtime_for_the_agent_across_registries() { + let f = fixture(); + let agent = aid(10); + insert_pty(&f.pty, sid(1), agent, nid(100)); + let (shutdown_called, _) = insert_structured(&f.structured, sid(2), agent, nid(200)); + + let out = f + .stop + .execute(StopLiveAgentInput { + project: project(), + agent_id: agent, + reason: Some("user requested".to_owned()), + }) + .await + .unwrap(); + + assert_eq!(out.stopped.len(), 2); + assert!(f.pty.is_empty()); + assert!(f.structured.is_empty()); + assert_eq!(f.pty_port.kills(), vec![sid(1)]); + assert!(shutdown_called.load(Ordering::SeqCst)); } #[tokio::test] @@ -423,6 +448,7 @@ async fn stop_pty_is_idempotent_after_success() { .execute(StopLiveAgentInput { project: project(), agent_id: a, + reason: None, }) .await .unwrap(); @@ -431,31 +457,31 @@ async fn stop_pty_is_idempotent_after_success() { .execute(StopLiveAgentInput { project: project(), agent_id: a, + reason: None, }) .await .unwrap(); - assert_eq!(first, second); - assert_eq!(second.runtime_state, LiveRuntimeState::Stopped); + assert!(!first.already_stopped); + assert!(second.already_stopped); + assert!(second.stopped.is_empty()); assert_eq!(f.pty_port.kills(), vec![sid(1)], "kill is not repeated"); } #[tokio::test] -async fn stop_unknown_agent_is_not_found() { +async fn stop_unknown_agent_is_idempotent_success() { let f = fixture(); - let err = f + let out = f .stop .execute(StopLiveAgentInput { project: project(), agent_id: aid(999), + reason: None, }) .await - .expect_err("absent agent ⇒ NOT_FOUND"); - - assert!( - matches!(err, application::AppError::NotFound(_)), - "got {err:?}" - ); + .expect("absent agent is an idempotent success"); + assert!(out.already_stopped); + assert!(out.stopped.is_empty()); assert!(f.pty_port.kills().is_empty(), "nothing killed"); } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 609e407b..a0bb60e6 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3252,6 +3252,59 @@ pub struct UpdateProjectContextRequestDto { pub content: String, } +/// Global machine-local user settings wire contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSettingsDto { + /// Default interface for newly opened agent terminals. + #[serde(default)] + pub default_agent_terminal_interface: Option, +} + +impl From for UserSettingsDto { + fn from(value: domain::IdeaUserSettings) -> Self { + Self { + default_agent_terminal_interface: value.default_agent_terminal_interface, + } + } +} + +impl From for domain::IdeaUserSettings { + fn from(value: UserSettingsDto) -> Self { + Self { + default_agent_terminal_interface: value.default_agent_terminal_interface, + } + } +} + +/// Project-owned settings wire contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSettingsDto { + /// Optional validated project theme colour. + #[serde(default)] + pub theme_color: Option, +} + +impl From for ProjectSettingsDto { + fn from(value: domain::ProjectSettings) -> Self { + Self { + theme_color: value.theme_color.map(|color| color.as_str().to_owned()), + } + } +} + +/// Project settings update request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectSettingsRequestDto { + /// Project registry id. + pub project_id: String, + /// New colour, or null to restore the product default. + #[serde(default)] + pub theme_color: Option, +} + /// Request DTO for `launch_agent`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -3747,6 +3800,9 @@ impl From for ReplyProgressStageDto { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReplyProgressDto { + /// Time at which the backend accepted this visible event. + #[serde(default)] + pub occurred_at_ms: Option, /// Optional stable progress entity id for frontend upsert. #[serde(skip_serializing_if = "Option::is_none")] pub progress_id: Option, @@ -3775,6 +3831,7 @@ pub struct ReplyProgressDto { impl From for ReplyProgressDto { fn from(value: ReplyProgress) -> Self { Self { + occurred_at_ms: None, progress_id: value.progress_id, source: value.source.into(), kind: value.kind.into(), @@ -3804,6 +3861,9 @@ pub enum ReplyChunk { /// The user's submitted prompt, retained in live scrollback for reattach. #[serde(rename_all = "camelCase")] UserPrompt { + /// Time at which the backend accepted this prompt. + #[serde(default)] + at_ms: Option, /// Submitted prompt text. text: String, /// Display labels for attachments submitted with the prompt. @@ -3816,12 +3876,18 @@ pub enum ReplyChunk { /// An assistant text fragment (incremental chat rendering). #[serde(rename_all = "camelCase")] TextDelta { + /// Time at which the backend accepted this fragment. + #[serde(default)] + at_ms: Option, /// The text fragment. text: String, }, /// A human-readable tool-activity badge (best-effort observability). #[serde(rename_all = "camelCase")] ToolActivity { + /// Time at which the backend accepted this activity. + #[serde(default)] + at_ms: Option, /// The human-readable activity label. label: String, }, @@ -3834,12 +3900,18 @@ pub enum ReplyChunk { /// The deterministic end-of-turn chunk carrying the aggregated final content. #[serde(rename_all = "camelCase")] Final { + /// Time at which the backend accepted the terminal response. + #[serde(default)] + at_ms: Option, /// The aggregated final content of the turn. content: String, }, /// A visible terminal fallback when the model stream produced no usable answer. #[serde(rename_all = "camelCase")] Error { + /// Time at which the backend accepted the terminal error. + #[serde(default)] + at_ms: Option, /// Human-readable explanation to render in the chat cell. message: String, }, @@ -4210,7 +4282,7 @@ pub struct ConversationTurnWorkPreviewDto { /// Origin of the turn (human operator or a delegating agent). pub source: TicketWorkSourceDto, /// Timestamp (epoch milliseconds) of the turn. - pub at_ms: u64, + pub at_ms: Option, /// Bounded excerpt of the turn text. pub text_preview: String, /// Character length of the original (un-truncated) turn text. @@ -4469,6 +4541,9 @@ pub struct StopLiveAgentRequestDto { pub project_id: String, /// Id of the running agent to stop. pub agent_id: String, + /// Optional human-readable reason for the stop. + #[serde(default)] + pub reason: Option, } /// Response DTO for `stop_live_agent`: the torn-down session's coordinates. @@ -4477,11 +4552,23 @@ pub struct StopLiveAgentRequestDto { pub struct StopLiveAgentResponseDto { /// The stopped agent's id. pub agent_id: String, - /// The id of the session that was torn down. + /// Every runtime actually torn down by this request. + pub stopped: Vec, + /// True when the operation was an idempotent no-op. + pub already_stopped: bool, +} + +/// One stopped runtime in an agent-level stop response. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StoppedAgentRuntimeDto { + /// Removed runtime session id. pub session_id: String, - /// Runtime family that owned the session (`pty`/`structured`). + /// Last hosting layout node id. + pub node_id: String, + /// Runtime family. pub kind: LiveWorkSessionKindDto, - /// Runtime lifecycle projection after the stop request. + /// Terminal lifecycle state. pub runtime_state: LiveRuntimeStateDto, } @@ -4489,9 +4576,17 @@ impl From for StopLiveAgentResponseDto { fn from(out: StopLiveAgentOutput) -> Self { Self { agent_id: out.agent_id.to_string(), - session_id: out.session_id.to_string(), - kind: out.kind.into(), - runtime_state: out.runtime_state.into(), + stopped: out + .stopped + .into_iter() + .map(|item| StoppedAgentRuntimeDto { + session_id: item.session_id.to_string(), + node_id: item.node_id.to_string(), + kind: item.kind.into(), + runtime_state: item.state.into(), + }) + .collect(), + already_stopped: out.already_stopped, } } } @@ -5552,7 +5647,7 @@ pub struct TurnViewDto { /// Stable turn id (also the pagination anchor). pub id: String, /// Timestamp (epoch milliseconds). - pub at_ms: u64, + pub at_ms: Option, /// Turn nature (prompt, response, tool activity). pub role: TurnRole, /// Origin (human or delegating agent). diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 88d22ec0..925f4766 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -44,27 +44,28 @@ use application::{ PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, - ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, ReadSkill, - ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, - ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, - RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, - ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, - ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, + ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, + ReadProjectSettings, ReadSkill, ReadTemplate, ReadUserSettings, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, + RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RecordTurn, RecordTurnProvider, + ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput, + RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, + ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, - SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, + SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, - UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSystemPermissions, - UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSettings, + UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateUserSettings, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::events::AgentRestartPhase; @@ -77,10 +78,10 @@ use domain::ports::{ McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore, - ProjectPluginSettingsStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, - ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, + ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, PtyHandle, PtyPort, + RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker, - WakeError, WakeReason, WindowStateStore, + UserSettingsStore, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -105,19 +106,19 @@ use infrastructure::{ FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, - FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, - FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, - FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, - IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, - LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, - LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, - ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, - SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, - TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore, + FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, + FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore, + Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, + LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, + ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, + TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, + ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -992,6 +993,7 @@ impl WakeSessionProvider for AppWakeSessionProvider { mcp_runtime: None, allow_structured_alongside_pty: true, require_structured: false, + terminal_interface: None, fresh_start: false, managed_temp_exposure: application::ManagedTempExposureRequest::default(), }) @@ -1102,6 +1104,11 @@ fn self_restart_launch_input( mcp_runtime, allow_structured_alongside_pty: false, require_structured: context.require_structured, + terminal_interface: Some(if context.require_structured { + domain::DefaultAgentTerminalInterface::IdeaCli + } else { + domain::DefaultAgentTerminalInterface::NativeTui + }), fresh_start: false, managed_temp_exposure: application::ManagedTempExposureRequest::default(), } @@ -1124,6 +1131,11 @@ fn automatic_resume_launch_input( mcp_runtime, allow_structured_alongside_pty: false, require_structured: context.require_structured, + terminal_interface: Some(if context.require_structured { + domain::DefaultAgentTerminalInterface::IdeaCli + } else { + domain::DefaultAgentTerminalInterface::NativeTui + }), fresh_start: false, managed_temp_exposure: application::ManagedTempExposureRequest::default(), } @@ -1835,6 +1847,8 @@ pub struct RemoveProjectResult { /// types — downstream code only sees the `Arc` held inside the use /// cases. pub struct BackendCore { + /// Shared business clock used to stamp transport events at acceptance. + pub clock: Arc, /// Trivial health use case validating the end-to-end wiring. pub health: Arc, /// List/filter the unified slash-command registry. @@ -2095,7 +2109,7 @@ pub struct BackendCore { /// Rebinds an already-running agent's live session to a visible cell (Lot D). pub attach_live_agent: Arc, /// Tears down an already-running agent's live session by agent id (Lot D). - pub stop_live_agent: Arc, + pub stop_live_agent: Arc, /// Best-effort inspection of a conversation (last topic + token indicator) /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty @@ -2103,6 +2117,14 @@ pub struct BackendCore { pub inspect_conversation: Arc, /// Project registry — used by agent commands to resolve a `Project` from an id. pub project_store: Arc, + /// Reads global machine-local preferences. + pub read_user_settings: Arc, + /// Updates global machine-local preferences. + pub update_user_settings: Arc, + /// Reads settings owned by the active project. + pub read_project_settings: Arc, + /// Updates settings owned by the active project. + pub update_project_settings: Arc, /// Read the project permission document. pub get_project_permissions: Arc, /// Update project-level default permissions. @@ -2455,6 +2477,26 @@ impl BackendCore { // Port-typed handles for injection. let fs_port = Arc::clone(&fs) as Arc; let store_port = Arc::clone(&store) as Arc; + let user_settings_store = Arc::new(FsUserSettingsStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let user_settings_store_port = + Arc::clone(&user_settings_store) as Arc; + let project_settings_store = Arc::new(FsProjectSettingsStore::new(Arc::clone(&fs_port))); + let project_settings_store_port = + Arc::clone(&project_settings_store) as Arc; + let read_user_settings = + Arc::new(ReadUserSettings::new(Arc::clone(&user_settings_store_port))); + let update_user_settings = Arc::new(UpdateUserSettings::new(Arc::clone( + &user_settings_store_port, + ))); + let read_project_settings = Arc::new(ReadProjectSettings::new(Arc::clone( + &project_settings_store_port, + ))); + let update_project_settings = Arc::new(UpdateProjectSettings::new(Arc::clone( + &project_settings_store_port, + ))); let open_project_restore_store_port = Arc::clone(&open_project_restore_store) as Arc; let window_state_port = Arc::clone(&window_state_store) as Arc; @@ -3207,6 +3249,7 @@ impl BackendCore { .with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver)) .with_plugin_mcp_resolver(Arc::clone(&project_plugin_resolver)) .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) + .with_user_settings(Arc::clone(&user_settings_store_port)) .with_permission_store(Arc::clone(&permission_store_port)) .with_system_permission_store(Arc::clone(&system_permission_store_port)) // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule @@ -4100,14 +4143,17 @@ impl BackendCore { )) as Arc); let stop_live_agent = Arc::new( - StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) + StopAgentRuntime::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) .with_cascade( Arc::clone(&input_mediator), Arc::clone(&orchestrator_service), ) .with_execution_cascade(Arc::clone(&orchestrator_service) as Arc) - .with_restart_rendezvous(Arc::clone(&restart_rendezvous)), + .with_restart_rendezvous(Arc::clone(&restart_rendezvous)) + .with_live_state(Arc::new(AppLiveStateProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc), ); // --- Windows (L10) --- @@ -4131,6 +4177,7 @@ impl BackendCore { )); Self { + clock: Arc::clone(&clock) as Arc, health, list_slash_commands, execute_slash_command, @@ -4257,6 +4304,10 @@ impl BackendCore { stop_live_agent, inspect_conversation, project_store, + read_user_settings, + update_user_settings, + read_project_settings, + update_project_settings, get_project_permissions, update_project_permissions, update_agent_permissions, diff --git a/crates/domain/src/conversation_log.rs b/crates/domain/src/conversation_log.rs index d1bf88d8..5cc95640 100644 --- a/crates/domain/src/conversation_log.rs +++ b/crates/domain/src/conversation_log.rs @@ -91,8 +91,10 @@ pub struct ConversationTurn { pub id: TurnId, /// La conversation (paire) à laquelle ce tour appartient. pub conversation: ConversationId, - /// Horodatage (epoch millisecondes) du tour. - pub at_ms: u64, + /// Horodatage (epoch millisecondes) du tour. Absent uniquement pour les + /// lignes legacy écrites avant que l'horodatage ne devienne universel. + #[serde(default)] + pub at_ms: Option, /// L'origine de l'entrée (Humain ou Agent délégant). pub source: InputSource, /// La nature du tour (invite, réponse, activité outillée). @@ -115,7 +117,7 @@ impl ConversationTurn { Self { id, conversation, - at_ms, + at_ms: Some(at_ms), source, role, text: text.into(), diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 767fbe09..319c72f9 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -67,6 +67,7 @@ pub mod readiness; pub mod remote; pub mod sandbox; pub mod session_limit; +pub mod settings; pub mod skill; pub mod slash_command; pub mod sprint; @@ -258,6 +259,8 @@ pub use sandbox::{ SandboxKind, SandboxPlan, SandboxStatus, }; +pub use settings::{DefaultAgentTerminalInterface, IdeaUserSettings, ProjectSettings, ThemeColor}; + pub use orchestrator::{ OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility, }; @@ -277,9 +280,10 @@ pub use ports::{ PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner, - ProfileStore, ProjectPluginSettingsStore, ProjectStore, ProviderModelCatalogue, PtyError, - PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, - ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, - StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, - TemplateStore, WindowStateStore, + ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, + ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, + RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore, + SprintStoreError, StoreError, StructuredSessionEnvironment, + StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, UserSettingsStore, + WindowStateStore, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index d6b7dfe8..1956d379 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -539,6 +539,31 @@ pub trait ProjectPluginSettingsStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Persistence for global, machine-local user settings. +#[async_trait] +pub trait UserSettingsStore: Send + Sync { + /// Loads settings, returning an empty document when missing. + async fn load(&self) -> Result; + /// Persists the complete document. + async fn save(&self, settings: &crate::IdeaUserSettings) -> Result<(), StoreError>; +} + +/// Persistence for settings owned by one project. +#[async_trait] +pub trait ProjectSettingsStore: Send + Sync { + /// Loads settings, returning an empty document when missing. + async fn load( + &self, + project_root: &crate::ProjectPath, + ) -> Result; + /// Persists the complete document. + async fn save( + &self, + project_root: &crate::ProjectPath, + settings: &crate::ProjectSettings, + ) -> Result<(), StoreError>; +} + /// Manifest parser and validator. pub trait PluginManifestValidator: Send + Sync { /// Validates raw manifest bytes. diff --git a/crates/domain/src/settings.rs b/crates/domain/src/settings.rs new file mode 100644 index 00000000..96e90f51 --- /dev/null +++ b/crates/domain/src/settings.rs @@ -0,0 +1,69 @@ +//! Machine-local user preferences and project-owned presentation settings. + +use serde::{Deserialize, Serialize}; + +/// Preferred interface when a new human-facing agent terminal is opened. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DefaultAgentTerminalInterface { + /// Provider's native interactive terminal UI. + NativeTui, + /// IdeA's structured/custom CLI chat surface. + IdeaCli, +} + +/// Global, machine-local IdeA preferences. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IdeaUserSettings { + /// Default for newly launched human-facing agent terminals. + #[serde(default)] + pub default_agent_terminal_interface: Option, +} + +/// Strict project theme colour (`#RRGGBB`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ThemeColor(String); + +impl ThemeColor { + /// Validates and builds a project theme colour. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let valid = value.len() == 7 + && value.starts_with('#') + && value.as_bytes()[1..].iter().all(u8::is_ascii_hexdigit); + valid + .then_some(Self(value)) + .ok_or_else(|| "themeColor must match #RRGGBB".to_owned()) + } + + /// Returns the validated wire representation. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ThemeColor { + type Error = String; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: ThemeColor) -> Self { + value.0 + } +} + +/// Settings owned by one project and stored below its `.ideai` directory. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSettings { + /// Optional visual theme colour; absence keeps the product fallback. + #[serde(default)] + pub theme_color: Option, +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 8b0ffa88..8e0f35d3 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -120,8 +120,9 @@ pub use store::{ AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore, - FsProfileStore, FsProjectStore, FsSecretStore, FsSkillStore, FsSystemPermissionStore, - FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, - OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, - RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsSecretStore, FsSkillStore, + FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore, + HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, + VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, + VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 9ff80969..470a2f09 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -17,6 +17,7 @@ mod profile; mod project; mod project_plugin; mod secrets; +mod settings; mod skill; mod system_permission; mod template; @@ -44,6 +45,7 @@ pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; pub use project_plugin::FsProjectPluginSettingsStore; pub use secrets::FsSecretStore; +pub use settings::{FsProjectSettingsStore, FsUserSettingsStore}; pub use skill::FsSkillStore; pub use system_permission::FsSystemPermissionStore; pub use template::FsTemplateStore; diff --git a/crates/infrastructure/src/store/settings.rs b/crates/infrastructure/src/store/settings.rs new file mode 100644 index 00000000..143614bf --- /dev/null +++ b/crates/infrastructure/src/store/settings.rs @@ -0,0 +1,104 @@ +//! JSON persistence for global user settings and project-owned settings. + +use async_trait::async_trait; +use domain::{ + FileSystem, FsError, IdeaUserSettings, ProjectPath, ProjectSettings, ProjectSettingsStore, + RemotePath, StoreError, UserSettingsStore, +}; +use std::sync::Arc; + +#[derive(Clone)] +/// File-backed global user-settings adapter. +pub struct FsUserSettingsStore { + fs: Arc, + app_data_dir: String, +} +impl FsUserSettingsStore { + /// Builds an adapter rooted in the machine-local application data directory. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + fn path(&self) -> RemotePath { + RemotePath::new(format!( + "{}/settings.json", + self.app_data_dir.trim_end_matches(['/', '\\']) + )) + } +} +#[async_trait] +impl UserSettingsStore for FsUserSettingsStore { + async fn load(&self) -> Result { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(FsError::NotFound(_)) => Ok(IdeaUserSettings::default()), + Err(error) => Err(StoreError::Io(error.to_string())), + } + } + async fn save(&self, settings: &IdeaUserSettings) -> Result<(), StoreError> { + self.fs + .create_dir_all(&RemotePath::new(self.app_data_dir.clone())) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let mut bytes = serde_json::to_vec_pretty(settings) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + +#[derive(Clone)] +/// File-backed project-settings adapter. +pub struct FsProjectSettingsStore { + fs: Arc, +} +impl FsProjectSettingsStore { + /// Builds an adapter over the location-neutral filesystem port. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + fn path(root: &ProjectPath) -> RemotePath { + RemotePath::new(format!( + "{}/.ideai/project-settings.json", + root.as_str().trim_end_matches(['/', '\\']) + )) + } +} +#[async_trait] +impl ProjectSettingsStore for FsProjectSettingsStore { + async fn load(&self, root: &ProjectPath) -> Result { + match self.fs.read(&Self::path(root)).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(FsError::NotFound(_)) => Ok(ProjectSettings::default()), + Err(error) => Err(StoreError::Io(error.to_string())), + } + } + async fn save(&self, root: &ProjectPath, settings: &ProjectSettings) -> Result<(), StoreError> { + let dir = RemotePath::new(format!( + "{}/.ideai", + root.as_str().trim_end_matches(['/', '\\']) + )); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let mut bytes = serde_json::to_vec_pretty(settings) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&Self::path(root), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} diff --git a/crates/infrastructure/tests/conversation_log.rs b/crates/infrastructure/tests/conversation_log.rs index 7d20ece1..0af2d9ab 100644 --- a/crates/infrastructure/tests/conversation_log.rs +++ b/crates/infrastructure/tests/conversation_log.rs @@ -133,6 +133,26 @@ async fn append_writes_one_valid_json_line_per_turn() { } } +#[tokio::test] +async fn legacy_line_without_timestamp_remains_readable_without_backfill() { + let tmp = TempDir::new(); + let c = conv_id(91); + std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap(); + let raw = format!( + "{{\"id\":\"{}\",\"conversation\":\"{}\",\"source\":{{\"kind\":\"human\"}},\"role\":\"prompt\",\"text\":\"legacy\"}}\n", + turn_id(1), c + ); + std::fs::write(tmp.log_path(c), &raw).unwrap(); + + let turns = FsConversationLog::new(&tmp.project_path()) + .read(c, None) + .await + .unwrap(); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].at_ms, None); + assert_eq!(std::fs::read_to_string(tmp.log_path(c)).unwrap(), raw); +} + // --------------------------------------------------------------------------- // survives a "restart" — a fresh instance on the same root relits everything // --------------------------------------------------------------------------- diff --git a/crates/infrastructure/tests/settings.rs b/crates/infrastructure/tests/settings.rs new file mode 100644 index 00000000..4e6fdf34 --- /dev/null +++ b/crates/infrastructure/tests/settings.rs @@ -0,0 +1,74 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use domain::{ + DefaultAgentTerminalInterface, FileSystem, IdeaUserSettings, ProjectPath, ProjectSettings, + ProjectSettingsStore, ThemeColor, UserSettingsStore, +}; +use infrastructure::{FsProjectSettingsStore, FsUserSettingsStore, LocalFileSystem}; +use uuid::Uuid; + +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("idea-settings-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn root(&self) -> ProjectPath { + ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn fs() -> Arc { + Arc::new(LocalFileSystem::new()) +} + +#[tokio::test] +async fn global_user_preference_persists_outside_project() { + let tmp = TempDir::new(); + let store = FsUserSettingsStore::new(fs(), tmp.0.to_string_lossy()); + assert_eq!(store.load().await.unwrap(), IdeaUserSettings::default()); + let settings = IdeaUserSettings { + default_agent_terminal_interface: Some(DefaultAgentTerminalInterface::IdeaCli), + }; + store.save(&settings).await.unwrap(); + assert_eq!( + FsUserSettingsStore::new(fs(), tmp.0.to_string_lossy()) + .load() + .await + .unwrap(), + settings + ); + assert!(tmp.0.join("settings.json").is_file()); + assert!(!tmp.0.join(".ideai").exists()); +} + +#[tokio::test] +async fn project_settings_missing_then_valid_round_trip() { + let tmp = TempDir::new(); + let store = FsProjectSettingsStore::new(fs()); + assert_eq!( + store.load(&tmp.root()).await.unwrap(), + ProjectSettings::default() + ); + assert!(!tmp.0.join(".ideai/project-settings.json").exists()); + let settings = ProjectSettings { + theme_color: Some(ThemeColor::new("#12AbEF").unwrap()), + }; + store.save(&tmp.root(), &settings).await.unwrap(); + assert_eq!(store.load(&tmp.root()).await.unwrap(), settings); + assert!(!tmp.0.join(".ideai/CONTEXT.md").exists()); +} + +#[test] +fn theme_color_validation_is_strict() { + assert!(ThemeColor::new("#112233").is_ok()); + for invalid in ["112233", "#123", "#GG0000", "#11223344", "# 12233"] { + assert!(ThemeColor::new(invalid).is_err(), "accepted {invalid}"); + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 5a245e0d..4cb87061 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -93,7 +93,7 @@ use backend::dto::{ MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, - ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto, RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, @@ -116,7 +116,8 @@ use backend::dto::{ UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, - UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto, + UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto, }; use backend::events::DomainEventDto; type PtyChunk = Vec; @@ -1647,6 +1648,10 @@ async fn execute_launch_agent_for_ws( mcp_runtime, allow_structured_alongside_pty: false, require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat), + terminal_interface: request.cell_kind.map(|kind| match kind { + backend::dto::CellKind::Pty => domain::DefaultAgentTerminalInterface::NativeTui, + backend::dto::CellKind::Chat => domain::DefaultAgentTerminalInterface::IdeaCli, + }), fresh_start: request.fresh_start, managed_temp_exposure: application::ManagedTempExposureRequest::default(), }) @@ -1919,6 +1924,7 @@ async fn ws_send_chat( let (tap_tx, tap_rx) = std::sync::mpsc::channel(); let tap_out = tx.clone(); + let tap_clock = Arc::clone(&state.app.clock); let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle()); let tap_pump = std::thread::spawn(move || { for event in tap_rx { @@ -1930,7 +1936,7 @@ async fn ws_send_chat( if matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }) { continue; } - let chunk = reply_event_chunk(event); + let chunk = reply_event_chunk(event, u64::try_from(tap_clock.now_millis()).ok()); if tap_out .blocking_send(ServerFrame::chat_output(None, sid, chunk)) .is_err() @@ -1978,7 +1984,10 @@ async fn ws_send_chat( break; } backend::StructuredHumanTurnTransition::Failed(message) => { - let chunk = reply_event_chunk(ReplyEvent::Error { message }); + let chunk = reply_event_chunk( + ReplyEvent::Error { message }, + u64::try_from(state.app.clock.now_millis()).ok(), + ); tx.send(ServerFrame::chat_output( (!sent_ack).then_some(frame.id.as_str()), sid, @@ -2002,7 +2011,7 @@ async fn ws_send_chat( guard.mark_alive(); } } - let chunk = reply_event_chunk(event); + let chunk = reply_event_chunk(event, u64::try_from(state.app.clock.now_millis()).ok()); if !sent_ack { tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk)) .await @@ -2030,7 +2039,10 @@ async fn ws_send_chat( resume_prompt = Some(prompt); } backend::StructuredHumanTurnTransition::Failed(message) => { - let chunk = reply_event_chunk(ReplyEvent::Error { message }); + let chunk = reply_event_chunk( + ReplyEvent::Error { message }, + u64::try_from(state.app.clock.now_millis()).ok(), + ); tx.send(ServerFrame::chat_output( (!sent_ack).then_some(frame.id.as_str()), sid, @@ -2076,7 +2088,7 @@ async fn ws_send_chat( tx.send(ServerFrame::chat_output( Some(&frame.id), sid, - json!({ "kind": "final", "content": "" }), + json!({ "kind": "final", "content": "", "atMs": u64::try_from(state.app.clock.now_millis()).ok() }), )) .await .map_err(|_| ErrorDto { @@ -2112,16 +2124,20 @@ fn ws_detach_chat( Ok(()) } -fn reply_event_chunk(event: ReplyEvent) -> Value { +fn reply_event_chunk(event: ReplyEvent, occurred_at_ms: Option) -> Value { match event { - ReplyEvent::TextDelta { text } => json!({ "kind": "textDelta", "text": text }), + ReplyEvent::TextDelta { text } => { + json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms }) + } ReplyEvent::ToolActivity { label } => { - json!({ "kind": "toolActivity", "label": label }) + json!({ "kind": "toolActivity", "label": label, "atMs": occurred_at_ms }) } ReplyEvent::Progress { progress } => { - json!({ "kind": "progress", "progress": reply_progress_value(progress) }) + json!({ "kind": "progress", "progress": reply_progress_value(progress, occurred_at_ms) }) + } + ReplyEvent::Announcement { text } => { + json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms }) } - ReplyEvent::Announcement { text } => json!({ "kind": "textDelta", "text": text }), ReplyEvent::Heartbeat => json!({ "kind": "progress", "progress": { @@ -2129,6 +2145,7 @@ fn reply_event_chunk(event: ReplyEvent) -> Value { "kind": "other", "stage": "info", "label": "heartbeat" + ,"occurredAtMs": occurred_at_ms } }), ReplyEvent::RateLimited { resets_at_ms } => json!({ @@ -2138,15 +2155,20 @@ fn reply_event_chunk(event: ReplyEvent) -> Value { "kind": "turn", "stage": "info", "label": "rate_limited", - "occurredAtMs": resets_at_ms + "occurredAtMs": occurred_at_ms, + "resetsAtMs": resets_at_ms } }), - ReplyEvent::Error { message } => json!({ "kind": "error", "message": message }), - ReplyEvent::Final { content } => json!({ "kind": "final", "content": content }), + ReplyEvent::Error { message } => { + json!({ "kind": "error", "message": message, "atMs": occurred_at_ms }) + } + ReplyEvent::Final { content } => { + json!({ "kind": "final", "content": content, "atMs": occurred_at_ms }) + } } } -fn reply_progress_value(progress: ReplyProgress) -> Value { +fn reply_progress_value(progress: ReplyProgress, occurred_at_ms: Option) -> Value { json!({ "progressId": progress.progress_id, "source": reply_progress_source(progress.source), @@ -2157,6 +2179,7 @@ fn reply_progress_value(progress: ReplyProgress) -> Value { "provider": progress.provider, "nativeEvent": progress.native_event, "toolName": progress.tool_name, + "occurredAtMs": occurred_at_ms, }) } @@ -3370,6 +3393,12 @@ async fn invoke( "set_active_layout" => invoke_set_active_layout(&request.args, &state.app).await, "read_project_context" => invoke_read_project_context(&request.args, &state.app).await, "update_project_context" => invoke_update_project_context(&request.args, &state.app).await, + "read_user_settings" => invoke_read_user_settings(&state.app).await, + "update_user_settings" => invoke_update_user_settings(&request.args, &state.app).await, + "read_project_settings" => invoke_read_project_settings(&request.args, &state.app).await, + "update_project_settings" => { + invoke_update_project_settings(&request.args, &state.app).await + } "first_run_state" => invoke_first_run_state(&state.app).await, "reference_profiles" => invoke_reference_profiles(&state.app).await, "detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await, @@ -3803,6 +3832,66 @@ async fn invoke_update_project_context( Ok(Value::Null) } +async fn invoke_read_user_settings(state: &BackendCore) -> Result { + let output: UserSettingsDto = state + .read_user_settings + .execute() + .await + .map_err(ErrorDto::from)? + .into(); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_user_settings(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_user_settings", args)?; + let output: UserSettingsDto = state + .update_user_settings + .execute(request.into()) + .await + .map_err(ErrorDto::from)? + .into(); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_read_project_settings( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "read_project_settings")?, + state, + ) + .await?; + let output: ProjectSettingsDto = state + .read_project_settings + .execute(&project) + .await + .map_err(ErrorDto::from)? + .into(); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_settings( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_project_settings", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let theme_color = request + .theme_color + .map(domain::ThemeColor::new) + .transpose() + .map_err(|message| ErrorDto::from(AppError::Invalid(message)))?; + let output: ProjectSettingsDto = state + .update_project_settings + .execute(&project, domain::ProjectSettings { theme_color }) + .await + .map_err(ErrorDto::from)? + .into(); + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_first_run_state(state: &BackendCore) -> Result { let output = state .first_run_state @@ -3991,6 +4080,7 @@ async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result { it("stop_live_agent wraps project and agent in the request DTO", async () => { invoke.mockResolvedValueOnce({ agentId: "agent-2", - sessionId: "session-4", - kind: "pty", + stopped: [{ + sessionId: "session-4", + nodeId: "node-3", + kind: "pty", + runtimeState: "stopped", + }], + alreadyStopped: false, }); const out = await new TauriAgentGateway().stopLiveAgent("proj-1", "agent-2"); expect(invoke).toHaveBeenCalledWith("stop_live_agent", { request: { projectId: "proj-1", agentId: "agent-2" }, }); - expect(out.sessionId).toBe("session-4"); + expect(out.stopped[0]?.sessionId).toBe("session-4"); }); it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => { diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index b11b4525..d650a53d 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -98,9 +98,9 @@ export class TauriAgentGateway implements AgentGateway { }); } - stopLiveAgent(projectId: string, agentId: string): Promise { + stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise { return invoke("stop_live_agent", { - request: { projectId, agentId }, + request: { projectId, agentId, ...(reason ? { reason } : {}) }, }); } diff --git a/frontend/src/adapters/conversationNormalization.ts b/frontend/src/adapters/conversationNormalization.ts index 4374a7d3..3d283bd5 100644 --- a/frontend/src/adapters/conversationNormalization.ts +++ b/frontend/src/adapters/conversationNormalization.ts @@ -47,7 +47,7 @@ function normalizeTurn(value: unknown, index: number): TurnView { const text = stringValue(turn.text); return { id: stringValue(turn.id, `turn-${index}`), - atMs: numberValue(turn.atMs, index), + atMs: typeof turn.atMs === "number" && Number.isFinite(turn.atMs) ? turn.atMs : null, role: normalizeRole(turn.role), source: normalizeSource(turn.source), text, diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 750d56c5..b982f543 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -14,7 +14,7 @@ * error or an inert stub, never a Tauri call. */ -import type { Gateways } from "@/ports"; +import type { Gateways, UserSettings, UserSettingsGateway } from "@/ports"; import { LocalStorageUiPreferencesGateway } from "../uiPreferences"; import { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; @@ -146,6 +146,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway focusedProject: new WebFocusedProjectGateway(), // Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is. uiPreferences: new LocalStorageUiPreferencesGateway(), + userSettings: new HttpUserSettingsGateway(http), plugin: new WebPluginGateway(), pluginWorkspace: new WebPluginWorkspaceGateway(), pluginTask: new WebPluginTaskGateway(), @@ -156,6 +157,18 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway }; } +class HttpUserSettingsGateway implements UserSettingsGateway { + constructor(private readonly http: HttpInvoker) {} + + readUserSettings(): Promise { + return this.http.invoke("read_user_settings"); + } + + updateUserSettings(settings: UserSettings): Promise { + return this.http.invoke("update_user_settings", { request: settings }); + } +} + export { HttpInvoker } from "./httpInvoker"; export { WsLiveClient } from "./wsLiveClient"; export { WebSession, getWebSession, setWebSessionForTests } from "./webSession"; diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 80f15c80..27dbdc18 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -73,6 +73,7 @@ import type { PermissionGateway, ProfileGateway, ProjectGateway, + ProjectSettings, SaveOpenCodeProviderProfileInput, SkillGateway, TemplateGateway, @@ -134,6 +135,14 @@ export class HttpProjectGateway implements ProjectGateway { async updateProjectContext(projectId: string, content: string): Promise { await this.http.invoke("update_project_context", { request: { projectId, content } }); } + readProjectSettings(projectId: string): Promise { + return this.http.invoke("read_project_settings", { projectId }); + } + updateProjectSettings(projectId: string, settings: ProjectSettings): Promise { + return this.http.invoke("update_project_settings", { + request: { projectId, themeColor: settings.themeColor }, + }); + } listProjectFiles(projectId: string, path: string): Promise { return this.http.request( "GET", diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 97dcb98b..e445d88f 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -216,8 +216,10 @@ export class HttpAgentGateway implements AgentGateway { attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise { return this.http.invoke("attach_live_agent", { request: { projectId, agentId, nodeId } }); } - stopLiveAgent(projectId: string, agentId: string): Promise { - return this.http.invoke("stop_live_agent", { request: { projectId, agentId } }); + stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise { + return this.http.invoke("stop_live_agent", { + request: { projectId, agentId, ...(reason ? { reason } : {}) }, + }); } createAgent(projectId: string, input: CreateAgentInput): Promise { return this.http.invoke("create_agent", { diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 8f9a7b8f..774159c6 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -34,6 +34,7 @@ import { TauriTicketGateway } from "./ticket"; import { TauriWindowGateway } from "./window"; import { TauriFocusedProjectGateway } from "./focusedProject"; import { LocalStorageUiPreferencesGateway } from "./uiPreferences"; +import { TauriUserSettingsGateway } from "./userSettings"; import { TauriPluginGateway } from "./plugin"; import { TauriPluginWorkspaceGateway } from "./pluginWorkspace"; import { TauriPluginTaskGateway } from "./pluginTask"; @@ -82,6 +83,7 @@ export function createTauriGateways(): Gateways { window: new TauriWindowGateway(), focusedProject: new TauriFocusedProjectGateway(), uiPreferences: new LocalStorageUiPreferencesGateway(), + userSettings: new TauriUserSettingsGateway(), plugin: new TauriPluginGateway(), pluginWorkspace: new TauriPluginWorkspaceGateway(), pluginTask: new TauriPluginTaskGateway(), @@ -115,6 +117,7 @@ export { TauriWindowGateway, TauriFocusedProjectGateway, LocalStorageUiPreferencesGateway, + TauriUserSettingsGateway, TauriPluginGateway, TauriPluginStorageGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9ef426f1..b3b4b0f9 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -136,6 +136,7 @@ import type { OpenTerminalOptions, ProfileGateway, ProjectGateway, + ProjectSettings, PermissionGateway, PluginConfigDocumentReadInput, PluginConfigDocumentUpdateInput, @@ -178,6 +179,8 @@ import type { CreateTicketInput, UpdateTicketInput, UiPreferencesGateway, + UserSettings, + UserSettingsGateway, ViewWindowClosed, ViewWindowSnapshot, WindowGateway, @@ -414,7 +417,6 @@ export class MockAgentGateway implements AgentGateway { /** Runtime kind per live agent (`agentId → kind`). */ private liveKindByAgent = new Map(); /** Last stopped runtime per agent for idempotent stop. */ - private stoppedByAgent = new Map(); /** Retained structured reply chunks per live chat session. */ private chatScrollback = new Map(); /** Persisted custom chat drafts keyed by project/layout/node. */ @@ -539,14 +541,9 @@ export class MockAgentGateway implements AgentGateway { } const sessionId = this.liveSessionByAgent.get(agentId); if (!sessionId) { - const stopped = this.stoppedByAgent.get(agentId); - if (stopped) return stopped; - const err: GatewayError = { - code: "NOT_FOUND", - message: `agent ${agentId} has no live session`, - }; - throw err; + return { agentId, stopped: [], alreadyStopped: true }; } + const nodeId = this.liveByAgent.get(agentId) ?? "mock-node"; const session = this.sessions.get(sessionId); if (session) session.closed = true; this.sessions.delete(sessionId); @@ -557,11 +554,14 @@ export class MockAgentGateway implements AgentGateway { this.liveKindByAgent.delete(agentId); const stopped: StoppedLiveAgent = { agentId, - sessionId, - kind, - runtimeState: "stopped", + stopped: [{ + sessionId, + nodeId, + kind, + runtimeState: "stopped", + }], + alreadyStopped: false, }; - this.stoppedByAgent.set(agentId, stopped); return stopped; } @@ -842,7 +842,6 @@ export class MockAgentGateway implements AgentGateway { this.liveByAgent.set(agentId, options.nodeId); this.liveSessionByAgent.set(agentId, sessionId); this.liveKindByAgent.set(agentId, "pty"); - this.stoppedByAgent.delete(agentId); } const clearLive = () => { if (this.liveByAgent.get(agentId) === options.nodeId) { @@ -910,7 +909,6 @@ export class MockAgentGateway implements AgentGateway { this.liveByAgent.set(agentId, options.nodeId); this.liveSessionByAgent.set(agentId, sessionId); this.liveKindByAgent.set(agentId, "structured"); - this.stoppedByAgent.delete(agentId); } return { sessionId, @@ -1251,6 +1249,7 @@ export class MockTerminalGateway implements TerminalGateway { export class MockProjectGateway implements ProjectGateway { private projects: Project[] = []; private contexts = new Map(); + private settings = new Map(); private restoreProjectIds: string[] = []; private restoreFailures = new Map(); @@ -1426,6 +1425,28 @@ export class MockProjectGateway implements ProjectGateway { this.contexts.set(projectId, content); } + async readProjectSettings(projectId: string): Promise { + if (!this.projects.some((project) => project.id === projectId)) { + throw { code: "NOT_FOUND", message: `project ${projectId} not found` }; + } + return this.settings.get(projectId) ?? { themeColor: null }; + } + + async updateProjectSettings( + projectId: string, + settings: ProjectSettings, + ): 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)) { + throw { code: "INVALID", message: "themeColor must use #RRGGBB" }; + } + const stored = { themeColor: settings.themeColor?.toUpperCase() ?? null }; + this.settings.set(projectId, stored); + return stored; + } + async listProjectFiles(_projectId: string, path: string): Promise { return { path, entries: [] }; } @@ -4838,6 +4859,7 @@ export function createMockGateways(): Gateways { window: new MockWindowGateway(), focusedProject: new MockFocusedProjectGateway(), uiPreferences: new MockUiPreferencesGateway(), + userSettings: new MockUserSettingsGateway(), plugin: new MockPluginGateway(), pluginWorkspace: new MockPluginWorkspaceGateway(), pluginTask: new MockPluginTaskGateway(), @@ -4848,5 +4870,18 @@ export function createMockGateways(): Gateways { }; } +export class MockUserSettingsGateway implements UserSettingsGateway { + private settings: UserSettings = { defaultAgentTerminalInterface: null }; + + async readUserSettings(): Promise { + return { ...this.settings }; + } + + async updateUserSettings(settings: UserSettings): Promise { + this.settings = { ...settings }; + return { ...this.settings }; + } +} + // Re-export GatewayError so tests can reference the thrown shape. export type { GatewayError }; diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index f38001cb..bacf04f9 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -42,6 +42,7 @@ describe("createMockGateways", () => { "terminal", "ticket", "uiPreferences", + "userSettings", "window", "workState", ]); diff --git a/frontend/src/adapters/project.ts b/frontend/src/adapters/project.ts index c1fff1d3..e8b9ffa5 100644 --- a/frontend/src/adapters/project.ts +++ b/frontend/src/adapters/project.ts @@ -15,7 +15,7 @@ import type { RemoveProjectOutput, RestoreOpenProjectsOutput, } from "@/domain"; -import type { ProjectGateway } from "@/ports"; +import type { ProjectGateway, ProjectSettings } from "@/ports"; export class TauriProjectGateway implements ProjectGateway { listProjects(): Promise { @@ -78,6 +78,19 @@ export class TauriProjectGateway implements ProjectGateway { }); } + readProjectSettings(projectId: string): Promise { + return invoke("read_project_settings", { projectId }); + } + + updateProjectSettings( + projectId: string, + settings: ProjectSettings, + ): Promise { + return invoke("update_project_settings", { + request: { projectId, themeColor: settings.themeColor }, + }); + } + async listProjectFiles(): Promise { throw new Error("Project files browsing is available in the web workspace only."); } diff --git a/frontend/src/adapters/userSettings.ts b/frontend/src/adapters/userSettings.ts new file mode 100644 index 00000000..a5bf807f --- /dev/null +++ b/frontend/src/adapters/userSettings.ts @@ -0,0 +1,13 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { UserSettings, UserSettingsGateway } from "@/ports"; + +export class TauriUserSettingsGateway implements UserSettingsGateway { + readUserSettings(): Promise { + return invoke("read_user_settings"); + } + + updateUserSettings(settings: UserSettings): Promise { + return invoke("update_user_settings", { request: settings }); + } +} diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index d4fbdae4..b10316d9 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -220,7 +220,7 @@ function normalizeSummaryStatus(value: unknown): ConversationPreviewStatus { return "missing"; } -function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPreview { +function normalizeTurn(value: unknown): ConversationTurnWorkPreview { const turn = isRecord(value) ? value : {}; const textPreview = stringValue(turn.textPreview); const role = @@ -230,7 +230,7 @@ function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPrevi return { role, source: normalizeSource(turn.source), - atMs: numberValue(turn.atMs, index), + atMs: nullableNumber(turn.atMs), textPreview, textLen: numberValue(turn.textLen, textPreview.length), }; diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 36c24a5b..f1ab2b2b 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -558,7 +558,7 @@ export type DomainEvent = target: string; ticketId: string; text: string; - atMs: number; + atMs: number | null; } | { type: "ptyOutput"; sessionId: string; bytes: number[] }; @@ -751,7 +751,7 @@ export type ConversationPreviewStatus = export interface ConversationTurnWorkPreview { role: "prompt" | "response" | "toolActivity"; source: TicketWorkSource; - atMs: number; + atMs: number | null; textPreview: string; textLen: number; } @@ -800,7 +800,7 @@ export type TurnSource = { kind: "human" } | { kind: "agent"; agentId: string }; /** One turn of the human transcript — full text, never truncated. */ export interface TurnView { id: string; - atMs: number; + atMs: number | null; role: TurnRole; source: TurnSource; text: string; @@ -1792,7 +1792,7 @@ export type ReplyProgressStage = "started" | "delta" | "completed" | "info"; export interface ReplyProgress { progressId?: string; - occurredAtMs?: number; + occurredAtMs?: number | null; source: ReplyProgressSource; kind: ReplyProgressKind; stage: ReplyProgressStage; @@ -1817,12 +1817,13 @@ export type ReplyChunk = text: string; attachments?: string[]; attachmentRefs?: ChatPromptAttachment[]; + atMs?: number | null; } - | { kind: "textDelta"; text: string } - | { kind: "toolActivity"; label: string } + | { kind: "textDelta"; text: string; atMs?: number | null } + | { kind: "toolActivity"; label: string; atMs?: number | null } | { kind: "progress"; progress: ReplyProgress } - | { kind: "final"; content: string } - | { kind: "error"; message: string }; + | { kind: "final"; content: string; atMs?: number | null } + | { kind: "error"; message: string; atMs?: number | null }; export type SlashCommandAvailability = | { status: "available" } diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 4f5abd6f..5a9829e5 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -847,9 +847,9 @@ describe("CustomAgentChatView", () => { })), sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { nowMs = firstDeltaReceivedAtMs; - onChunk({ kind: "textDelta", text: "Streaming reply" }); + onChunk({ kind: "textDelta", text: "Streaming reply", atMs: firstDeltaReceivedAtMs }); nowMs = finalChunkReceivedAtMs; - onChunk({ kind: "final", content: "Completed reply" }); + onChunk({ kind: "final", content: "Completed reply", atMs: finalChunkReceivedAtMs }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), @@ -898,16 +898,16 @@ describe("CustomAgentChatView", () => { expect(userBubble).toBeTruthy(); expect(agentBubble).toBeTruthy(); expect(finalBubble).toBeTruthy(); - expect(within(userBubble!).getByText(expectedChatTime(userReceivedAtMs))).toBeTruthy(); + expect(within(userBubble!).getByLabelText("Heure indisponible")).toBeTruthy(); expect( within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), ).toBeTruthy(); expect( - within(finalBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), + within(finalBubble!).getByText(expectedChatTime(finalChunkReceivedAtMs)), ).toBeTruthy(); expect(screen.getByText("Streaming reply")).toBeTruthy(); expect(screen.getByText("Completed reply")).toBeTruthy(); - expect(screen.queryByText(expectedChatTime(finalChunkReceivedAtMs))).toBeNull(); + expect(screen.getByText(expectedChatTime(finalChunkReceivedAtMs))).toBeTruthy(); } finally { nowSpy.mockRestore(); } @@ -927,9 +927,9 @@ describe("CustomAgentChatView", () => { })), sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { nowMs = firstDeltaReceivedAtMs; - onChunk({ kind: "textDelta", text: "Streaming reply" }); + onChunk({ kind: "textDelta", text: "Streaming reply", atMs: firstDeltaReceivedAtMs }); nowMs = errorReceivedAtMs; - onChunk({ kind: "error", message: "Broken reply" }); + onChunk({ kind: "error", message: "Broken reply", atMs: errorReceivedAtMs }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), @@ -981,9 +981,9 @@ describe("CustomAgentChatView", () => { within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), ).toBeTruthy(); expect( - within(errorBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), + within(errorBubble!).getByText(expectedChatTime(errorReceivedAtMs)), ).toBeTruthy(); - expect(screen.queryByText(expectedChatTime(errorReceivedAtMs))).toBeNull(); + expect(screen.getByText(expectedChatTime(errorReceivedAtMs))).toBeTruthy(); } finally { nowSpy.mockRestore(); } @@ -1036,12 +1036,8 @@ describe("CustomAgentChatView", () => { expect(agentBubble).toBeTruthy(); expect(finalBubble).toBeTruthy(); - expect( - within(agentBubble!).getByText(expectedChatTime(replayReceivedAtMs)), - ).toBeTruthy(); - expect( - within(finalBubble!).getByText(expectedChatTime(replayReceivedAtMs)), - ).toBeTruthy(); + expect(within(agentBubble!).getByLabelText("Heure indisponible")).toBeTruthy(); + expect(within(finalBubble!).getByLabelText("Heure indisponible")).toBeTruthy(); expect(within(agentBubble!).queryByText(indexOneTime)).toBeNull(); expect(within(finalBubble!).queryByText(indexOneTime)).toBeNull(); } finally { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index d26e2c3d..91a61d79 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -42,7 +42,7 @@ import { markProjectCliInteractionStarted, markProjectCliInteractionStopped, } from "@/features/projects/projectCliInteractionActivity"; -import { Button, Spinner, cn } from "@/shared"; +import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; import type { ChatAttachmentInput, ChatDraftScope } from "@/ports"; export interface CustomAgentChatViewProps { @@ -66,14 +66,14 @@ export interface CustomAgentChatViewProps { } type ChatTurn = - | { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number } - | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number } - | { role: "tool"; label: string; receivedAtMs?: number } + | { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number | null } + | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number | null } + | { role: "tool"; label: string; receivedAtMs?: number | null } | { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string } - | { role: "final"; text: string; receivedAtMs?: number } - | { role: "error"; text: string; receivedAtMs?: number } - | { role: "restart"; key: string; text: string; message?: string; failed?: boolean } - | { role: "unknown"; text: string; receivedAtMs?: number }; + | { role: "final"; text: string; receivedAtMs?: number | null } + | { role: "error"; text: string; receivedAtMs?: number | null } + | { role: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null } + | { role: "unknown"; text: string; receivedAtMs?: number | null }; interface CachedChatView { turns: ChatTurn[]; @@ -89,14 +89,6 @@ const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; const COMPOSER_HISTORY_LIMIT = 100; -const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1); -const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1); -const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( - MIN_REASONABLE_CHAT_TIMESTAMP_MS / 1000, -); -const MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( - MAX_REASONABLE_CHAT_TIMESTAMP_MS / 1000, -); export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); @@ -214,7 +206,7 @@ function isReplyRecord(chunk: unknown): chunk is Record { function appendAgentDelta( turns: ChatTurn[], text: string, - receivedAtMs: number, + receivedAtMs: number | null | undefined, ): ChatTurn[] { const next = [...turns]; const last = next[next.length - 1]; @@ -241,7 +233,7 @@ function appendUserPrompt( turns: ChatTurn[], text: string, attachments: string[] = [], - receivedAtMs = Date.now(), + receivedAtMs?: number | null, ): { turns: ChatTurn[]; appended: boolean } { for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; @@ -251,6 +243,11 @@ function appendUserPrompt( turn.text === text && JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments) ) { + if (turn.receivedAtMs == null && receivedAtMs != null) { + const next = [...turns]; + next[index] = { ...turn, receivedAtMs }; + return { turns: next, appended: false }; + } return { turns, appended: false }; } break; @@ -805,29 +802,21 @@ function formatSlashCommandHelp(commands: SlashCommand[]): string { .join("\n"); } -function lastAgentReceivedAtMs(turns: ChatTurn[]): number | undefined { - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; - if (turn.role === "agent") return turn.receivedAtMs; - if (turn.role === "user" || turn.role === "final" || turn.role === "error") { - return undefined; - } - } - return undefined; -} - function foldChunk( state: ChatViewState, raw: unknown, - receivedAtMs = Date.now(), + receivedAtMs?: number | null, ): ChatViewState { const { turns } = state; if (!isReplyRecord(raw)) { return { ...state, - turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], }; } + const emittedAtMs = + receivedAtMs ?? + normalizeTimestamp(typeof raw.atMs === "number" ? raw.atMs : null); const last = turns[turns.length - 1]; if ( (last?.role === "final" || last?.role === "error") && @@ -843,14 +832,14 @@ function foldChunk( case "textDelta": return { ...state, - turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs), + turns: appendAgentDelta(turns, String(raw.text ?? ""), emittedAtMs), }; case "toolActivity": return { ...state, turns: [ ...turns, - { role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs }, + { role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs: emittedAtMs }, ], }; case "progress": { @@ -860,7 +849,7 @@ function foldChunk( ...state, turns: [ ...turns, - { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }, + { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs }, ], }; } @@ -875,7 +864,7 @@ function foldChunk( next.push({ role: "final", text: content, - receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, + receivedAtMs: emittedAtMs, }); return { ...completedState, turns: next }; } @@ -887,7 +876,7 @@ function foldChunk( next.push({ role: "error", text: String(raw.message ?? "Erreur agent"), - receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, + receivedAtMs: emittedAtMs, }); return { ...completedState, turns: next }; } @@ -897,7 +886,7 @@ function foldChunk( turns, String(raw.text ?? raw.prompt ?? ""), attachmentLabels(raw.attachments), - receivedAtMs, + emittedAtMs, ); return { ...state, @@ -910,7 +899,7 @@ function foldChunk( default: return { ...state, - turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs }], }; } } @@ -1998,7 +1987,7 @@ export function CustomAgentChatView({ { role: "tool", label: `Profil changé vers ${selected.name}.`, - receivedAtMs: Date.now(), + receivedAtMs: null, }, ], progressIndex: new Map(), @@ -2040,7 +2029,7 @@ export function CustomAgentChatView({ const helpText = formatSlashCommandHelp(result.effect.commands); updateChatViewState((prev) => ({ ...prev, - turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }], + turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: null }], })); return; } @@ -2065,7 +2054,7 @@ export function CustomAgentChatView({ { role: "tool", label: `Commande plugin ${result.command.name} exécutée.`, - receivedAtMs: Date.now(), + receivedAtMs: null, }, ], })); @@ -2076,7 +2065,7 @@ export function CustomAgentChatView({ setError({ message, openFailure: false }); updateChatViewState((prev) => ({ ...prev, - turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], + turns: [...prev.turns, { role: "error", text: message, receivedAtMs: null }], })); } } @@ -2195,7 +2184,6 @@ export function CustomAgentChatView({ void clearPersistedDraft(); setBusy(true); setError(null); - const sentAtMs = Date.now(); updateChatViewState((prev) => ({ ...prev, turns: [ @@ -2203,7 +2191,7 @@ export function CustomAgentChatView({ { role: "user", text: displayText, - receivedAtMs: sentAtMs, + receivedAtMs: null, ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), }, ], @@ -2242,7 +2230,7 @@ export function CustomAgentChatView({ ...prev, turns: [ ...prev.turns, - { role: "error", text: describe(recoveryError), receivedAtMs: Date.now() }, + { role: "error", text: describe(recoveryError), receivedAtMs: null }, ], })); return; @@ -2253,7 +2241,7 @@ export function CustomAgentChatView({ setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ ...prev, - turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], + turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }], })); } } @@ -2280,7 +2268,7 @@ export function CustomAgentChatView({ { role: "tool", label: "Request stopped.", - receivedAtMs: Date.now(), + receivedAtMs: null, }, ], }; @@ -2299,7 +2287,7 @@ export function CustomAgentChatView({ ...completed, turns: [ ...completed.turns, - { role: "tool", label: "Request already stopped.", receivedAtMs: Date.now() }, + { role: "tool", label: "Request already stopped.", receivedAtMs: null }, ], }; }); @@ -2313,7 +2301,7 @@ export function CustomAgentChatView({ { role: "error", text: `Arrêt de la demande échoué: ${describe(e)}`, - receivedAtMs: Date.now(), + receivedAtMs: null, }, ], })); @@ -2729,7 +2717,10 @@ function ChatBubble({ turn.failed ? "text-danger" : "text-muted", )} > - {turn.text} +
+ {turn.text} + +
{turn.message && {turn.message}} ); @@ -2737,8 +2728,9 @@ function ChatBubble({ if (turn.role === "tool") { return (
- - {turn.label} + + {turn.label} +
); @@ -2824,67 +2816,21 @@ function ChatBubble({ ); } -function normalizeChatReceivedAtMs(receivedAtMs: number | undefined): number | undefined { - if (!Number.isFinite(receivedAtMs) || receivedAtMs === undefined || receivedAtMs <= 0) { - return undefined; - } - if ( - receivedAtMs >= MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS && - receivedAtMs < MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS - ) { - return receivedAtMs * 1000; - } - if ( - receivedAtMs < MIN_REASONABLE_CHAT_TIMESTAMP_MS || - receivedAtMs >= MAX_REASONABLE_CHAT_TIMESTAMP_MS - ) { - return undefined; - } - return receivedAtMs; -} - -function formatChatReceivedTime(receivedAtMs: number | undefined): string { - const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs); - if (!normalizedAtMs) return ""; - try { - const parts = new Intl.DateTimeFormat(undefined, { - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(new Date(normalizedAtMs)); - const hour = parts.find((part) => part.type === "hour")?.value; - const minute = parts.find((part) => part.type === "minute")?.value; - return hour && minute ? `${hour}:${minute}` : ""; - } catch { - return ""; - } -} - function ChatMessageMeta({ label, receivedAtMs, labelClassName, }: { label: string; - receivedAtMs: number | undefined; + receivedAtMs: number | null | undefined; labelClassName?: string; }) { - const time = formatChatReceivedTime(receivedAtMs); - const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs); - const dateTime = time && normalizedAtMs ? new Date(normalizedAtMs).toISOString() : undefined; return (
{label} - {time && ( - - )} +
); } @@ -3105,6 +3051,7 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) { {stage} {label} + @@ -3150,6 +3097,7 @@ function ToolProgressCard({ {compactToolName(progress)} )} · {stage} + {delegation?.excerpt && !detail && (

@@ -3224,6 +3172,7 @@ function ProgressBubble({ {title} {progress.label} · {stage} +

{source} diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index e839c31c..cbffe217 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -450,7 +450,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => { it("stopping an agent unmounts the terminal view", async () => { const agent = new MockAgentGateway(); - await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" }); + const created = await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" }); + const stop = vi.spyOn(agent, "stopLiveAgent"); renderPanel(agent); await waitForIdle(); @@ -466,6 +467,31 @@ describe("AgentsPanel (with MockAgentGateway)", () => { await waitFor(() => { expect(screen.queryByTestId("terminal-view")).toBeNull(); }); + expect(stop).toHaveBeenCalledWith( + PROJECT_ID, + created.id, + "Arrêt demandé depuis Agents", + ); + }); + + it("stops a live agent even when its TerminalView was never mounted", async () => { + const agent = new MockAgentGateway(); + const created = await agent.createAgent(PROJECT_ID, { name: "Headless", profileId: "p1" }); + await agent.launchAgent( + PROJECT_ID, + created.id, + { cwd: "/repo", rows: 24, cols: 80, nodeId: "background-node" }, + () => {}, + ); + const stop = vi.spyOn(agent, "stopLiveAgent"); + + renderPanel(agent); + await screen.findByRole("button", { name: "stop Headless" }); + expect(screen.queryByTestId("terminal-view")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "stop Headless" })); + + await waitFor(() => expect(stop).toHaveBeenCalled()); + await expect(agent.listLiveAgents(PROJECT_ID)).resolves.toEqual([]); }); it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => { diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 469c7518..bcbff066 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -171,7 +171,7 @@ function describe(e: unknown): string { } export function useAgents(projectId: string): AgentsViewModel { - const { agent, profile, system, terminal, input } = useGateways(); + const { agent, profile, system, input } = useGateways(); const [agents, setAgents] = useState([]); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -515,25 +515,21 @@ export function useAgents(projectId: string): AgentsViewModel { const stopAgent = useCallback( async (agentId?: string) => { const targetAgentId = agentId ?? runningAgentId; - const live = targetAgentId - ? liveAgents.find((candidate) => candidate.agentId === targetAgentId) - : null; + if (!targetAgentId) return; setRunningAgentId((current) => current === targetAgentId || !targetAgentId ? null : current, ); - if (live?.sessionId) { - try { - await terminal?.closeTerminal(live.sessionId); - setLiveAgents((current) => - current.filter((candidate) => candidate.agentId !== targetAgentId), - ); - } catch (e) { - setError(describe(e)); - await refreshLiveAgents(); - } + try { + await agent.stopLiveAgent(projectId, targetAgentId, "Arrêt demandé depuis Agents"); + setLiveAgents((current) => + current.filter((candidate) => candidate.agentId !== targetAgentId), + ); + } catch (e) { + setError(describe(e)); + await refreshLiveAgents(); } }, - [liveAgents, refreshLiveAgents, runningAgentId, terminal], + [agent, projectId, refreshLiveAgents, runningAgentId], ); const cancelResume = useCallback( diff --git a/frontend/src/features/announcements/AgentRestartOverlay.tsx b/frontend/src/features/announcements/AgentRestartOverlay.tsx index eca210b7..fda932c9 100644 --- a/frontend/src/features/announcements/AgentRestartOverlay.tsx +++ b/frontend/src/features/announcements/AgentRestartOverlay.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; -import { Spinner } from "@/shared"; +import { EntryTimestamp, Spinner } from "@/shared"; import { useAgentRestart, useTargetAnnouncements } from "./AnnouncementsProvider"; export interface AgentRestartOverlayProps { @@ -69,7 +69,8 @@ export function AgentRestartOverlay({ agentId }: AgentRestartOverlayProps) {
) : ( announcements.map((announcement) => ( -
+
+ {announcement.text}
)) diff --git a/frontend/src/features/announcements/AnnouncementsPreview.tsx b/frontend/src/features/announcements/AnnouncementsPreview.tsx index 7beb07a9..6c7840da 100644 --- a/frontend/src/features/announcements/AnnouncementsPreview.tsx +++ b/frontend/src/features/announcements/AnnouncementsPreview.tsx @@ -12,6 +12,7 @@ */ import { useRequesterAnnouncements } from "./AnnouncementsProvider"; +import { EntryTimestamp } from "@/shared"; export interface AnnouncementsPreviewProps { /** The requesting agent this preview belongs to (the announcement `requester`). */ @@ -60,10 +61,11 @@ export function AnnouncementsPreview({ {recent.map((a) => (
  • - {a.text} + + {a.text}
  • ))} diff --git a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx index f73c983e..578741b1 100644 --- a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx +++ b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx @@ -15,7 +15,7 @@ import { useEffect, useRef } from "react"; -import { Spinner } from "@/shared"; +import { EntryTimestamp, Spinner } from "@/shared"; import { useHydrateAgentBusy, useTargetAnnouncements, @@ -32,15 +32,6 @@ export interface TargetAnnouncementsOverlayProps { fallbackText?: string; } -function formatTime(atMs: number): string { - if (!Number.isFinite(atMs) || atMs <= 0) return ""; - try { - return new Date(atMs).toLocaleTimeString(); - } catch { - return ""; - } -} - export function TargetAnnouncementsOverlay({ projectId, agentId, @@ -88,13 +79,12 @@ export function TargetAnnouncementsOverlay({
    ) : ( announcements.map((a) => { - const time = formatTime(a.atMs); return (
    - {time && {time}} + {a.text} diff --git a/frontend/src/features/announcements/announcementsStore.ts b/frontend/src/features/announcements/announcementsStore.ts index 21f36d7c..8dba4c2e 100644 --- a/frontend/src/features/announcements/announcementsStore.ts +++ b/frontend/src/features/announcements/announcementsStore.ts @@ -34,7 +34,7 @@ export interface Announcement { /** Human-readable réflexion/annonce text. */ text: string; /** Wall-clock timestamp (epoch-ms) as emitted by the backend. */ - atMs: number; + atMs: number | null; /** * Monotonic local sequence assigned on ingest. Provides a stable React key and * a total order even when two announcements share the same `atMs`. @@ -110,6 +110,7 @@ export function purgeAllForTarget( /** Orders two announcements oldest → newest (by `atMs`, tie-broken by `seq`). */ function byTime(a: Announcement, b: Announcement): number { + if (a.atMs === null || b.atMs === null) return a.seq - b.seq; return a.atMs - b.atMs || a.seq - b.seq; } diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx index 278863ff..9b389f25 100644 --- a/frontend/src/features/conversations/ConversationViewer.tsx +++ b/frontend/src/features/conversations/ConversationViewer.tsx @@ -18,7 +18,7 @@ import type { TurnSource, TurnView, } from "@/domain"; -import { Button, Spinner, cn } from "@/shared"; +import { Button, EntryTimestamp, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "@/features/workstate"; import { useConversationThread } from "./useConversationThread"; @@ -38,15 +38,6 @@ function partyKey(source: TurnSource): string { return source.kind === "human" ? "human" : `agent:${source.agentId}`; } -function formatTime(atMs: number): string { - if (!Number.isFinite(atMs) || atMs <= 0) return ""; - try { - return new Date(atMs).toLocaleString(); - } catch { - return ""; - } -} - function roleLabel(role: TurnView["role"]): string { switch (role) { case "prompt": @@ -185,6 +176,7 @@ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) { {open ? "▾" : "▸"} Tool activity · {turn.textLen} chars + {open && (
    @@ -353,7 +345,6 @@ export function ConversationViewer({
                   );
                 }
                 const party = parties.meta.get(partyKey(turn.source));
    -            const time = formatTime(turn.atMs);
                 return (
                   
    · {roleLabel(turn.role)} - {time && · {time}} +

    {turn.text}

    diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 18d59bf0..92f71e5f 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -843,10 +843,8 @@ function LeafView({ } if (cellMode === "custom" && agentGateway?.closeAgentChat) { await agentGateway.closeAgentChat(session); - } else if (agentId && agentGateway?.stopLiveAgent) { - await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => { - await terminal?.closeTerminal(session); - }); + } else if (agentId && agentGateway) { + await agentGateway.stopLiveAgent(projectId, agentId, "Changement d'interface terminal"); } else { await terminal?.closeTerminal(session); } @@ -1115,7 +1113,7 @@ function LeafView({ height: "100%", minHeight: 0, minWidth: 0, - border: "1px solid #2a2a2a", + border: "1px solid var(--color-border-strong, #3b4757)", boxSizing: "border-box", overflow: "hidden", }} diff --git a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx new file mode 100644 index 00000000..76ef9ee0 --- /dev/null +++ b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockGateways } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { ProjectSettingsPanel, PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel"; +import { accessibleProjectAccent, validThemeColor } from "./projectTheme"; + +describe("ProjectSettingsPanel", () => { + it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Alpha", "/alpha"); + const update = vi.spyOn(gateways.project, "updateProjectSettings"); + const changed = vi.fn(); + window.addEventListener(PROJECT_THEME_CHANGED_EVENT, changed); + render( + + + , + ); + + const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement; + await waitFor(() => expect(picker.disabled).toBe(false)); + fireEvent.change(picker, { target: { value: "#aa3366" } }); + await waitFor(() => expect(update).toHaveBeenCalledWith(project.id, { themeColor: "#AA3366" })); + expect(changed).toHaveBeenCalled(); + + 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(); + window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed); + }); + + it("keeps strict colours and raises dark accents to readable contrast", () => { + expect(validThemeColor("#12abEF")).toBe("#12ABEF"); + expect(validThemeColor("red")).toBeNull(); + expect(accessibleProjectAccent("#000000")).not.toBe("#000000"); + }); +}); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.tsx b/frontend/src/features/projects/ProjectSettingsPanel.tsx new file mode 100644 index 00000000..6a29f6b7 --- /dev/null +++ b/frontend/src/features/projects/ProjectSettingsPanel.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; + +import { useGateways } from "@/app/di"; +import { Button, Panel, Spinner } from "@/shared"; +import { ProjectContextPanel } from "./ProjectContextPanel"; +import { DEFAULT_PROJECT_COLOR, validThemeColor } from "./projectTheme"; + +export const PROJECT_THEME_CHANGED_EVENT = "idea:project-theme-changed"; + +export function ProjectSettingsPanel({ projectId }: { projectId: string }) { + const { project } = useGateways(); + const [themeColor, setThemeColor] = useState(null); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setBusy(true); + project + .readProjectSettings(projectId) + .then((settings) => { + if (!cancelled) { + setThemeColor(validThemeColor(settings.themeColor)); + setError(null); + } + }) + .catch(() => { + if (!cancelled) setError("Impossible de charger les paramètres du projet."); + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, [project, projectId]); + + async function save(next: string | null) { + setBusy(true); + setError(null); + try { + const saved = await project.updateProjectSettings(projectId, { themeColor: next }); + const normalized = validThemeColor(saved.themeColor); + setThemeColor(normalized); + window.dispatchEvent( + new CustomEvent(PROJECT_THEME_CHANGED_EVENT, { + detail: { projectId, themeColor: normalized }, + }), + ); + } catch { + setError("Impossible d’enregistrer la couleur du projet."); + } finally { + setBusy(false); + } + } + + return ( +
    + +
    + + +
    + {busy && } + {error &&

    {error}

    } +
    + +
    +

    + Contexte du projet +

    + +
    +
    + ); +} diff --git a/frontend/src/features/projects/ProjectTabs.tsx b/frontend/src/features/projects/ProjectTabs.tsx index febdc6da..e4a322c2 100644 --- a/frontend/src/features/projects/ProjectTabs.tsx +++ b/frontend/src/features/projects/ProjectTabs.tsx @@ -13,6 +13,7 @@ import type { TabItem } from "@/shared"; import { Button, Tabs, cn } from "@/shared"; import type { ProjectActivityStatus } from "./projectActivity"; +import { accessibleProjectAccent } from "./projectTheme"; export interface ProjectTabsProps { items: TabItem[]; @@ -25,6 +26,7 @@ export interface ProjectTabsProps { onOpenProjectsPanel: () => void; /** Per-project custom CLI activity, keyed by project id. Missing = none. */ activityByProjectId?: Record; + themeColorByProjectId?: Record; className?: string; } @@ -36,10 +38,15 @@ export function ProjectTabs({ projectsPanelOpen, onOpenProjectsPanel, activityByProjectId = {}, + themeColorByProjectId = {}, className, }: ProjectTabsProps) { const tabs = items.map((item) => ({ ...item, + themeColor: themeColorByProjectId[item.id] ?? null, + themeAccent: themeColorByProjectId[item.id] + ? accessibleProjectAccent(themeColorByProjectId[item.id]!) + : null, leading: ( = { projects: "md", context: "lg", + projectSettings: "lg", work: "lg", tickets: "lg", agents: "lg", @@ -235,6 +238,7 @@ export function ProjectsView() { system, window: windowGateway, focusedProject, + project, git, agent, } = useGateways(); @@ -273,8 +277,15 @@ export function ProjectsView() { const [removalPending, setRemovalPending] = useState(false); const [removalError, setRemovalError] = useState(null); const [removalNotice, setRemovalNotice] = useState(null); + const [projectThemeColors, setProjectThemeColors] = useState< + Record + >({}); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; + const openProjectThemeKey = vm.openTabs.map((tab) => tab.id).join("\u0000"); + const activeThemeStyle = projectThemeStyle( + active ? projectThemeColors[active.id] : null, + ); const activeViewerConversation = viewerConversation?.projectId === active?.id ? viewerConversation : null; const showRestoreNotification = @@ -301,6 +312,40 @@ export function ProjectsView() { useEffect(() => { if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false); }, [vm.restoreFailures.length]); + + useEffect(() => { + let cancelled = false; + void Promise.all( + vm.openTabs.map(async (tab) => { + try { + const settings = await project.readProjectSettings(tab.id); + return [tab.id, validThemeColor(settings.themeColor)] as const; + } catch { + return [tab.id, null] as const; + } + }), + ).then((entries) => { + if (!cancelled) setProjectThemeColors(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [project, openProjectThemeKey]); + + useEffect(() => { + const update = (event: Event) => { + const detail = ( + event as CustomEvent<{ projectId: string; themeColor: string | null }> + ).detail; + if (!detail?.projectId) return; + setProjectThemeColors((current) => ({ + ...current, + [detail.projectId]: validThemeColor(detail.themeColor), + })); + }; + window.addEventListener(PROJECT_THEME_CHANGED_EVENT, update); + return () => window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, update); + }, []); const projectNameById = useMemo(() => { const names = new Map(); for (const project of vm.projects) names.set(project.id, project.name); @@ -601,6 +646,7 @@ export function ProjectsView() { // placement is marked in the submenu (and the parent entry shows ● when the // panel is open somewhere). Order per UX: content panels first, Projects last. const panelOrder: PanelId[] = [ + "projectSettings", "context", "work", "tickets", @@ -659,6 +705,11 @@ export function ProjectsView() { const nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({ id: panel, label: PANEL_TITLE[panel], + disabled: panel === "projectSettings" && !active, + title: + panel === "projectSettings" && !active + ? "Ouvrez ou sélectionnez un projet pour accéder à ses paramètres." + : undefined, active: placementOf(placements, panel) !== "closed", onSelect: () => {}, submenu: placementSubmenu(panel), @@ -954,6 +1005,7 @@ export function ProjectsView() { {/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */} ({ id: t.id, label: t.name }))} + themeColorByProjectId={projectThemeColors} activeTabId={vm.activeTabId} onSelect={(id) => vm.activateTab(id)} onClose={(id) => void vm.closeTab(id)} @@ -965,6 +1017,11 @@ export function ProjectsView() { }} /> +
    {/* ── Menu bar (replaces the former left sidebar) ── */} )} +
    ); } diff --git a/frontend/src/features/projects/ViewPanelBody.tsx b/frontend/src/features/projects/ViewPanelBody.tsx index 036b70e7..5259d39e 100644 --- a/frontend/src/features/projects/ViewPanelBody.tsx +++ b/frontend/src/features/projects/ViewPanelBody.tsx @@ -21,6 +21,7 @@ import { ProjectWorkStatePanel } from "@/features/workstate"; import { TicketsView } from "@/features/tickets"; import { GitPanel } from "@/features/git"; import { ProjectContextPanel } from "./ProjectContextPanel"; +import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import type { PanelId } from "./viewPlacement"; /** Panels that render a project-scoped view (everything except "projects"). */ @@ -45,6 +46,8 @@ export function ViewPanelBody({ switch (panel) { case "context": return ; + case "projectSettings": + return ; case "work": return ( Number.parseInt(hex.slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; +} + +function luminance(hex: string): number { + const channels = rgb(hex).map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +} + +function contrast(a: string, b: string): number { + const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (light + 0.05) / (dark + 0.05); +} + +/** Lightens very dark user colours until accent text remains WCAG-AA on canvas. */ +export function accessibleProjectAccent(raw: string): string { + const color = validThemeColor(raw) ?? DEFAULT_PROJECT_COLOR; + const [r, g, b] = rgb(color); + for (let amount = 0; amount <= 1; amount += 0.02) { + const next = `#${[r, g, b] + .map((channel) => Math.round(channel + (255 - channel) * amount).toString(16).padStart(2, "0")) + .join("")}`; + if (contrast(next, "#0E1116") >= 4.5) return next.toUpperCase(); + } + return "#FFFFFF"; +} + +export function projectThemeStyle(themeColor: string | null | undefined): CSSProperties { + const raw = validThemeColor(themeColor); + if (!raw) return {}; + const accent = accessibleProjectAccent(raw); + return { + "--project-theme-color": raw, + "--color-primary": accent, + "--color-primary-hover": `color-mix(in srgb, ${accent} 86%, white)`, + "--color-on-primary": contrast(accent, "#0E1116") >= 4.5 ? "#0E1116" : "#FFFFFF", + "--color-canvas": `color-mix(in srgb, #0e1116 96%, ${raw})`, + "--color-surface": `color-mix(in srgb, #161b22 95%, ${raw})`, + "--color-raised": `color-mix(in srgb, #1c2430 94%, ${raw})`, + "--color-overlay": `color-mix(in srgb, #222c3a 94%, ${raw})`, + "--color-border": `color-mix(in srgb, #2b3440 88%, ${accent})`, + "--color-border-strong": accent, + } as CSSProperties; +} diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index 3a332986..ea368da8 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -610,6 +610,33 @@ describe("ProjectsView (with MockProjectGateway)", () => { }); }); + it("opens project settings from Panneaux and reuses the context editor", async () => { + const project = new MockProjectGateway(); + await project.createProject("alpha", "/p/a"); + renderView(project); + await screen.findByText("/p/a"); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + 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(); + }); + + it("disables project settings while no project is active", async () => { + renderView(new MockProjectGateway()); + await waitForIdle(); + fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); + const item = screen.getByRole("button", { name: "Paramètres du projet" }); + expect(item.hasAttribute("disabled")).toBe(true); + expect(item.getAttribute("title")).toBe( + "Ouvrez ou sélectionnez un projet pour accéder à ses paramètres.", + ); + }); + it("Browse… button calls pickFolder and fills the root field with the returned path", async () => { const system = new MockSystemGateway(); renderView(new MockProjectGateway(), system); diff --git a/frontend/src/features/projects/viewPlacement.ts b/frontend/src/features/projects/viewPlacement.ts index 5ba4a492..fa8dbc58 100644 --- a/frontend/src/features/projects/viewPlacement.ts +++ b/frontend/src/features/projects/viewPlacement.ts @@ -23,6 +23,7 @@ import type { ViewWindowSnapshot } from "@/ports"; export type PanelId = | "projects" | "context" + | "projectSettings" | "work" | "tickets" | "agents" @@ -50,6 +51,7 @@ export type ViewPlacements = Partial>; export const PANEL_TITLE: Record = { projects: "Projects", context: "Project context", + projectSettings: "Paramètres du projet", work: "Work state", tickets: "Tickets", agents: "Agents", diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx index c1a4c85a..1198f80d 100644 --- a/frontend/src/features/settings/SettingsView.tsx +++ b/frontend/src/features/settings/SettingsView.tsx @@ -26,13 +26,15 @@ import { ProfilesSettings } from "@/features/first-run"; import { DevicesScreen } from "@/features/devices"; import { PluginsPanel } from "@/features/plugins"; import { DeploymentSettings } from "./DeploymentSettings"; +import { TerminalSettings } from "./TerminalSettings"; /** The Settings sections, in menu/nav order. */ -export type SettingsSection = "aiProfiles" | "deployment" | "devices" | "plugins"; +export type SettingsSection = "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 = { aiProfiles: "Profils IA", + terminal: "Terminal", deployment: "Déploiement", devices: "Appareils", plugins: "Plugins", @@ -41,6 +43,7 @@ export const SETTINGS_SECTION_LABEL: Record = { /** Section order — the single source of truth for both nav and menu. */ export const SETTINGS_SECTIONS: SettingsSection[] = [ "aiProfiles", + "terminal", "deployment", "devices", "plugins", @@ -98,6 +101,8 @@ export function SettingsView({
    {section === "aiProfiles" ? ( + ) : section === "terminal" ? ( + ) : section === "deployment" ? ( ) : section === "plugins" ? ( diff --git a/frontend/src/features/settings/TerminalSettings.test.tsx b/frontend/src/features/settings/TerminalSettings.test.tsx new file mode 100644 index 00000000..c2f7e44f --- /dev/null +++ b/frontend/src/features/settings/TerminalSettings.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { DIProvider } from "@/app/di"; +import { createMockGateways } from "@/adapters/mock"; +import { TerminalSettings } from "./TerminalSettings"; + +describe("TerminalSettings", () => { + it("loads and persists the exclusive global default without touching sessions", async () => { + const gateways = createMockGateways(); + const update = vi.spyOn(gateways.userSettings, "updateUserSettings"); + const close = vi.spyOn(gateways.terminal, "closeTerminal"); + render( + + + , + ); + + const native = (await screen.findByLabelText("TUI native")) as HTMLInputElement; + const cli = screen.getByLabelText("CLI IdeA") as HTMLInputElement; + await waitFor(() => expect(native.checked).toBe(true)); + fireEvent.click(cli); + await waitFor(() => + expect(update).toHaveBeenCalledWith({ defaultAgentTerminalInterface: "ideaCli" }), + ); + expect(cli.checked).toBe(true); + expect(close).not.toHaveBeenCalled(); + expect(screen.getByText("Utilisée à l'ouverture d'un nouveau terminal. Les terminaux déjà ouverts ne sont pas modifiés.")).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/settings/TerminalSettings.tsx b/frontend/src/features/settings/TerminalSettings.tsx new file mode 100644 index 00000000..cb4ff737 --- /dev/null +++ b/frontend/src/features/settings/TerminalSettings.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; + +import type { DefaultAgentTerminalInterface } from "@/ports"; +import { Panel, Spinner } from "@/shared"; +import { useGateways } from "@/app/di"; + +const choices: Array<{ value: DefaultAgentTerminalInterface; label: string }> = [ + { value: "nativeTui", label: "TUI native" }, + { value: "ideaCli", label: "CLI IdeA" }, +]; + +export function TerminalSettings() { + const { userSettings } = useGateways(); + const [value, setValue] = useState("nativeTui"); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setBusy(true); + userSettings + .readUserSettings() + .then((settings) => { + if (!cancelled) { + setValue(settings.defaultAgentTerminalInterface ?? "nativeTui"); + setError(null); + } + }) + .catch(() => { + if (!cancelled) setError("Impossible de charger la préférence de terminal."); + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, [userSettings]); + + async function select(next: DefaultAgentTerminalInterface) { + const previous = value; + setValue(next); + setBusy(true); + setError(null); + try { + const saved = await userSettings.updateUserSettings({ + defaultAgentTerminalInterface: next, + }); + setValue(saved.defaultAgentTerminalInterface ?? "nativeTui"); + } catch { + setValue(previous); + setError("Impossible d’enregistrer la préférence de terminal."); + } finally { + setBusy(false); + } + } + + return ( + +
    + + Interface de terminal par défaut + +
    + {choices.map((choice) => ( + + ))} +
    +

    + Utilisée à l'ouverture d'un nouveau terminal. Les terminaux déjà ouverts ne sont pas modifiés. +

    +
    + {busy && } + {error &&

    {error}

    } +
    + ); +} diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 09a0cc4e..0745b396 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -7,5 +7,6 @@ export { type SettingsSection, } from "./SettingsView"; export { DeploymentSettings } from "./DeploymentSettings"; +export { TerminalSettings } from "./TerminalSettings"; export { useDeployment } from "./useDeployment"; export type { DeploymentVm } from "./useDeployment"; diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index f72df5bb..634b923a 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -19,7 +19,7 @@ import { useGateways } from "@/app/di"; import { leaves } from "@/features/layout/layout"; import { useLayout } from "@/features/layout/useLayout"; import type { LayoutViewModel } from "@/features/layout/useLayout"; -import { Button, Panel, Spinner, cn } from "@/shared"; +import { Button, EntryTimestamp, Panel, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "./useProjectWorkState"; export interface ProjectWorkStatePanelProps { @@ -307,7 +307,10 @@ function ConversationSummaryLine({
      {(summary.recentTurns ?? []).map((turn) => (
    • - {turn.role}:{" "} + + {turn.role}: + + {" "} {turn.textPreview}
    • ))} @@ -659,7 +662,7 @@ function AgentRow({ } async function stop(): Promise { - if (!agent.live || !agentGateway?.stopLiveAgent) return; + if (!agent.live || !agentGateway) return; const warning = currentTurn ? " The current turn will be interrupted." : ""; @@ -667,10 +670,15 @@ function AgentRow({ setActionBusy(true); setMessage(null); try { - const stopped = await agentGateway.stopLiveAgent(projectId, agent.agentId); + const stopped = await agentGateway.stopLiveAgent( + projectId, + agent.agentId, + "Arrêt demandé depuis Travail", + ); if (visibleNodeIds.has(agent.live.nodeId)) { const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId); - if (!leaf?.session || leaf.session === stopped.sessionId) { + const stoppedSessionIds = new Set(stopped.stopped.map((runtime) => runtime.sessionId)); + if (!leaf?.session || stoppedSessionIds.has(leaf.session) || stopped.alreadyStopped) { await layout.setSession(agent.live.nodeId, null); } } @@ -763,7 +771,7 @@ function AgentRow({ size="sm" variant="danger" loading={actionBusy} - disabled={!agentGateway?.stopLiveAgent} + disabled={!agentGateway} onClick={() => void stop()} > Stop diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index 0fbae944..1e0bcd46 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -1006,7 +1006,13 @@ describe("ProjectWorkStatePanel", () => { "Stop Runner? The current turn will be interrupted.", ), ); - await waitFor(() => expect(stopSpy).toHaveBeenCalledWith(PROJECT_ID, created.id)); + await waitFor(() => + expect(stopSpy).toHaveBeenCalledWith( + PROJECT_ID, + created.id, + "Arrêt demandé depuis Travail", + ), + ); await waitFor(() => expect(refreshSpy).toHaveBeenCalled()); await waitFor(async () => { const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0]; diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts index 66d37ce9..7243f7ee 100644 --- a/frontend/src/plugins/runtime/services.test.ts +++ b/frontend/src/plugins/runtime/services.test.ts @@ -71,6 +71,8 @@ function gateways(overrides: { readOpenProjectRestoreState: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })), readProjectContext: vi.fn(async () => "project context"), updateProjectContext: vi.fn(), + readProjectSettings: vi.fn(async () => ({ themeColor: null })), + updateProjectSettings: vi.fn(async (_projectId, settings) => settings), listProjectFiles: vi.fn(async (_projectId, path) => ({ path, entries: [] })), downloadProjectFile: vi.fn(), downloadProjectDirectory: vi.fn(), diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index abfe9473..ca526c8e 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -257,7 +257,7 @@ export interface AgentGateway { * Stops an already-live agent session without going through terminal teardown * commands from the Work panel. The backend owns the live-agent lifecycle. */ - stopLiveAgent?(projectId: string, agentId: string): Promise; + stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise; /** Creates a new agent from scratch; returns the created agent. */ createAgent(projectId: string, input: CreateAgentInput): Promise; /** @@ -435,9 +435,30 @@ export interface LiveAgent { /** Result returned when the backend stops a live agent. */ export interface StoppedLiveAgent { agentId: string; + stopped: StoppedAgentRuntime[]; + alreadyStopped: boolean; +} + +export interface StoppedAgentRuntime { sessionId: string; + nodeId: string; kind: "pty" | "structured"; - runtimeState?: AgentRuntimeState; + runtimeState: AgentRuntimeState; +} + +export type DefaultAgentTerminalInterface = "nativeTui" | "ideaCli"; + +export interface UserSettings { + defaultAgentTerminalInterface: DefaultAgentTerminalInterface | null; +} + +export interface UserSettingsGateway { + readUserSettings(): Promise; + updateUserSettings(settings: UserSettings): Promise; +} + +export interface ProjectSettings { + themeColor: string | null; } export type AgentRuntimeState = @@ -596,6 +617,10 @@ export interface ProjectGateway { readProjectContext(projectId: string): Promise; /** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */ updateProjectContext(projectId: string, content: string): Promise; + /** Reads project-owned settings. */ + readProjectSettings(projectId: string): Promise; + /** Replaces project-owned settings. */ + updateProjectSettings(projectId: string, settings: ProjectSettings): Promise; /** Lists one project-relative directory for the web project files surface. */ listProjectFiles(projectId: string, path: string): Promise; /** Downloads one project-relative file. */ @@ -1784,6 +1809,7 @@ export interface Gateways { window: WindowGateway; focusedProject: FocusedProjectGateway; uiPreferences: UiPreferencesGateway; + userSettings: UserSettingsGateway; plugin: PluginGateway; pluginWorkspace: PluginWorkspaceGateway; pluginTask: PluginTaskGateway; diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index 25e3788c..c5e95de3 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -32,6 +32,13 @@ export type { ToolbarProps } from "./ui/Toolbar"; export { Spinner } from "./ui/Spinner"; export type { SpinnerProps } from "./ui/Spinner"; +export { + EntryTimestamp, + formatFullLocalTime, + formatShortLocalTime, + normalizeTimestamp, +} from "./ui/EntryTimestamp"; + export { zIndex } from "./ui/zIndex"; export type { ZIndexLevel } from "./ui/zIndex"; diff --git a/frontend/src/shared/ui/EntryTimestamp.test.tsx b/frontend/src/shared/ui/EntryTimestamp.test.tsx new file mode 100644 index 00000000..abf1f55b --- /dev/null +++ b/frontend/src/shared/ui/EntryTimestamp.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { EntryTimestamp, formatShortLocalTime } from "./EntryTimestamp"; + +describe("EntryTimestamp", () => { + it("shows a stable local HH:mm and exposes the full zoned date", () => { + const atMs = Date.UTC(2026, 8, 10, 12, 37); + render(); + const timestamp = screen.getByText(formatShortLocalTime(atMs)!); + expect(timestamp.getAttribute("title")).toBeTruthy(); + expect(timestamp.getAttribute("tabindex")).toBe("0"); + }); + + it("does not invent a timestamp for legacy entries", () => { + render(); + expect(screen.getByLabelText("Heure indisponible").textContent).toBe("—"); + }); +}); diff --git a/frontend/src/shared/ui/EntryTimestamp.tsx b/frontend/src/shared/ui/EntryTimestamp.tsx new file mode 100644 index 00000000..d1533150 --- /dev/null +++ b/frontend/src/shared/ui/EntryTimestamp.tsx @@ -0,0 +1,52 @@ +import { cn } from "../lib/cn"; + +export function normalizeTimestamp(atMs: number | null | undefined): number | null { + return typeof atMs === "number" && Number.isFinite(atMs) && atMs > 0 && !Number.isNaN(new Date(atMs).getTime()) + ? atMs + : null; +} + +export function formatShortLocalTime(atMs: number | null | undefined): string | null { + const value = normalizeTimestamp(atMs); + if (value === null) return null; + const parts = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(value)); + const hour = parts.find((part) => part.type === "hour")?.value; + const minute = parts.find((part) => part.type === "minute")?.value; + return hour && minute ? `${hour}:${minute}` : null; +} + +export function formatFullLocalTime(atMs: number | null | undefined): string { + const value = normalizeTimestamp(atMs); + if (value === null) return "Heure indisponible"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "full", + timeStyle: "long", + }).format(new Date(value)); +} + +export function EntryTimestamp({ + atMs, + className, +}: { + atMs: number | null | undefined; + className?: string; +}) { + const value = normalizeTimestamp(atMs); + const short = formatShortLocalTime(value); + const full = formatFullLocalTime(value); + return ( + + ); +} diff --git a/frontend/src/shared/ui/MenuBar.tsx b/frontend/src/shared/ui/MenuBar.tsx index d33c5a83..31696bfc 100644 --- a/frontend/src/shared/ui/MenuBar.tsx +++ b/frontend/src/shared/ui/MenuBar.tsx @@ -21,6 +21,7 @@ export interface MenuBarItem { label: string; onSelect: () => void; disabled?: boolean; + title?: string; /** Renders a selected/active marker (e.g. the currently-open panel). */ active?: boolean; /** @@ -124,6 +125,7 @@ export function MenuBar({ menus, className }: MenuBarProps) {