feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)

This commit is contained in:
2026-08-11 16:24:20 +02:00
parent 25ad44a3d9
commit 94b4ce7e5f
23 changed files with 1552 additions and 70 deletions

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 {