merge(chat): persistance du brouillon par projet/layout (#182, QA verte)

This commit is contained in:
2026-08-11 16:24:20 +02:00
26 changed files with 1563 additions and 78 deletions

View File

@ -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.
- 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.

View File

@ -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.

View File

@ -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",

View File

@ -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<ChatDraftScopeInput, ErrorDto> {
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<ReadChatDraftResponseDto, ErrorDto> {
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).
///

View File

@ -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,

View File

@ -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!({

View File

@ -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<String>,
/// Last update time when a draft exists.
pub updated_at_ms: Option<u64>,
}
/// 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<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
}
impl ReadChatDraft {
/// Builds the use case.
#[must_use]
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
Self { projects, drafts }
}
/// Executes the read.
pub async fn execute(
&self,
input: ChatDraftScopeInput,
) -> Result<ReadChatDraftOutput, AppError> {
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<Project, AppError> {
self.projects
.load_project(project_id)
.await
.map_err(AppError::from)
}
}
/// Saves a persisted custom chat draft.
pub struct SaveChatDraft {
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
clock: Arc<dyn Clock>,
}
impl SaveChatDraft {
/// Builds the use case.
#[must_use]
pub fn new(
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
clock: Arc<dyn Clock>,
) -> 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<Project, AppError> {
self.projects
.load_project(project_id)
.await
.map_err(AppError::from)
}
}
/// Clears a persisted custom chat draft.
pub struct ClearChatDraft {
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
}
impl ClearChatDraft {
/// Builds the use case.
#[must_use]
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> 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<Project, AppError> {
self.projects
.load_project(project_id)
.await
.map_err(AppError::from)
}
}

View File

@ -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,

View File

@ -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<HashMap<ProjectId, Project>>,
}
#[async_trait]
impl ProjectStore for FakeProjects {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(self.projects.lock().unwrap().values().cloned().collect())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
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<domain::layout::Workspace, StoreError> {
Err(StoreError::NotFound)
}
}
#[derive(Default)]
struct FakeDrafts {
drafts: Mutex<HashMap<ChatDraftKey, ChatDraft>>,
saves: Mutex<Vec<ChatDraft>>,
clears: Mutex<Vec<ChatDraftKey>>,
}
#[async_trait]
impl ChatDraftStore for FakeDrafts {
async fn read(
&self,
_root: &ProjectPath,
key: ChatDraftKey,
) -> Result<Option<ChatDraft>, 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<dyn ProjectStore>,
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
Arc::new(FixedClock),
);
let read = ReadChatDraft::new(
Arc::clone(&projects) as Arc<dyn ProjectStore>,
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
);
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<dyn ProjectStore>,
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
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<dyn ProjectStore>,
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
);
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<dyn ProjectStore>,
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
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());
}

View File

@ -3100,6 +3100,42 @@ impl From<TerminalSession> 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<String>,
/// Last update time, epoch milliseconds, or `null` when absent.
pub updated_at_ms: Option<u64>,
}
/// Structured attachment intent sent by the chat frontend.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -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<MarkIssueAttachmentSummarized>,
/// Import durable agent/chat attachments.
pub import_chat_attachments: Arc<ImportChatAttachments>,
/// Read one persisted custom chat draft.
pub read_chat_draft: Arc<ReadChatDraft>,
/// Save one persisted custom chat draft.
pub save_chat_draft: Arc<SaveChatDraft>,
/// Clear one persisted custom chat draft.
pub clear_chat_draft: Arc<ClearChatDraft>,
/// Link two public tickets.
pub link_issues: Arc<LinkIssues>,
/// 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<dyn ChatAttachmentStore>;
let chat_draft_store = Arc::new(FsChatDraftStore::new());
let chat_draft_store_port = Arc::clone(&chat_draft_store) as Arc<dyn ChatDraftStore>;
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<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
));
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<dyn Clock>,
));
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<dyn Clock>,
@ -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,

View File

@ -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<String>,
updated_at_ms: u64,
) -> Result<Self, ChatDraftError> {
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,
},
}

View File

@ -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};

View File

@ -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<Vec<ChatAttachment>, 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<Option<ChatDraft>, 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 {

View File

@ -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 { .. }));
}

View File

@ -0,0 +1,125 @@
//! Filesystem custom chat draft store.
//!
//! Drafts are persisted per project under `<root>/.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<ChatDraft>,
}
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<ChatDraftsDoc, StoreError> {
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<Option<ChatDraft>, 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
}
}

View File

@ -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::{

View File

@ -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;
}

View File

@ -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",

View File

@ -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<ChatDraftSnapshot> {
return invoke<ChatDraftSnapshot>("read_chat_draft", {
request: scope,
});
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
await invoke("save_chat_draft", {
request: { ...scope, text },
});
}
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
await invoke("clear_chat_draft", {
request: scope,
});
}
async closeAgentChat(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId });
}

View File

@ -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<ChatDraftSnapshot> {
return this.http.invoke<ChatDraftSnapshot>("read_chat_draft", { request: scope });
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
await this.http.invoke("save_chat_draft", { request: { ...scope, text } });
}
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
await this.http.invoke("clear_chat_draft", { request: scope });
}
async launchAgent(
projectId: string,
agentId: string,

View File

@ -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<string, "pty" | "structured">();
/** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>();
/** Persisted custom chat drafts keyed by project/layout/node. */
private chatDrafts = new Map<string, ChatDraftSnapshot>();
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<Agent[]> {
return structuredClone(this.getAgents(projectId));
}
@ -985,6 +993,28 @@ export class MockAgentGateway implements AgentGateway {
};
}
async readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
return structuredClone(
this.chatDrafts.get(this.chatDraftKey(scope)) ?? { text: null, updatedAtMs: null },
);
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
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<void> {
this.chatDrafts.delete(this.chatDraftKey(scope));
}
async closeAgentChat(sessionId: string): Promise<void> {
this.chatScrollback.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {

View File

@ -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<string, string>([
["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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-a"
/>
</DIProvider>,
);
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-b"
/>
</DIProvider>,
);
await waitFor(() =>
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
);
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-a"
/>
</DIProvider>,
);
await screen.findByDisplayValue("edited draft");
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-2"
layoutId="layout-a"
/>
</DIProvider>,
);
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<string, string>();
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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<never>(() => {})),

View File

@ -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<string | null>(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<string | null | undefined>(undefined);
@ -507,6 +515,30 @@ export function CustomAgentChatView({
agent.closeAgentChat,
);
const slashPrefix = slashCommandPrefix(draft);
const draftScope = useMemo<ChatDraftScope | null>(
() => (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) {

View File

@ -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<void>;
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<void>;
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<void>;
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<void>;
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}

View File

@ -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<ExecuteSlashCommandResult>;
/** Reads a persisted custom chat draft scoped by project/layout/node. */
readChatDraft?(scope: ChatDraftScope): Promise<ChatDraftSnapshot>;
/** Saves a custom chat draft. Empty text is accepted but callers usually clear explicitly. */
saveChatDraft?(scope: ChatDraftScope, text: string): Promise<void>;
/** Clears a persisted custom chat draft. */
clearChatDraft?(scope: ChatDraftScope): Promise<void>;
/** Interrupts only the current turn of a live structured session. */
cancelAgentChat?(sessionId: string): Promise<void>;
/** Shuts a live structured session down. */