merge(chat): persistance du brouillon par projet/layout (#182, QA verte)
This commit is contained in:
@ -1,11 +1,14 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#182"
|
issueRef: "#182"
|
||||||
version: 3
|
version: 6
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedAt: 1786455395341
|
updatedAt: 1786458224057
|
||||||
---
|
---
|
||||||
2026-08-11
|
2026-08-11
|
||||||
- Ticket ouvert suite au signalement utilisateur.
|
- Ticket ouvert suite au signalement utilisateur.
|
||||||
- Cadrage UX: restauration transparente du brouillon au retour sur la cellule/layout; pas de persistance des attachments; TTL court acceptable.
|
- Cadrage UX: restauration transparente du brouillon au retour sur la cellule/layout; pas de persistance des attachments; TTL court acceptable.
|
||||||
- Arbitrage architecture validé: store backend JSON, scoped par projet/layout/node; pas de solution frontend-only.
|
- Arbitrage architecture validé: store backend JSON, scoped par projet/layout/node; pas de solution frontend-only.
|
||||||
- L'implémentation devra nettoyer le draft à l'envoi et après /clean si la conversation est vidée.
|
- L'implémentation devra nettoyer le draft à l'envoi et après /clean si la conversation est vidée.
|
||||||
|
- Implémentation frontend dev reçue: intégration composer -> backend draft store avec scope projectId/layoutId/nodeId, sauvegarde debounced, restauration silencieuse, effacement après envoi et slash command consommée, pas de persistance des attachments.
|
||||||
|
- Implémentation backend dev reçue: modèle ChatDraft text-only, store JSON `.ideai/chat-drafts.json`, use cases read/save/clear par scope exact projet/layout/node, commandes Tauri `read_chat_draft`/`save_chat_draft`/`clear_chat_draft`.
|
||||||
|
- QA 2026-08-11: VERT. Vérifications exécutées avec succès: frontend CustomAgentChatView/agent tests, backend domain/application/infrastructure sur le scope et la persistance draft. QA a ajouté un test manquant sur l'effacement du draft après slash command consommée.
|
||||||
@ -2,7 +2,7 @@
|
|||||||
id: "82436920-96cc-4a05-a1c7-0555e90ef47c"
|
id: "82436920-96cc-4a05-a1c7-0555e90ef47c"
|
||||||
number: 182
|
number: 182
|
||||||
title: "Persister le brouillon de chat par layout et retour projet"
|
title: "Persister le brouillon de chat par layout et retour projet"
|
||||||
status: "inProgress"
|
status: "qa"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
@ -11,7 +11,7 @@ attachments: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
createdAt: 1786454719252
|
createdAt: 1786454719252
|
||||||
updatedAt: 1786455395341
|
updatedAt: 1786458224057
|
||||||
version: 3
|
version: 6
|
||||||
---
|
---
|
||||||
Quand l'utilisateur tape un message dans le chat puis change de layout ou de projet, le texte saisi doit rester dans le chat quand il revient sur ce layout.
|
Quand l'utilisateur tape un message dans le chat puis change de layout ou de projet, le texte saisi doit rester dans le chat quand il revient sur ce layout.
|
||||||
@ -2441,7 +2441,7 @@
|
|||||||
"issueRef": "#182",
|
"issueRef": "#182",
|
||||||
"path": "182",
|
"path": "182",
|
||||||
"title": "Persister le brouillon de chat par layout et retour projet",
|
"title": "Persister le brouillon de chat par layout et retour projet",
|
||||||
"status": "inProgress",
|
"status": "qa",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
@ -2449,7 +2449,7 @@
|
|||||||
"kind": "agent",
|
"kind": "agent",
|
||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
},
|
},
|
||||||
"updatedAt": 1786455395341
|
"updatedAt": 1786458224057
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#183",
|
"issueRef": "#183",
|
||||||
|
|||||||
@ -14,20 +14,20 @@ use crate::dto::DismissEmbedderSuggestionRequestDto;
|
|||||||
use application::plugin_slash_commands_from_runtime_catalog;
|
use application::plugin_slash_commands_from_runtime_catalog;
|
||||||
use application::{
|
use application::{
|
||||||
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
||||||
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
|
ChatDraftScopeInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput,
|
||||||
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
|
CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput,
|
||||||
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, ExecuteSlashCommandInput,
|
DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput,
|
||||||
GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput,
|
ExecuteSlashCommandInput, GetMemoryInput, GetProjectSystemPermissionsInput,
|
||||||
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
|
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||||
GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput,
|
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||||
ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
|
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
||||||
ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput,
|
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
||||||
OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput,
|
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
|
||||||
ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
|
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
||||||
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||||
RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, SetActiveLayoutInput, SlashCommandEffect,
|
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
@ -48,28 +48,29 @@ use crate::dto::{
|
|||||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
||||||
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
||||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
|
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
|
||||||
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
|
ChatDraftScopeRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
|
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
|
||||||
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
|
||||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||||
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
|
||||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||||
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||||
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
|
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
||||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||||
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
@ -2237,6 +2238,77 @@ pub async fn change_agent_profile(
|
|||||||
.map_err(ErrorDto::from)
|
.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
|
/// `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).
|
/// 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::delete_agent,
|
||||||
commands::launch_agent,
|
commands::launch_agent,
|
||||||
commands::change_agent_profile,
|
commands::change_agent_profile,
|
||||||
|
commands::read_chat_draft,
|
||||||
|
commands::save_chat_draft,
|
||||||
|
commands::clear_chat_draft,
|
||||||
commands::agent_send,
|
commands::agent_send,
|
||||||
commands::import_chat_attachments,
|
commands::import_chat_attachments,
|
||||||
commands::cancel_resume,
|
commands::cancel_resume,
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
//! `cellKind` and PTY paths keep `"pty"`.
|
//! `cellKind` and PTY paths keep `"pty"`.
|
||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto,
|
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
|
||||||
ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto,
|
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto,
|
||||||
ReplyProgressKindDto, ReplyProgressSourceDto, ReplyProgressStageDto, TerminalSessionDto,
|
ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
|
||||||
|
ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
|
||||||
};
|
};
|
||||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
@ -154,6 +155,50 @@ fn reply_chunk_rejects_snake_case_tag() {
|
|||||||
// Chat attachments — structured input + durable metadata, camelCase
|
// 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]
|
#[test]
|
||||||
fn chat_attachment_input_dto_deserialises_camel_case() {
|
fn chat_attachment_input_dto_deserialises_camel_case() {
|
||||||
let dto: ChatAttachmentInputDto = serde_json::from_value(json!({
|
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 agent;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod chat_attachments;
|
pub mod chat_attachments;
|
||||||
|
pub mod chat_drafts;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
@ -76,6 +77,10 @@ pub use chat_attachments::{
|
|||||||
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||||
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||||
};
|
};
|
||||||
|
pub use chat_drafts::{
|
||||||
|
ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, ReadChatDraftOutput, SaveChatDraft,
|
||||||
|
SaveChatDraftInput,
|
||||||
|
};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
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)
|
// 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.
|
/// Structured attachment intent sent by the chat frontend.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -16,7 +16,7 @@ use application::{
|
|||||||
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||||
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||||
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
||||||
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft,
|
||||||
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||||
@ -40,30 +40,31 @@ use application::{
|
|||||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue,
|
||||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||||
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider,
|
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||||
ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
||||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||||
SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry,
|
||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||||
|
AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
BackgroundTaskStore, ChatAttachmentStore, Clock, DeviceSessionStore, Embedder,
|
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
|
||||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore,
|
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore,
|
||||||
MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||||
@ -91,13 +92,13 @@ use infrastructure::{
|
|||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsConversationLog,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
||||||
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
|
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||||
FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||||
@ -1023,6 +1024,12 @@ pub struct BackendCore {
|
|||||||
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
||||||
/// Import durable agent/chat attachments.
|
/// Import durable agent/chat attachments.
|
||||||
pub import_chat_attachments: Arc<ImportChatAttachments>,
|
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.
|
/// Link two public tickets.
|
||||||
pub link_issues: Arc<LinkIssues>,
|
pub link_issues: Arc<LinkIssues>,
|
||||||
/// Unlink public tickets.
|
/// Unlink public tickets.
|
||||||
@ -1711,6 +1718,8 @@ impl BackendCore {
|
|||||||
let chat_attachment_store = Arc::new(FsChatAttachmentStore::new());
|
let chat_attachment_store = Arc::new(FsChatAttachmentStore::new());
|
||||||
let chat_attachment_store_port =
|
let chat_attachment_store_port =
|
||||||
Arc::clone(&chat_attachment_store) as Arc<dyn ChatAttachmentStore>;
|
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(
|
let create_issue = Arc::new(CreateIssue::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
Arc::clone(&issue_allocator_port),
|
Arc::clone(&issue_allocator_port),
|
||||||
@ -1769,6 +1778,19 @@ impl BackendCore {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
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(
|
let link_issues = Arc::new(LinkIssues::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
@ -3001,6 +3023,9 @@ impl BackendCore {
|
|||||||
read_issue_attachment,
|
read_issue_attachment,
|
||||||
mark_issue_attachment_summarized,
|
mark_issue_attachment_summarized,
|
||||||
import_chat_attachments,
|
import_chat_attachments,
|
||||||
|
read_chat_draft,
|
||||||
|
save_chat_draft,
|
||||||
|
clear_chat_draft,
|
||||||
link_issues,
|
link_issues,
|
||||||
unlink_issues,
|
unlink_issues,
|
||||||
assign_issue_agent,
|
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 agent_tool_policy;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
pub mod chat_attachment;
|
pub mod chat_attachment;
|
||||||
|
pub mod chat_draft;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
@ -106,6 +107,7 @@ pub use background_task::{
|
|||||||
pub use chat_attachment::{
|
pub use chat_attachment::{
|
||||||
ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind,
|
ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind,
|
||||||
};
|
};
|
||||||
|
pub use chat_draft::{ChatDraft, ChatDraftError, ChatDraftKey, CHAT_DRAFT_MAX_BYTES};
|
||||||
|
|
||||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ use crate::background_task::{
|
|||||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
};
|
};
|
||||||
use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind};
|
use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind};
|
||||||
|
use crate::chat_draft::{ChatDraft, ChatDraftKey};
|
||||||
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
use crate::ids::{
|
use crate::ids::{
|
||||||
@ -1251,6 +1252,23 @@ pub trait ChatAttachmentStore: Send + Sync {
|
|||||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError>;
|
) -> 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.
|
/// Errors from the sprint store.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum SprintStoreError {
|
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 assistant;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
pub mod chat_attachments;
|
pub mod chat_attachments;
|
||||||
|
pub mod chat_drafts;
|
||||||
pub mod clock;
|
pub mod clock;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
@ -55,6 +56,7 @@ pub use background_task::{
|
|||||||
CommandBackgroundRunner,
|
CommandBackgroundRunner,
|
||||||
};
|
};
|
||||||
pub use chat_attachments::FsChatAttachmentStore;
|
pub use chat_attachments::FsChatAttachmentStore;
|
||||||
|
pub use chat_drafts::FsChatDraftStore;
|
||||||
pub use clock::SystemClock;
|
pub use clock::SystemClock;
|
||||||
pub use conversation::InMemoryConversationRegistry;
|
pub use conversation::InMemoryConversationRegistry;
|
||||||
pub use conversation_log::{
|
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");
|
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 () => {
|
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
invoke.mockResolvedValueOnce({
|
||||||
sessionId: "chat-session-1",
|
sessionId: "chat-session-1",
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentChatHandle,
|
AgentChatHandle,
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
|
ChatDraftScope,
|
||||||
|
ChatDraftSnapshot,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
LiveAgent,
|
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> {
|
async closeAgentChat(sessionId: string): Promise<void> {
|
||||||
await invoke("close_agent_session", { sessionId });
|
await invoke("close_agent_session", { sessionId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,8 @@ import type {
|
|||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
|
ChatDraftScope,
|
||||||
|
ChatDraftSnapshot,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
CreateTicketInput,
|
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(
|
async launchAgent(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@ -103,6 +103,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentChatHandle,
|
AgentChatHandle,
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
|
ChatDraftScope,
|
||||||
|
ChatDraftSnapshot,
|
||||||
ConversationGateway,
|
ConversationGateway,
|
||||||
ConversationPageRequest,
|
ConversationPageRequest,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
@ -401,6 +403,8 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
private liveKindByAgent = new Map<string, "pty" | "structured">();
|
private liveKindByAgent = new Map<string, "pty" | "structured">();
|
||||||
/** Retained structured reply chunks per live chat session. */
|
/** Retained structured reply chunks per live chat session. */
|
||||||
private chatScrollback = new Map<string, ReplyChunk[]>();
|
private chatScrollback = new Map<string, ReplyChunk[]>();
|
||||||
|
/** Persisted custom chat drafts keyed by project/layout/node. */
|
||||||
|
private chatDrafts = new Map<string, ChatDraftSnapshot>();
|
||||||
private slashCommands: SlashCommand[] = [
|
private slashCommands: SlashCommand[] = [
|
||||||
{
|
{
|
||||||
name: "/help",
|
name: "/help",
|
||||||
@ -437,6 +441,10 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
return `${projectId}::${agentId}`;
|
return `${projectId}::${agentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private chatDraftKey(scope: ChatDraftScope): string {
|
||||||
|
return `${scope.projectId}::${scope.layoutId}::${scope.nodeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
async listAgents(projectId: string): Promise<Agent[]> {
|
async listAgents(projectId: string): Promise<Agent[]> {
|
||||||
return structuredClone(this.getAgents(projectId));
|
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> {
|
async closeAgentChat(sessionId: string): Promise<void> {
|
||||||
this.chatScrollback.delete(sessionId);
|
this.chatScrollback.delete(sessionId);
|
||||||
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
PluginMenuRegistry,
|
PluginMenuRegistry,
|
||||||
PluginRuntimeRegistry,
|
PluginRuntimeRegistry,
|
||||||
} from "@/plugins/runtime";
|
} from "@/plugins/runtime";
|
||||||
import type { Gateways } from "@/ports";
|
import type { ChatDraftScope, Gateways } from "@/ports";
|
||||||
import { CustomAgentChatView } from "./CustomAgentChatView";
|
import { CustomAgentChatView } from "./CustomAgentChatView";
|
||||||
|
|
||||||
const profile: AgentProfile = {
|
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 () => {
|
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
||||||
|
|||||||
@ -28,7 +28,7 @@ import type {
|
|||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
||||||
import { Button, Spinner, cn } from "@/shared";
|
import { Button, Spinner, cn } from "@/shared";
|
||||||
import type { ChatAttachmentInput } from "@/ports";
|
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
|
||||||
|
|
||||||
export interface CustomAgentChatViewProps {
|
export interface CustomAgentChatViewProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -36,6 +36,7 @@ export interface CustomAgentChatViewProps {
|
|||||||
agentName: string;
|
agentName: string;
|
||||||
profile: AgentProfile;
|
profile: AgentProfile;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
layoutId?: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
@ -464,6 +465,7 @@ export function CustomAgentChatView({
|
|||||||
agentName,
|
agentName,
|
||||||
profile,
|
profile,
|
||||||
cwd,
|
cwd,
|
||||||
|
layoutId,
|
||||||
nodeId,
|
nodeId,
|
||||||
sessionId,
|
sessionId,
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -490,6 +492,12 @@ export function CustomAgentChatView({
|
|||||||
const sessionRef = useRef<string | null>(sessionId);
|
const sessionRef = useRef<string | null>(sessionId);
|
||||||
sessionRef.current = currentSession;
|
sessionRef.current = currentSession;
|
||||||
const slashQuerySeqRef = useRef(0);
|
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 mountedRef = useRef(false);
|
||||||
const openOrAttachCountRef = useRef(0);
|
const openOrAttachCountRef = useRef(0);
|
||||||
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
||||||
@ -507,6 +515,30 @@ export function CustomAgentChatView({
|
|||||||
agent.closeAgentChat,
|
agent.closeAgentChat,
|
||||||
);
|
);
|
||||||
const slashPrefix = slashCommandPrefix(draft);
|
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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
@ -520,6 +552,79 @@ export function CustomAgentChatView({
|
|||||||
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!supported ||
|
!supported ||
|
||||||
@ -554,12 +659,12 @@ export function CustomAgentChatView({
|
|||||||
|
|
||||||
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
||||||
if (!isSlashCommandAvailable(command)) return;
|
if (!isSlashCommandAvailable(command)) return;
|
||||||
setDraft(`${command.name} `);
|
editDraft(`${command.name} `);
|
||||||
setSlashMenuOpen(false);
|
setSlashMenuOpen(false);
|
||||||
setSlashCommands([]);
|
setSlashCommands([]);
|
||||||
setSlashActiveIndex(0);
|
setSlashActiveIndex(0);
|
||||||
requestAnimationFrame(() => composerRef.current?.focus());
|
requestAnimationFrame(() => composerRef.current?.focus());
|
||||||
}, []);
|
}, [editDraft]);
|
||||||
|
|
||||||
const publishSessionId = useCallback((nextSessionId: string | null) => {
|
const publishSessionId = useCallback((nextSessionId: string | null) => {
|
||||||
selfEmittedSessionIdRef.current = nextSessionId;
|
selfEmittedSessionIdRef.current = nextSessionId;
|
||||||
@ -887,6 +992,7 @@ export function CustomAgentChatView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
void clearPersistedDraft();
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const sid =
|
const sid =
|
||||||
@ -979,6 +1085,7 @@ export function CustomAgentChatView({
|
|||||||
return unique.length > 0 ? [...prev, ...unique] : prev;
|
return unique.length > 0 ? [...prev, ...unique] : prev;
|
||||||
});
|
});
|
||||||
if (pastedText) {
|
if (pastedText) {
|
||||||
|
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
||||||
setDraft((prev) =>
|
setDraft((prev) =>
|
||||||
prev.slice(0, selectionStart) +
|
prev.slice(0, selectionStart) +
|
||||||
pastedText +
|
pastedText +
|
||||||
@ -1004,6 +1111,7 @@ export function CustomAgentChatView({
|
|||||||
const displayText = text || "Pièce jointe";
|
const displayText = text || "Pièce jointe";
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
void clearPersistedDraft();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setTurns((prev) => [
|
setTurns((prev) => [
|
||||||
@ -1305,7 +1413,7 @@ export function CustomAgentChatView({
|
|||||||
value={draft}
|
value={draft}
|
||||||
disabled={!supported || opening || busy}
|
disabled={!supported || opening || busy}
|
||||||
placeholder="Message à l'agent…"
|
placeholder="Message à l'agent…"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => editDraft(e.target.value)}
|
||||||
onPaste={(e) => void pasteClipboardFiles(e)}
|
onPaste={(e) => void pasteClipboardFiles(e)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (slashMenuOpen && slashCommands.length > 0) {
|
if (slashMenuOpen && slashCommands.length > 0) {
|
||||||
|
|||||||
@ -128,6 +128,7 @@ export function LayoutGrid({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
parentSplit={null}
|
parentSplit={null}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
layoutId={layoutId}
|
||||||
workState={work.state}
|
workState={work.state}
|
||||||
refreshWorkState={work.refresh}
|
refreshWorkState={work.refresh}
|
||||||
refitSignal={refitEpoch}
|
refitSignal={refitEpoch}
|
||||||
@ -145,6 +146,7 @@ interface NodeViewProps {
|
|||||||
/** The enclosing split + this node's index in it, for the merge action. */
|
/** The enclosing split + this node's index in it, for the merge action. */
|
||||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
layoutId?: string;
|
||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
refitSignal: number;
|
refitSignal: number;
|
||||||
@ -158,6 +160,7 @@ function NodeView({
|
|||||||
vm,
|
vm,
|
||||||
parentSplit,
|
parentSplit,
|
||||||
projectId,
|
projectId,
|
||||||
|
layoutId,
|
||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
refitSignal,
|
refitSignal,
|
||||||
@ -191,6 +194,7 @@ function NodeView({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
parentSplit={parentSplit}
|
parentSplit={parentSplit}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
layoutId={layoutId}
|
||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
refitSignal={refitSignal}
|
refitSignal={refitSignal}
|
||||||
@ -203,6 +207,7 @@ function NodeView({
|
|||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
layoutId={layoutId}
|
||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
refitSignal={refitSignal}
|
refitSignal={refitSignal}
|
||||||
@ -217,6 +222,7 @@ function NodeView({
|
|||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
layoutId={layoutId}
|
||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
refitSignal={refitSignal}
|
refitSignal={refitSignal}
|
||||||
@ -236,6 +242,7 @@ interface LeafViewProps {
|
|||||||
vm: LayoutViewModel;
|
vm: LayoutViewModel;
|
||||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
layoutId?: string;
|
||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
refitSignal: number;
|
refitSignal: number;
|
||||||
@ -339,6 +346,7 @@ function LeafView({
|
|||||||
vm,
|
vm,
|
||||||
parentSplit,
|
parentSplit,
|
||||||
projectId,
|
projectId,
|
||||||
|
layoutId,
|
||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
refitSignal,
|
refitSignal,
|
||||||
@ -1247,6 +1255,7 @@ function LeafView({
|
|||||||
agentName={customCliAgent.name}
|
agentName={customCliAgent.name}
|
||||||
profile={customCliProfile}
|
profile={customCliProfile}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
|
layoutId={layoutId}
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
sessionId={session}
|
sessionId={session}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
@ -1573,6 +1582,7 @@ interface SplitViewProps {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
vm: LayoutViewModel;
|
vm: LayoutViewModel;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
layoutId?: string;
|
||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
refitSignal: number;
|
refitSignal: number;
|
||||||
@ -1585,6 +1595,7 @@ function SplitView({
|
|||||||
cwd,
|
cwd,
|
||||||
vm,
|
vm,
|
||||||
projectId,
|
projectId,
|
||||||
|
layoutId,
|
||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
refitSignal,
|
refitSignal,
|
||||||
@ -1631,6 +1642,7 @@ function SplitView({
|
|||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
layoutId={layoutId}
|
||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
refitSignal={refitSignal}
|
refitSignal={refitSignal}
|
||||||
@ -1727,6 +1739,7 @@ interface GridViewProps {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
vm: LayoutViewModel;
|
vm: LayoutViewModel;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
layoutId?: string;
|
||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
refitSignal: number;
|
refitSignal: number;
|
||||||
@ -1739,6 +1752,7 @@ function GridView({
|
|||||||
cwd,
|
cwd,
|
||||||
vm,
|
vm,
|
||||||
projectId,
|
projectId,
|
||||||
|
layoutId,
|
||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
refitSignal,
|
refitSignal,
|
||||||
@ -1781,6 +1795,7 @@ function GridView({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
parentSplit={null}
|
parentSplit={null}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
layoutId={layoutId}
|
||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
refitSignal={refitSignal}
|
refitSignal={refitSignal}
|
||||||
|
|||||||
@ -166,6 +166,17 @@ export interface SendAgentChatOptions {
|
|||||||
attachments?: ChatAttachmentInput[];
|
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
|
* 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
|
* resume popup. Both fields are optional: a missing inspector or a missing
|
||||||
@ -317,6 +328,12 @@ export interface AgentGateway {
|
|||||||
name: string,
|
name: string,
|
||||||
options?: { sessionId?: string | null; arguments?: unknown[] },
|
options?: { sessionId?: string | null; arguments?: unknown[] },
|
||||||
): Promise<ExecuteSlashCommandResult>;
|
): 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. */
|
/** Interrupts only the current turn of a live structured session. */
|
||||||
cancelAgentChat?(sessionId: string): Promise<void>;
|
cancelAgentChat?(sessionId: string): Promise<void>;
|
||||||
/** Shuts a live structured session down. */
|
/** Shuts a live structured session down. */
|
||||||
|
|||||||
Reference in New Issue
Block a user