From 94b4ce7e5f9232512047df769c2a5386e639d7c7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 16:24:20 +0200 Subject: [PATCH 1/2] feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182) --- crates/app-tauri/src/commands.rs | 142 ++++++-- crates/app-tauri/src/lib.rs | 3 + crates/app-tauri/tests/dto_chat.rs | 51 ++- crates/application/src/chat_drafts.rs | 157 +++++++++ crates/application/src/lib.rs | 5 + crates/application/tests/chat_drafts.rs | 230 +++++++++++++ crates/backend/src/dto.rs | 36 ++ crates/backend/src/lib.rs | 79 +++-- crates/domain/src/chat_draft.rs | 84 +++++ crates/domain/src/lib.rs | 2 + crates/domain/src/ports.rs | 18 + crates/domain/tests/chat_draft.rs | 26 ++ crates/infrastructure/src/chat_drafts.rs | 125 +++++++ crates/infrastructure/src/lib.rs | 2 + crates/infrastructure/tests/chat_drafts.rs | 110 ++++++ frontend/src/adapters/agent.test.ts | 27 ++ frontend/src/adapters/agent.ts | 20 ++ frontend/src/adapters/http/streamGateways.ts | 14 + frontend/src/adapters/mock/index.ts | 30 ++ .../agents/CustomAgentChatView.test.tsx | 313 +++++++++++++++++- .../features/agents/CustomAgentChatView.tsx | 116 ++++++- frontend/src/features/layout/LayoutGrid.tsx | 15 + frontend/src/ports/index.ts | 17 + 23 files changed, 1552 insertions(+), 70 deletions(-) create mode 100644 crates/application/src/chat_drafts.rs create mode 100644 crates/application/tests/chat_drafts.rs create mode 100644 crates/domain/src/chat_draft.rs create mode 100644 crates/domain/tests/chat_draft.rs create mode 100644 crates/infrastructure/src/chat_drafts.rs create mode 100644 crates/infrastructure/tests/chat_drafts.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 1c7bd76f..5d2c49d9 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -14,20 +14,20 @@ use crate::dto::DismissEmbedderSuggestionRequestDto; use application::plugin_slash_commands_from_runtime_catalog; use application::{ AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput, - CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, - DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, - DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, ExecuteSlashCommandInput, - GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, - GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, - GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, - ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, - ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, - OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput, - ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, - ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, - RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + ChatDraftScopeInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, + CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, + DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, + ExecuteSlashCommandInput, GetMemoryInput, GetProjectSystemPermissionsInput, + GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, + GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, + LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, + ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, + LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, + ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, + ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SetActiveLayoutInput, SlashCommandEffect, + RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, @@ -48,28 +48,29 @@ use crate::dto::{ AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto, - CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, - CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, - EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto, - ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, - GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, - GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, - ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, - InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, - LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, - MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, - OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, - ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, - ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, - ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, - ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, - ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + ChatDraftScopeRequestDto, CloneOpenCodeProfileFromSeedRequestDto, + CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, + CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, + CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, + ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto, + FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, + GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, + HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto, + ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, + ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, + PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, + ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, + RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, + ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, @@ -2237,6 +2238,77 @@ pub async fn change_agent_profile( .map_err(ErrorDto::from) } +fn chat_draft_scope_from_dto( + request: ChatDraftScopeRequestDto, +) -> Result { + Ok(ChatDraftScopeInput { + project_id: parse_project_id(&request.project_id)?, + layout_id: parse_layout_id(&request.layout_id)?, + node_id: parse_node_id(&request.node_id)?, + }) +} + +/// `read_chat_draft` — read persisted custom chat draft text for a project/layout/node. +/// +/// # Errors +/// Returns an [`ErrorDto`] for malformed ids, unknown project, or store failure. +#[tauri::command] +pub async fn read_chat_draft( + request: ChatDraftScopeRequestDto, + state: State<'_, AppState>, +) -> Result { + let output = state + .read_chat_draft + .execute(chat_draft_scope_from_dto(request)?) + .await + .map_err(ErrorDto::from)?; + Ok(ReadChatDraftResponseDto { + text: output.text, + updated_at_ms: output.updated_at_ms, + }) +} + +/// `save_chat_draft` — persist custom chat draft text for a project/layout/node. +/// +/// Empty text clears the draft. Attachments are not part of this contract. +/// +/// # Errors +/// Returns an [`ErrorDto`] for malformed ids, unknown project, invalid text, or store failure. +#[tauri::command] +pub async fn save_chat_draft( + request: SaveChatDraftRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .save_chat_draft + .execute(SaveChatDraftInput { + scope: ChatDraftScopeInput { + project_id: parse_project_id(&request.project_id)?, + layout_id: parse_layout_id(&request.layout_id)?, + node_id: parse_node_id(&request.node_id)?, + }, + text: request.text, + }) + .await + .map_err(ErrorDto::from) +} + +/// `clear_chat_draft` — clear persisted custom chat draft text for a project/layout/node. +/// +/// # Errors +/// Returns an [`ErrorDto`] for malformed ids, unknown project, or store failure. +#[tauri::command] +pub async fn clear_chat_draft( + request: ChatDraftScopeRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .clear_chat_draft + .execute(chat_draft_scope_from_dto(request)?) + .await + .map_err(ErrorDto::from) +} + /// `agent_send` — send a prompt to a live **structured** (chat) session and pump /// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7). /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index ec6bdffe..b413d8a0 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -342,6 +342,9 @@ pub fn run() { commands::delete_agent, commands::launch_agent, commands::change_agent_profile, + commands::read_chat_draft, + commands::save_chat_draft, + commands::clear_chat_draft, commands::agent_send, commands::import_chat_attachments, commands::cancel_resume, diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index ee27c352..c88da3b7 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -7,9 +7,10 @@ //! `cellKind` and PTY paths keep `"pty"`. use app_tauri_lib::dto::{ - CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto, - ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, - ReplyProgressKindDto, ReplyProgressSourceDto, ReplyProgressStageDto, TerminalSessionDto, + CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, + ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto, + ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, + ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, }; use application::{LaunchAgentOutput, StructuredSessionDescriptor}; use domain::project::ProjectPath; @@ -154,6 +155,50 @@ fn reply_chunk_rejects_snake_case_tag() { // Chat attachments — structured input + durable metadata, camelCase // --------------------------------------------------------------------------- +#[test] +fn chat_draft_scope_request_deserialises_camel_case() { + let dto: ChatDraftScopeRequestDto = serde_json::from_value(json!({ + "projectId": Uuid::from_u128(1).to_string(), + "layoutId": Uuid::from_u128(2).to_string(), + "nodeId": Uuid::from_u128(3).to_string() + })) + .unwrap(); + + assert_eq!(dto.project_id, Uuid::from_u128(1).to_string()); + assert_eq!(dto.layout_id, Uuid::from_u128(2).to_string()); + assert_eq!(dto.node_id, Uuid::from_u128(3).to_string()); +} + +#[test] +fn save_chat_draft_request_deserialises_text_only_camel_case() { + let dto: SaveChatDraftRequestDto = serde_json::from_value(json!({ + "projectId": Uuid::from_u128(1).to_string(), + "layoutId": Uuid::from_u128(2).to_string(), + "nodeId": Uuid::from_u128(3).to_string(), + "text": "draft text" + })) + .unwrap(); + + assert_eq!(dto.text, "draft text"); +} + +#[test] +fn read_chat_draft_response_serialises_camel_case() { + let dto = ReadChatDraftResponseDto { + text: Some("draft text".to_owned()), + updated_at_ms: Some(42), + }; + let v = serde_json::to_value(dto).unwrap(); + + assert_eq!(v["text"], "draft text"); + assert_eq!(v["updatedAtMs"], 42); + assert!(v.get("updated_at_ms").is_none(), "no snake_case leak"); + assert!( + v.get("attachments").is_none(), + "drafts never persist attachments" + ); +} + #[test] fn chat_attachment_input_dto_deserialises_camel_case() { let dto: ChatAttachmentInputDto = serde_json::from_value(json!({ diff --git a/crates/application/src/chat_drafts.rs b/crates/application/src/chat_drafts.rs new file mode 100644 index 00000000..b3a30596 --- /dev/null +++ b/crates/application/src/chat_drafts.rs @@ -0,0 +1,157 @@ +//! Custom chat draft use cases. + +use std::sync::Arc; + +use domain::ports::{ChatDraftStore, Clock, ProjectStore}; +use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId}; + +use crate::AppError; + +/// Input identifying one draft scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChatDraftScopeInput { + /// Project owning the layout. + pub project_id: ProjectId, + /// Named layout containing the chat cell. + pub layout_id: LayoutId, + /// Layout node hosting the chat cell. + pub node_id: NodeId, +} + +impl ChatDraftScopeInput { + fn key(self) -> ChatDraftKey { + ChatDraftKey::new(self.project_id, self.layout_id, self.node_id) + } +} + +/// Output of [`ReadChatDraft`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadChatDraftOutput { + /// Restored draft text, or `None` when no draft exists. + pub text: Option, + /// Last update time when a draft exists. + pub updated_at_ms: Option, +} + +/// Input for [`SaveChatDraft`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveChatDraftInput { + /// Draft scope. + pub scope: ChatDraftScopeInput, + /// Text to persist. Empty text clears the draft. + pub text: String, +} + +/// Reads a persisted custom chat draft. +pub struct ReadChatDraft { + projects: Arc, + drafts: Arc, +} + +impl ReadChatDraft { + /// Builds the use case. + #[must_use] + pub fn new(projects: Arc, drafts: Arc) -> Self { + Self { projects, drafts } + } + + /// Executes the read. + pub async fn execute( + &self, + input: ChatDraftScopeInput, + ) -> Result { + let project = self.load_project(input.project_id).await?; + let draft = self.drafts.read(&project.root, input.key()).await?; + Ok(ReadChatDraftOutput { + text: draft.as_ref().map(|draft| draft.text.clone()), + updated_at_ms: draft.map(|draft| draft.updated_at_ms), + }) + } + + async fn load_project(&self, project_id: ProjectId) -> Result { + self.projects + .load_project(project_id) + .await + .map_err(AppError::from) + } +} + +/// Saves a persisted custom chat draft. +pub struct SaveChatDraft { + projects: Arc, + drafts: Arc, + clock: Arc, +} + +impl SaveChatDraft { + /// Builds the use case. + #[must_use] + pub fn new( + projects: Arc, + drafts: Arc, + clock: Arc, + ) -> Self { + Self { + projects, + drafts, + clock, + } + } + + /// Executes the save. Empty text clears the draft. + pub async fn execute(&self, input: SaveChatDraftInput) -> Result<(), AppError> { + let project = self.load_project(input.scope.project_id).await?; + let key = input.scope.key(); + if input.text.is_empty() { + return self + .drafts + .clear(&project.root, key) + .await + .map_err(AppError::from); + } + let updated_at_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0); + let draft = ChatDraft::new(key, input.text, updated_at_ms) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.drafts + .save(&project.root, draft) + .await + .map_err(AppError::from) + } + + async fn load_project(&self, project_id: ProjectId) -> Result { + self.projects + .load_project(project_id) + .await + .map_err(AppError::from) + } +} + +/// Clears a persisted custom chat draft. +pub struct ClearChatDraft { + projects: Arc, + drafts: Arc, +} + +impl ClearChatDraft { + /// Builds the use case. + #[must_use] + pub fn new(projects: Arc, drafts: Arc) -> Self { + Self { projects, drafts } + } + + /// Executes the clear. + pub async fn execute(&self, input: ChatDraftScopeInput) -> Result<(), AppError> { + let project = self.load_project(input.project_id).await?; + self.drafts + .clear(&project.root, input.key()) + .await + .map_err(AppError::from) + } + + async fn load_project(&self, project_id: ProjectId) -> Result { + self.projects + .load_project(project_id) + .await + .map_err(AppError::from) + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index ca0b8fc1..8e78f38e 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -14,6 +14,7 @@ pub mod agent; pub mod background; pub mod chat_attachments; +pub mod chat_drafts; pub mod conversation; pub mod device; pub mod diag; @@ -76,6 +77,10 @@ pub use chat_attachments::{ ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments, ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES, }; +pub use chat_drafts::{ + ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, ReadChatDraftOutput, SaveChatDraft, + SaveChatDraftInput, +}; pub use conversation::{ ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView, diff --git a/crates/application/tests/chat_drafts.rs b/crates/application/tests/chat_drafts.rs new file mode 100644 index 00000000..c716d13b --- /dev/null +++ b/crates/application/tests/chat_drafts.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::ports::{ChatDraftStore, Clock, ProjectStore, StoreError}; +use domain::{ + ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId, ProjectPath, RemoteRef, +}; +use uuid::Uuid; + +use application::{ + ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, SaveChatDraft, SaveChatDraftInput, +}; + +#[derive(Default)] +struct FakeProjects { + projects: Mutex>, +} + +#[async_trait] +impl ProjectStore for FakeProjects { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.projects.lock().unwrap().values().cloned().collect()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.projects + .lock() + .unwrap() + .get(&id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.projects + .lock() + .unwrap() + .insert(project.id, project.clone()); + Ok(()) + } + + async fn save_workspace( + &self, + _workspace: &domain::layout::Workspace, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Err(StoreError::NotFound) + } +} + +#[derive(Default)] +struct FakeDrafts { + drafts: Mutex>, + saves: Mutex>, + clears: Mutex>, +} + +#[async_trait] +impl ChatDraftStore for FakeDrafts { + async fn read( + &self, + _root: &ProjectPath, + key: ChatDraftKey, + ) -> Result, StoreError> { + Ok(self.drafts.lock().unwrap().get(&key).cloned()) + } + + async fn save(&self, _root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> { + self.saves.lock().unwrap().push(draft.clone()); + self.drafts.lock().unwrap().insert(draft.key, draft); + Ok(()) + } + + async fn clear(&self, _root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> { + self.clears.lock().unwrap().push(key); + self.drafts.lock().unwrap().remove(&key); + Ok(()) + } +} + +struct FixedClock; + +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + 1234 + } +} + +fn pid() -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(1)) +} + +fn lid(n: u128) -> LayoutId { + LayoutId::from_uuid(Uuid::from_u128(n)) +} + +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} + +fn project() -> Project { + Project::new( + pid(), + "P", + ProjectPath::new("/tmp/idea-chat-drafts-test").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap() +} + +fn scope(layout_id: LayoutId, node_id: NodeId) -> ChatDraftScopeInput { + ChatDraftScopeInput { + project_id: pid(), + layout_id, + node_id, + } +} + +#[tokio::test] +async fn save_and_read_chat_draft_round_trip_text_only() { + let projects = Arc::new(FakeProjects::default()); + projects.save_project(&project()).await.unwrap(); + let drafts = Arc::new(FakeDrafts::default()); + let save = SaveChatDraft::new( + Arc::clone(&projects) as Arc, + Arc::clone(&drafts) as Arc, + Arc::new(FixedClock), + ); + let read = ReadChatDraft::new( + Arc::clone(&projects) as Arc, + Arc::clone(&drafts) as Arc, + ); + + save.execute(SaveChatDraftInput { + scope: scope(lid(2), nid(3)), + text: "hello draft".to_owned(), + }) + .await + .unwrap(); + + let output = read.execute(scope(lid(2), nid(3))).await.unwrap(); + assert_eq!(output.text.as_deref(), Some("hello draft")); + assert_eq!(output.updated_at_ms, Some(1234)); + assert_eq!(drafts.saves.lock().unwrap()[0].text, "hello draft"); +} + +#[tokio::test] +async fn save_empty_text_clears_draft() { + let projects = Arc::new(FakeProjects::default()); + projects.save_project(&project()).await.unwrap(); + let drafts = Arc::new(FakeDrafts::default()); + let key = ChatDraftKey::new(pid(), lid(2), nid(3)); + drafts + .drafts + .lock() + .unwrap() + .insert(key, ChatDraft::new(key, "old", 1).unwrap()); + let save = SaveChatDraft::new( + Arc::clone(&projects) as Arc, + Arc::clone(&drafts) as Arc, + Arc::new(FixedClock), + ); + + save.execute(SaveChatDraftInput { + scope: scope(lid(2), nid(3)), + text: String::new(), + }) + .await + .unwrap(); + + assert!(drafts.drafts.lock().unwrap().is_empty()); + assert_eq!(drafts.clears.lock().unwrap().as_slice(), &[key]); +} + +#[tokio::test] +async fn clear_chat_draft_is_scoped_by_layout_and_node() { + let projects = Arc::new(FakeProjects::default()); + projects.save_project(&project()).await.unwrap(); + let drafts = Arc::new(FakeDrafts::default()); + let keep = ChatDraftKey::new(pid(), lid(2), nid(4)); + let clear = ChatDraftKey::new(pid(), lid(2), nid(3)); + drafts + .drafts + .lock() + .unwrap() + .insert(keep, ChatDraft::new(keep, "keep", 1).unwrap()); + drafts + .drafts + .lock() + .unwrap() + .insert(clear, ChatDraft::new(clear, "clear", 1).unwrap()); + let usecase = ClearChatDraft::new( + Arc::clone(&projects) as Arc, + Arc::clone(&drafts) as Arc, + ); + + usecase.execute(scope(lid(2), nid(3))).await.unwrap(); + + let drafts = drafts.drafts.lock().unwrap(); + assert!(drafts.contains_key(&keep)); + assert!(!drafts.contains_key(&clear)); +} + +#[tokio::test] +async fn oversized_chat_draft_is_rejected_before_store_call() { + let projects = Arc::new(FakeProjects::default()); + projects.save_project(&project()).await.unwrap(); + let drafts = Arc::new(FakeDrafts::default()); + let save = SaveChatDraft::new( + Arc::clone(&projects) as Arc, + Arc::clone(&drafts) as Arc, + Arc::new(FixedClock), + ); + + let err = save + .execute(SaveChatDraftInput { + scope: scope(lid(2), nid(3)), + text: "x".repeat(domain::CHAT_DRAFT_MAX_BYTES + 1), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(drafts.saves.lock().unwrap().is_empty()); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 1cfab0ab..9a03a754 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3100,6 +3100,42 @@ impl From for TerminalSessionDto { // Structured chat sessions (§17 — D4) // --------------------------------------------------------------------------- +/// Request scoped to one persisted custom chat draft. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatDraftScopeRequestDto { + /// Project id. + pub project_id: String, + /// Named layout id. + pub layout_id: String, + /// Layout node id. + pub node_id: String, +} + +/// Request for saving one custom chat draft. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveChatDraftRequestDto { + /// Project id. + pub project_id: String, + /// Named layout id. + pub layout_id: String, + /// Layout node id. + pub node_id: String, + /// Draft text. Empty text clears the draft. + pub text: String, +} + +/// Response for reading one custom chat draft. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadChatDraftResponseDto { + /// Draft text, or `null` when absent. + pub text: Option, + /// Last update time, epoch milliseconds, or `null` when absent. + pub updated_at_ms: Option, +} + /// Structured attachment intent sent by the chat frontend. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 731eab5e..da3050c8 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -16,7 +16,7 @@ use application::{ AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, - CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, + CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, @@ -40,30 +40,31 @@ use application::{ PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, - ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, - ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, - ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, - RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, - SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, - SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, - UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, - UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, - UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, + ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, + RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, + ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, + ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, + ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, + SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry, + SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, + StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, + UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, + UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, - BackgroundTaskStore, ChatAttachmentStore, Clock, DeviceSessionStore, Embedder, + BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator, @@ -91,13 +92,13 @@ use infrastructure::{ BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, - FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsConversationLog, - FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, - FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, - FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, - FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, - FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, - FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, + FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore, + FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, + FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, + FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, + FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, + FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, + FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, @@ -1023,6 +1024,12 @@ pub struct BackendCore { pub mark_issue_attachment_summarized: Arc, /// Import durable agent/chat attachments. pub import_chat_attachments: Arc, + /// Read one persisted custom chat draft. + pub read_chat_draft: Arc, + /// Save one persisted custom chat draft. + pub save_chat_draft: Arc, + /// Clear one persisted custom chat draft. + pub clear_chat_draft: Arc, /// Link two public tickets. pub link_issues: Arc, /// Unlink public tickets. @@ -1711,6 +1718,8 @@ impl BackendCore { let chat_attachment_store = Arc::new(FsChatAttachmentStore::new()); let chat_attachment_store_port = Arc::clone(&chat_attachment_store) as Arc; + let chat_draft_store = Arc::new(FsChatDraftStore::new()); + let chat_draft_store_port = Arc::clone(&chat_draft_store) as Arc; let create_issue = Arc::new(CreateIssue::new( Arc::clone(&issue_store_port), Arc::clone(&issue_allocator_port), @@ -1769,6 +1778,19 @@ impl BackendCore { Arc::clone(&ids) as Arc, Arc::clone(&clock) as Arc, )); + let read_chat_draft = Arc::new(ReadChatDraft::new( + Arc::clone(&store_port), + Arc::clone(&chat_draft_store_port), + )); + let save_chat_draft = Arc::new(SaveChatDraft::new( + Arc::clone(&store_port), + Arc::clone(&chat_draft_store_port), + Arc::clone(&clock) as Arc, + )); + let clear_chat_draft = Arc::new(ClearChatDraft::new( + Arc::clone(&store_port), + Arc::clone(&chat_draft_store_port), + )); let link_issues = Arc::new(LinkIssues::new( Arc::clone(&issue_store_port), Arc::clone(&clock) as Arc, @@ -3001,6 +3023,9 @@ impl BackendCore { read_issue_attachment, mark_issue_attachment_summarized, import_chat_attachments, + read_chat_draft, + save_chat_draft, + clear_chat_draft, link_issues, unlink_issues, assign_issue_agent, diff --git a/crates/domain/src/chat_draft.rs b/crates/domain/src/chat_draft.rs new file mode 100644 index 00000000..67249cff --- /dev/null +++ b/crates/domain/src/chat_draft.rs @@ -0,0 +1,84 @@ +//! Persisted custom chat draft model. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ids::{LayoutId, NodeId, ProjectId}; + +/// Maximum draft text size persisted by IdeA. +pub const CHAT_DRAFT_MAX_BYTES: usize = 100 * 1024; + +/// Stable scope for one custom chat draft. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatDraftKey { + /// Project owning the layout. + pub project_id: ProjectId, + /// Named layout containing the chat cell. + pub layout_id: LayoutId, + /// Layout node hosting the chat cell. + pub node_id: NodeId, +} + +impl ChatDraftKey { + /// Builds a draft key. + #[must_use] + pub const fn new(project_id: ProjectId, layout_id: LayoutId, node_id: NodeId) -> Self { + Self { + project_id, + layout_id, + node_id, + } + } +} + +/// Durable text draft for one custom chat composer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatDraft { + /// Stable draft scope. + pub key: ChatDraftKey, + /// Draft text. Attachments are deliberately excluded. + pub text: String, + /// Last update time, epoch milliseconds. + pub updated_at_ms: u64, +} + +impl ChatDraft { + /// Builds validated draft metadata. + /// + /// # Errors + /// Returns [`ChatDraftError::TooLarge`] when `text` exceeds the maximum + /// persisted draft size. + pub fn new( + key: ChatDraftKey, + text: impl Into, + updated_at_ms: u64, + ) -> Result { + let text = text.into(); + if text.len() > CHAT_DRAFT_MAX_BYTES { + return Err(ChatDraftError::TooLarge { + max: CHAT_DRAFT_MAX_BYTES, + actual: text.len(), + }); + } + Ok(Self { + key, + text, + updated_at_ms, + }) + } +} + +/// Chat draft validation errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ChatDraftError { + /// Draft content exceeds the persisted size limit. + #[error("chat draft exceeds {max} bytes: {actual}")] + TooLarge { + /// Maximum allowed bytes. + max: usize, + /// Actual text bytes. + actual: usize, + }, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index cd168797..c3fba43b 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -34,6 +34,7 @@ pub mod agent; pub mod agent_tool_policy; pub mod background_task; pub mod chat_attachment; +pub mod chat_draft; pub mod conversation; pub mod conversation_log; pub mod device; @@ -106,6 +107,7 @@ pub use background_task::{ pub use chat_attachment::{ ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind, }; +pub use chat_draft::{ChatDraft, ChatDraftError, ChatDraftKey, CHAT_DRAFT_MAX_BYTES}; pub use skill::{Skill, SkillKind, SkillRef, SkillScope}; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 7ac3ca75..1967de46 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -35,6 +35,7 @@ use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind}; +use crate::chat_draft::{ChatDraft, ChatDraftKey}; use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice}; use crate::events::DomainEvent; use crate::ids::{ @@ -1251,6 +1252,23 @@ pub trait ChatAttachmentStore: Send + Sync { ) -> Result, ChatAttachmentStoreError>; } +/// Persistence port for project-scoped custom chat drafts. +#[async_trait] +pub trait ChatDraftStore: Send + Sync { + /// Reads one draft. Missing entries return `Ok(None)`. + async fn read( + &self, + root: &ProjectPath, + key: ChatDraftKey, + ) -> Result, StoreError>; + + /// Saves or replaces one draft. + async fn save(&self, root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError>; + + /// Clears one draft. Missing entries are a no-op. + async fn clear(&self, root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError>; +} + /// Errors from the sprint store. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum SprintStoreError { diff --git a/crates/domain/tests/chat_draft.rs b/crates/domain/tests/chat_draft.rs new file mode 100644 index 00000000..79f208a7 --- /dev/null +++ b/crates/domain/tests/chat_draft.rs @@ -0,0 +1,26 @@ +use domain::{ChatDraft, ChatDraftError, ChatDraftKey, LayoutId, NodeId, ProjectId}; +use uuid::Uuid; + +fn key() -> ChatDraftKey { + ChatDraftKey::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + LayoutId::from_uuid(Uuid::from_u128(2)), + NodeId::from_uuid(Uuid::from_u128(3)), + ) +} + +#[test] +fn chat_draft_accepts_text_and_scope() { + let draft = ChatDraft::new(key(), "hello", 42).unwrap(); + + assert_eq!(draft.key, key()); + assert_eq!(draft.text, "hello"); + assert_eq!(draft.updated_at_ms, 42); +} + +#[test] +fn chat_draft_rejects_oversized_text() { + let err = ChatDraft::new(key(), "x".repeat(domain::CHAT_DRAFT_MAX_BYTES + 1), 42).unwrap_err(); + + assert!(matches!(err, ChatDraftError::TooLarge { .. })); +} diff --git a/crates/infrastructure/src/chat_drafts.rs b/crates/infrastructure/src/chat_drafts.rs new file mode 100644 index 00000000..4ed98be6 --- /dev/null +++ b/crates/infrastructure/src/chat_drafts.rs @@ -0,0 +1,125 @@ +//! Filesystem custom chat draft store. +//! +//! Drafts are persisted per project under `/.ideai/chat-drafts.json`. + +use std::path::PathBuf; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ports::{ChatDraftStore, StoreError}; +use domain::{ChatDraft, ChatDraftKey, ProjectPath}; + +const IDEAI_DIR: &str = ".ideai"; +const CHAT_DRAFTS_FILE: &str = "chat-drafts.json"; +const CHAT_DRAFTS_TMP_FILE: &str = "chat-drafts.json.tmp"; +const CHAT_DRAFTS_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChatDraftsDoc { + version: u32, + drafts: Vec, +} + +impl Default for ChatDraftsDoc { + fn default() -> Self { + Self { + version: CHAT_DRAFTS_VERSION, + drafts: Vec::new(), + } + } +} + +/// Filesystem-backed [`ChatDraftStore`]. +#[derive(Debug, Clone, Default)] +pub struct FsChatDraftStore; + +impl FsChatDraftStore { + /// Builds a store. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +fn dir(root: &ProjectPath) -> PathBuf { + PathBuf::from(root.as_str()).join(IDEAI_DIR) +} + +fn path(root: &ProjectPath) -> PathBuf { + dir(root).join(CHAT_DRAFTS_FILE) +} + +fn tmp_path(root: &ProjectPath) -> PathBuf { + dir(root).join(CHAT_DRAFTS_TMP_FILE) +} + +fn io_error(err: std::io::Error) -> StoreError { + StoreError::Io(err.to_string()) +} + +async fn read_doc(root: &ProjectPath) -> Result { + match tokio::fs::read(path(root)).await { + Ok(bytes) => { + let doc: ChatDraftsDoc = serde_json::from_slice(&bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + if doc.version != CHAT_DRAFTS_VERSION { + return Err(StoreError::Invalid(format!( + "unsupported chat drafts version {}", + doc.version + ))); + } + Ok(doc) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ChatDraftsDoc::default()), + Err(err) => Err(io_error(err)), + } +} + +async fn write_doc(root: &ProjectPath, doc: &ChatDraftsDoc) -> Result<(), StoreError> { + let dir = dir(root); + tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; + let bytes = + serde_json::to_vec_pretty(doc).map_err(|err| StoreError::Serialization(err.to_string()))?; + let tmp = tmp_path(root); + tokio::fs::write(&tmp, bytes).await.map_err(io_error)?; + tokio::fs::rename(tmp, path(root)).await.map_err(io_error) +} + +#[async_trait] +impl ChatDraftStore for FsChatDraftStore { + async fn read( + &self, + root: &ProjectPath, + key: ChatDraftKey, + ) -> Result, StoreError> { + Ok(read_doc(root) + .await? + .drafts + .into_iter() + .find(|draft| draft.key == key)) + } + + async fn save(&self, root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> { + let mut doc = read_doc(root).await?; + if let Some(slot) = doc + .drafts + .iter_mut() + .find(|existing| existing.key == draft.key) + { + *slot = draft; + } else { + doc.drafts.push(draft); + } + doc.drafts + .sort_by_key(|draft| (draft.key.project_id, draft.key.layout_id, draft.key.node_id)); + write_doc(root, &doc).await + } + + async fn clear(&self, root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> { + let mut doc = read_doc(root).await?; + doc.drafts.retain(|draft| draft.key != key); + write_doc(root, &doc).await + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index e9528b52..c46d95f4 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -15,6 +15,7 @@ pub mod assistant; pub mod background_task; pub mod chat_attachments; +pub mod chat_drafts; pub mod clock; pub mod conversation; pub mod conversation_log; @@ -55,6 +56,7 @@ pub use background_task::{ CommandBackgroundRunner, }; pub use chat_attachments::FsChatAttachmentStore; +pub use chat_drafts::FsChatDraftStore; pub use clock::SystemClock; pub use conversation::InMemoryConversationRegistry; pub use conversation_log::{ diff --git a/crates/infrastructure/tests/chat_drafts.rs b/crates/infrastructure/tests/chat_drafts.rs new file mode 100644 index 00000000..f18c61e3 --- /dev/null +++ b/crates/infrastructure/tests/chat_drafts.rs @@ -0,0 +1,110 @@ +use domain::ports::{ChatDraftStore, IdGenerator}; +use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, ProjectId, ProjectPath}; +use infrastructure::{FsChatDraftStore, UuidGenerator}; +use uuid::Uuid; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "idea-chat-draft-{tag}-{}", + UuidGenerator::new().new_uuid() + )) +} + +fn pid(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} + +fn lid(n: u128) -> LayoutId { + LayoutId::from_uuid(Uuid::from_u128(n)) +} + +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} + +fn key(layout: u128, node: u128) -> ChatDraftKey { + ChatDraftKey::new(pid(1), lid(layout), nid(node)) +} + +#[tokio::test] +async fn fs_chat_draft_store_saves_reads_and_replaces_one_scope() { + let project_root = temp_dir("roundtrip"); + tokio::fs::create_dir_all(&project_root).await.unwrap(); + let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(); + let store = FsChatDraftStore::new(); + + store + .save(&root, ChatDraft::new(key(2, 3), "first", 10).unwrap()) + .await + .unwrap(); + store + .save(&root, ChatDraft::new(key(2, 3), "second", 11).unwrap()) + .await + .unwrap(); + + let draft = store.read(&root, key(2, 3)).await.unwrap().unwrap(); + assert_eq!(draft.text, "second"); + assert_eq!(draft.updated_at_ms, 11); + assert!( + tokio::fs::metadata(project_root.join(".ideai/chat-drafts.json")) + .await + .is_ok() + ); + + let _ = tokio::fs::remove_dir_all(project_root).await; +} + +#[tokio::test] +async fn fs_chat_draft_store_scopes_by_layout_and_node() { + let project_root = temp_dir("scoped"); + tokio::fs::create_dir_all(&project_root).await.unwrap(); + let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(); + let store = FsChatDraftStore::new(); + + store + .save(&root, ChatDraft::new(key(2, 2), "node two", 10).unwrap()) + .await + .unwrap(); + store + .save(&root, ChatDraft::new(key(2, 3), "node three", 11).unwrap()) + .await + .unwrap(); + + assert_eq!( + store.read(&root, key(2, 2)).await.unwrap().unwrap().text, + "node two" + ); + assert_eq!( + store.read(&root, key(2, 3)).await.unwrap().unwrap().text, + "node three" + ); + + let _ = tokio::fs::remove_dir_all(project_root).await; +} + +#[tokio::test] +async fn fs_chat_draft_store_clear_removes_only_target_scope() { + let project_root = temp_dir("clear"); + tokio::fs::create_dir_all(&project_root).await.unwrap(); + let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(); + let store = FsChatDraftStore::new(); + + store + .save(&root, ChatDraft::new(key(2, 2), "clear", 10).unwrap()) + .await + .unwrap(); + store + .save(&root, ChatDraft::new(key(2, 3), "keep", 11).unwrap()) + .await + .unwrap(); + + store.clear(&root, key(2, 2)).await.unwrap(); + + assert!(store.read(&root, key(2, 2)).await.unwrap().is_none()); + assert_eq!( + store.read(&root, key(2, 3)).await.unwrap().unwrap().text, + "keep" + ); + + let _ = tokio::fs::remove_dir_all(project_root).await; +} diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 13769cf7..52aca821 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -253,6 +253,33 @@ describe("TauriAgentGateway invoke payloads", () => { expect(out.effect.kind).toBe("cleanConversation"); }); + it("wraps chat draft persistence commands with the project/layout/node scope", async () => { + const gateway = new TauriAgentGateway(); + const scope = { + projectId: "proj-1", + layoutId: "layout-1", + nodeId: "node-1", + }; + invoke.mockResolvedValueOnce({ text: "hello", updatedAtMs: 42 }); + + await expect(gateway.readChatDraft(scope)).resolves.toEqual({ + text: "hello", + updatedAtMs: 42, + }); + await gateway.saveChatDraft(scope, "edited"); + await gateway.clearChatDraft(scope); + + expect(invoke).toHaveBeenNthCalledWith(1, "read_chat_draft", { + request: scope, + }); + expect(invoke).toHaveBeenNthCalledWith(2, "save_chat_draft", { + request: { ...scope, text: "edited" }, + }); + expect(invoke).toHaveBeenNthCalledWith(3, "clear_chat_draft", { + request: scope, + }); + }); + it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => { invoke.mockResolvedValueOnce({ sessionId: "chat-session-1", diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 73ef6296..3aaca1e6 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -28,6 +28,8 @@ import type { import type { AgentChatHandle, AgentGateway, + ChatDraftScope, + ChatDraftSnapshot, ConversationDetails, CreateAgentInput, LiveAgent, @@ -297,6 +299,24 @@ export class TauriAgentGateway implements AgentGateway { }); } + readChatDraft(scope: ChatDraftScope): Promise { + return invoke("read_chat_draft", { + request: scope, + }); + } + + async saveChatDraft(scope: ChatDraftScope, text: string): Promise { + await invoke("save_chat_draft", { + request: { ...scope, text }, + }); + } + + async clearChatDraft(scope: ChatDraftScope): Promise { + await invoke("clear_chat_draft", { + request: scope, + }); + } + async closeAgentChat(sessionId: string): Promise { await invoke("close_agent_session", { sessionId }); } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 4e32fe71..e4b3b4c8 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -41,6 +41,8 @@ import type { } from "@/domain"; import type { AgentGateway, + ChatDraftScope, + ChatDraftSnapshot, ConversationDetails, CreateAgentInput, CreateTicketInput, @@ -255,6 +257,18 @@ export class HttpAgentGateway implements AgentGateway { }); } + readChatDraft(scope: ChatDraftScope): Promise { + return this.http.invoke("read_chat_draft", { request: scope }); + } + + async saveChatDraft(scope: ChatDraftScope, text: string): Promise { + await this.http.invoke("save_chat_draft", { request: { ...scope, text } }); + } + + async clearChatDraft(scope: ChatDraftScope): Promise { + await this.http.invoke("clear_chat_draft", { request: scope }); + } + async launchAgent( projectId: string, agentId: string, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 10e9016e..01f7e499 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -103,6 +103,8 @@ import type { import type { AgentChatHandle, AgentGateway, + ChatDraftScope, + ChatDraftSnapshot, ConversationGateway, ConversationPageRequest, ConversationDetails, @@ -401,6 +403,8 @@ export class MockAgentGateway implements AgentGateway { private liveKindByAgent = new Map(); /** Retained structured reply chunks per live chat session. */ private chatScrollback = new Map(); + /** Persisted custom chat drafts keyed by project/layout/node. */ + private chatDrafts = new Map(); private slashCommands: SlashCommand[] = [ { name: "/help", @@ -437,6 +441,10 @@ export class MockAgentGateway implements AgentGateway { return `${projectId}::${agentId}`; } + private chatDraftKey(scope: ChatDraftScope): string { + return `${scope.projectId}::${scope.layoutId}::${scope.nodeId}`; + } + async listAgents(projectId: string): Promise { return structuredClone(this.getAgents(projectId)); } @@ -985,6 +993,28 @@ export class MockAgentGateway implements AgentGateway { }; } + async readChatDraft(scope: ChatDraftScope): Promise { + return structuredClone( + this.chatDrafts.get(this.chatDraftKey(scope)) ?? { text: null, updatedAtMs: null }, + ); + } + + async saveChatDraft(scope: ChatDraftScope, text: string): Promise { + const key = this.chatDraftKey(scope); + if (text.length === 0) { + this.chatDrafts.delete(key); + return; + } + this.chatDrafts.set(key, { + text, + updatedAtMs: Date.now(), + }); + } + + async clearChatDraft(scope: ChatDraftScope): Promise { + this.chatDrafts.delete(this.chatDraftKey(scope)); + } + async closeAgentChat(sessionId: string): Promise { this.chatScrollback.delete(sessionId); for (const [agentId, liveSessionId] of this.liveSessionByAgent) { diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 5c272ff2..5f7b7325 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -11,7 +11,7 @@ import { PluginMenuRegistry, PluginRuntimeRegistry, } from "@/plugins/runtime"; -import type { Gateways } from "@/ports"; +import type { ChatDraftScope, Gateways } from "@/ports"; import { CustomAgentChatView } from "./CustomAgentChatView"; const profile: AgentProfile = { @@ -1718,6 +1718,317 @@ describe("CustomAgentChatView", () => { ); }); + it("restores and saves drafts scoped by project, layout, and node", async () => { + const drafts = new Map([ + ["project-1::layout-a::node-1", "restored draft"], + ]); + const key = (scope: ChatDraftScope) => + `${scope.projectId}::${scope.layoutId}::${scope.nodeId}`; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + readChatDraft: vi.fn(async (scope: ChatDraftScope) => ({ + text: drafts.get(key(scope)) ?? null, + updatedAtMs: null, + })), + saveChatDraft: vi.fn(async (scope: ChatDraftScope, text: string) => { + if (text) drafts.set(key(scope), text); + else drafts.delete(key(scope)); + }), + clearChatDraft: vi.fn(async (scope: ChatDraftScope) => { + drafts.delete(key(scope)); + }), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const baseProps = { + agentId: "agent-1", + agentName: "Worker", + profile, + cwd: "/repo", + nodeId: "node-1", + sessionId: "chat-session-1", + conversationId: "conversation-1", + onSessionId: vi.fn(), + onConversationId: vi.fn(), + }; + + const { rerender } = render( + null) }, + } as unknown as Gateways} + > + + , + ); + + const composer = await screen.findByDisplayValue("restored draft"); + fireEvent.change(composer, { target: { value: "edited draft" } }); + + await waitFor(() => + expect(agent.saveChatDraft).toHaveBeenCalledWith( + { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }, + "edited draft", + ), + ); + + rerender( + null) }, + } as unknown as Gateways} + > + + , + ); + await waitFor(() => + expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""), + ); + + rerender( + null) }, + } as unknown as Gateways} + > + + , + ); + await screen.findByDisplayValue("edited draft"); + + rerender( + null) }, + } as unknown as Gateways} + > + + , + ); + await waitFor(() => + expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""), + ); + }); + + it("does not persist attachments with the restored draft", async () => { + const drafts = new Map(); + const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + readChatDraft: vi.fn(async () => ({ + text: drafts.get("draft") ?? null, + updatedAtMs: null, + })), + saveChatDraft: vi.fn(async (_scope: ChatDraftScope, text: string) => { + drafts.set("draft", text); + }), + clearChatDraft: vi.fn(async () => { + drafts.delete("draft"); + }), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const renderView = () => + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + const first = renderView(); + await waitFor(() => + expect(agent.readChatDraft).toHaveBeenCalledWith(scope), + ); + const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: "draft with file" } }); + + const file = new File(["ignored"], "not-persisted.png", { type: "image/png" }); + Object.defineProperty(file, "arrayBuffer", { + value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), + }); + fireEvent.paste(composer, { + clipboardData: { + items: [ + { + kind: "file", + type: "image/png", + getAsFile: () => file, + }, + ], + getData: () => "", + }, + }); + await screen.findByText("Fichier joint: not-persisted.png"); + await waitFor(() => + expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file"), + ); + + first.unmount(); + renderView(); + + await screen.findByDisplayValue("draft with file"); + expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull(); + }); + + it("clears the persisted draft when sending the composer", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + readChatDraft: vi.fn(async () => ({ text: "to send", updatedAtMs: null })), + saveChatDraft: vi.fn(async () => {}), + clearChatDraft: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await screen.findByDisplayValue("to send"); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); + expect(agent.clearChatDraft).toHaveBeenCalledWith(scope); + expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""); + }); + + it("clears the persisted draft when a slash command is consumed", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async () => ({ + command: { + name: "/help", + shortDescription: "Afficher les commandes disponibles", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: "native" as const, + native: "help" as const, + }, + effect: { + kind: "help" as const, + commands: [], + }, + })), + readChatDraft: vi.fn(async () => ({ text: "/help", updatedAtMs: null })), + saveChatDraft: vi.fn(async () => {}), + clearChatDraft: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await screen.findByDisplayValue("/help"); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => + expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", { + sessionId: "chat-session-1", + }), + ); + expect(agent.clearChatDraft).toHaveBeenCalledWith(scope); + expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""); + }); + it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => { const agent = { launchAgentChat: vi.fn(() => new Promise(() => {})), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index a1b34188..3d4fa7d7 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -28,7 +28,7 @@ import type { import { useGateways } from "@/app/di"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { Button, Spinner, cn } from "@/shared"; -import type { ChatAttachmentInput } from "@/ports"; +import type { ChatAttachmentInput, ChatDraftScope } from "@/ports"; export interface CustomAgentChatViewProps { projectId: string; @@ -36,6 +36,7 @@ export interface CustomAgentChatViewProps { agentName: string; profile: AgentProfile; cwd: string; + layoutId?: string; nodeId: string; sessionId: string | null; conversationId: string | null; @@ -464,6 +465,7 @@ export function CustomAgentChatView({ agentName, profile, cwd, + layoutId, nodeId, sessionId, conversationId, @@ -490,6 +492,12 @@ export function CustomAgentChatView({ const sessionRef = useRef(sessionId); sessionRef.current = currentSession; const slashQuerySeqRef = useRef(0); + const draftSaveSeqRef = useRef(0); + const draftHydratedRef = useRef(false); + const draftEditedBeforeHydrationRef = useRef(false); + const lastPersistedDraftRef = useRef(""); + const draftRef = useRef(""); + draftRef.current = draft; const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); const selfEmittedSessionIdRef = useRef(undefined); @@ -507,6 +515,30 @@ export function CustomAgentChatView({ agent.closeAgentChat, ); const slashPrefix = slashCommandPrefix(draft); + const draftScope = useMemo( + () => (layoutId ? { projectId, layoutId, nodeId } : null), + [layoutId, nodeId, projectId], + ); + + const clearPersistedDraft = useCallback(async () => { + if (!draftScope) return; + draftSaveSeqRef.current += 1; + lastPersistedDraftRef.current = ""; + try { + if (agent.clearChatDraft) { + await agent.clearChatDraft(draftScope); + } else if (agent.saveChatDraft) { + await agent.saveChatDraft(draftScope, ""); + } + } catch (e) { + console.warn("[chat-draft] clear failed", instrumentationError(e)); + } + }, [agent, draftScope]); + + const editDraft = useCallback((text: string) => { + if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; + setDraft(text); + }, []); useEffect(() => { mountedRef.current = true; @@ -520,6 +552,79 @@ export function CustomAgentChatView({ if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); }, []); + useEffect(() => { + draftSaveSeqRef.current += 1; + draftHydratedRef.current = false; + draftEditedBeforeHydrationRef.current = false; + lastPersistedDraftRef.current = ""; + setDraft(""); + if (!draftScope || !agent.readChatDraft) { + draftHydratedRef.current = true; + return; + } + + let cancelled = false; + agent + .readChatDraft(draftScope) + .then((snapshot) => { + if (cancelled || !mountedRef.current) return; + const restored = snapshot.text ?? ""; + lastPersistedDraftRef.current = restored; + if (!draftEditedBeforeHydrationRef.current) { + setDraft(restored); + } + }) + .catch((e) => { + if (!cancelled) { + console.warn("[chat-draft] read failed", instrumentationError(e)); + } + }) + .finally(() => { + if (!cancelled) { + draftHydratedRef.current = true; + if ( + draftEditedBeforeHydrationRef.current && + agent.saveChatDraft && + draftRef.current !== lastPersistedDraftRef.current + ) { + const text = draftRef.current; + void agent + .saveChatDraft(draftScope, text) + .then(() => { + lastPersistedDraftRef.current = text; + }) + .catch((e) => { + console.warn("[chat-draft] save failed", instrumentationError(e)); + }); + } + } + }); + return () => { + cancelled = true; + }; + }, [agent, draftScope]); + + useEffect(() => { + if (!draftScope || !agent.saveChatDraft || !draftHydratedRef.current) return; + if (draft === lastPersistedDraftRef.current) return; + + const seq = ++draftSaveSeqRef.current; + const timeout = window.setTimeout(() => { + if (draftSaveSeqRef.current !== seq) return; + agent + .saveChatDraft!(draftScope, draft) + .then(() => { + if (draftSaveSeqRef.current === seq) { + lastPersistedDraftRef.current = draft; + } + }) + .catch((e) => { + console.warn("[chat-draft] save failed", instrumentationError(e)); + }); + }, 250); + return () => window.clearTimeout(timeout); + }, [agent, draft, draftScope]); + useEffect(() => { if ( !supported || @@ -554,12 +659,12 @@ export function CustomAgentChatView({ const selectSlashCommand = useCallback((command: SlashCommand) => { if (!isSlashCommandAvailable(command)) return; - setDraft(`${command.name} `); + editDraft(`${command.name} `); setSlashMenuOpen(false); setSlashCommands([]); setSlashActiveIndex(0); requestAnimationFrame(() => composerRef.current?.focus()); - }, []); + }, [editDraft]); const publishSessionId = useCallback((nextSessionId: string | null) => { selfEmittedSessionIdRef.current = nextSessionId; @@ -887,6 +992,7 @@ export function CustomAgentChatView({ return; } setDraft(""); + void clearPersistedDraft(); setError(null); try { const sid = @@ -979,6 +1085,7 @@ export function CustomAgentChatView({ return unique.length > 0 ? [...prev, ...unique] : prev; }); if (pastedText) { + if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; setDraft((prev) => prev.slice(0, selectionStart) + pastedText + @@ -1004,6 +1111,7 @@ export function CustomAgentChatView({ const displayText = text || "Pièce jointe"; setDraft(""); setAttachments([]); + void clearPersistedDraft(); setBusy(true); setError(null); setTurns((prev) => [ @@ -1305,7 +1413,7 @@ export function CustomAgentChatView({ value={draft} disabled={!supported || opening || busy} placeholder="Message à l'agent…" - onChange={(e) => setDraft(e.target.value)} + onChange={(e) => editDraft(e.target.value)} onPaste={(e) => void pasteClipboardFiles(e)} onKeyDown={(e) => { if (slashMenuOpen && slashCommands.length > 0) { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 31871b16..10b9c644 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -128,6 +128,7 @@ export function LayoutGrid({ vm={vm} parentSplit={null} projectId={projectId} + layoutId={layoutId} workState={work.state} refreshWorkState={work.refresh} refitSignal={refitEpoch} @@ -145,6 +146,7 @@ interface NodeViewProps { /** The enclosing split + this node's index in it, for the merge action. */ parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; + layoutId?: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; @@ -158,6 +160,7 @@ function NodeView({ vm, parentSplit, projectId, + layoutId, workState, refreshWorkState, refitSignal, @@ -191,6 +194,7 @@ function NodeView({ vm={vm} parentSplit={parentSplit} projectId={projectId} + layoutId={layoutId} workState={workState} refreshWorkState={refreshWorkState} refitSignal={refitSignal} @@ -203,6 +207,7 @@ function NodeView({ cwd={cwd} vm={vm} projectId={projectId} + layoutId={layoutId} workState={workState} refreshWorkState={refreshWorkState} refitSignal={refitSignal} @@ -217,6 +222,7 @@ function NodeView({ cwd={cwd} vm={vm} projectId={projectId} + layoutId={layoutId} workState={workState} refreshWorkState={refreshWorkState} refitSignal={refitSignal} @@ -236,6 +242,7 @@ interface LeafViewProps { vm: LayoutViewModel; parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; + layoutId?: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; @@ -339,6 +346,7 @@ function LeafView({ vm, parentSplit, projectId, + layoutId, workState, refreshWorkState, refitSignal, @@ -1247,6 +1255,7 @@ function LeafView({ agentName={customCliAgent.name} profile={customCliProfile} cwd={cwd} + layoutId={layoutId} nodeId={id} sessionId={session} conversationId={conversationId} @@ -1573,6 +1582,7 @@ interface SplitViewProps { cwd: string; vm: LayoutViewModel; projectId: string; + layoutId?: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; @@ -1585,6 +1595,7 @@ function SplitView({ cwd, vm, projectId, + layoutId, workState, refreshWorkState, refitSignal, @@ -1631,6 +1642,7 @@ function SplitView({ cwd={cwd} vm={vm} projectId={projectId} + layoutId={layoutId} workState={workState} refreshWorkState={refreshWorkState} refitSignal={refitSignal} @@ -1727,6 +1739,7 @@ interface GridViewProps { cwd: string; vm: LayoutViewModel; projectId: string; + layoutId?: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; @@ -1739,6 +1752,7 @@ function GridView({ cwd, vm, projectId, + layoutId, workState, refreshWorkState, refitSignal, @@ -1781,6 +1795,7 @@ function GridView({ vm={vm} parentSplit={null} projectId={projectId} + layoutId={layoutId} workState={workState} refreshWorkState={refreshWorkState} refitSignal={refitSignal} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 607127a7..38f87838 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -166,6 +166,17 @@ export interface SendAgentChatOptions { attachments?: ChatAttachmentInput[]; } +export interface ChatDraftScope { + projectId: string; + layoutId: string; + nodeId: string; +} + +export interface ChatDraftSnapshot { + text: string | null; + updatedAtMs?: number | null; +} + /** * Best-effort enriched details about a CLI conversation (T7), used to enrich the * resume popup. Both fields are optional: a missing inspector or a missing @@ -317,6 +328,12 @@ export interface AgentGateway { name: string, options?: { sessionId?: string | null; arguments?: unknown[] }, ): Promise; + /** Reads a persisted custom chat draft scoped by project/layout/node. */ + readChatDraft?(scope: ChatDraftScope): Promise; + /** Saves a custom chat draft. Empty text is accepted but callers usually clear explicitly. */ + saveChatDraft?(scope: ChatDraftScope, text: string): Promise; + /** Clears a persisted custom chat draft. */ + clearChatDraft?(scope: ChatDraftScope): Promise; /** Interrupts only the current turn of a live structured session. */ cancelAgentChat?(sessionId: string): Promise; /** Shuts a live structured session down. */ From 1aa9d01b2b523114b411ab93b544ab6cef782781 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 16:24:20 +0200 Subject: [PATCH 2/2] chore(tickets): sync miroir #182 (QA verte) --- .ideai/tickets/182/carnet.md | 9 ++++++--- .ideai/tickets/182/issue.md | 6 +++--- .ideai/tickets/index.json | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.ideai/tickets/182/carnet.md b/.ideai/tickets/182/carnet.md index 3d67f5ea..973e70fd 100644 --- a/.ideai/tickets/182/carnet.md +++ b/.ideai/tickets/182/carnet.md @@ -1,11 +1,14 @@ --- issueRef: "#182" -version: 3 +version: 6 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786455395341 +updatedAt: 1786458224057 --- 2026-08-11 - Ticket ouvert suite au signalement utilisateur. - Cadrage UX: restauration transparente du brouillon au retour sur la cellule/layout; pas de persistance des attachments; TTL court acceptable. - Arbitrage architecture validé: store backend JSON, scoped par projet/layout/node; pas de solution frontend-only. -- L'implémentation devra nettoyer le draft à l'envoi et après /clean si la conversation est vidée. \ No newline at end of file +- L'implémentation devra nettoyer le draft à l'envoi et après /clean si la conversation est vidée. +- Implémentation frontend dev reçue: intégration composer -> backend draft store avec scope projectId/layoutId/nodeId, sauvegarde debounced, restauration silencieuse, effacement après envoi et slash command consommée, pas de persistance des attachments. +- Implémentation backend dev reçue: modèle ChatDraft text-only, store JSON `.ideai/chat-drafts.json`, use cases read/save/clear par scope exact projet/layout/node, commandes Tauri `read_chat_draft`/`save_chat_draft`/`clear_chat_draft`. +- QA 2026-08-11: VERT. Vérifications exécutées avec succès: frontend CustomAgentChatView/agent tests, backend domain/application/infrastructure sur le scope et la persistance draft. QA a ajouté un test manquant sur l'effacement du draft après slash command consommée. \ No newline at end of file diff --git a/.ideai/tickets/182/issue.md b/.ideai/tickets/182/issue.md index 4752901c..4ffc179c 100644 --- a/.ideai/tickets/182/issue.md +++ b/.ideai/tickets/182/issue.md @@ -2,7 +2,7 @@ id: "82436920-96cc-4a05-a1c7-0555e90ef47c" number: 182 title: "Persister le brouillon de chat par layout et retour projet" -status: "inProgress" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1786454719252 -updatedAt: 1786455395341 -version: 3 +updatedAt: 1786458224057 +version: 6 --- Quand l'utilisateur tape un message dans le chat puis change de layout ou de projet, le texte saisi doit rester dans le chat quand il revient sur ce layout. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index fcdf8ab3..d4f5e6fb 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -2441,7 +2441,7 @@ "issueRef": "#182", "path": "182", "title": "Persister le brouillon de chat par layout et retour projet", - "status": "inProgress", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -2449,7 +2449,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786455395341 + "updatedAt": 1786458224057 }, { "issueRef": "#183",