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

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