feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)
This commit is contained in:
@ -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).
|
||||
///
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!({
|
||||
|
||||
157
crates/application/src/chat_drafts.rs
Normal file
157
crates/application/src/chat_drafts.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
230
crates/application/tests/chat_drafts.rs
Normal file
230
crates/application/tests/chat_drafts.rs
Normal 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());
|
||||
}
|
||||
@ -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")]
|
||||
|
||||
@ -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,
|
||||
|
||||
84
crates/domain/src/chat_draft.rs
Normal file
84
crates/domain/src/chat_draft.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
@ -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};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
26
crates/domain/tests/chat_draft.rs
Normal file
26
crates/domain/tests/chat_draft.rs
Normal 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 { .. }));
|
||||
}
|
||||
125
crates/infrastructure/src/chat_drafts.rs
Normal file
125
crates/infrastructure/src/chat_drafts.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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::{
|
||||
|
||||
110
crates/infrastructure/tests/chat_drafts.rs
Normal file
110
crates/infrastructure/tests/chat_drafts.rs
Normal 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;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>(() => {})),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user