feat(backend,frontend): lot utilisateur #310 #311 #312 #313 — horodatage conversation, stop agent robuste, préférence terminal par défaut, paramètres projet (QA verte)

- #310: horodatage de tous les messages et événements de conversation visibles — composant partagé EntryTimestamp, timestamps propagés dans le transcript chat, les annonces inter-agent et le viewer de conversation, normalisation côté domain/adapters (conversation_log, paginate).
- #311: stop d'agent robuste pour PTY non monté, sessions headless et CLI custom — lifecycle/orchestrator durcis, ponts chat app-tauri/web-server alignés, arrêt fiable sans session orpheline.
- #312: préférence globale d'interface terminal par défaut — nouveau store settings (domain/application/infrastructure + tests), DTO et commandes backend, adapter frontend userSettings, panneau TerminalSettings dans les paramètres globaux.
- #313: Paramètres du projet — contexte de projet éditable et couleur de thème par projet (ProjectSettingsPanel + projectTheme), teinte des onglets projet, persistance via DTO projet.

QA verte (GO QA sur feature/batch-310-311-312-313-chat-stop-prefs-theme) — vérifications Git: git diff --cached --check OK; staging borné à crates/ et frontend/ (74 fichiers, +2105/-531), commit unique de lot conforme à la convention des lots (précédents batch-151/158/159, batch-210/211/212) car les fichiers partagés (dto.rs, commands.rs, CustomAgentChatView) entrelacent les 4 tickets.

fichiers .ideai/* (agents/test.md, chat-drafts.json, system-permissions.json, tickets/counter.json, tickets/index.json, tickets/308-313/), attachments/ et scripts/ hors périmètre et préservés en worktree ; aucun push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:26:16 +02:00
parent fc45594d9a
commit 402a60f213
74 changed files with 2105 additions and 531 deletions

View File

@ -93,7 +93,7 @@ use backend::dto::{
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
@ -116,7 +116,8 @@ use backend::dto::{
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto,
};
use backend::events::DomainEventDto;
type PtyChunk = Vec<u8>;
@ -1647,6 +1648,10 @@ async fn execute_launch_agent_for_ws(
mcp_runtime,
allow_structured_alongside_pty: false,
require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat),
terminal_interface: request.cell_kind.map(|kind| match kind {
backend::dto::CellKind::Pty => domain::DefaultAgentTerminalInterface::NativeTui,
backend::dto::CellKind::Chat => domain::DefaultAgentTerminalInterface::IdeaCli,
}),
fresh_start: request.fresh_start,
managed_temp_exposure: application::ManagedTempExposureRequest::default(),
})
@ -1919,6 +1924,7 @@ async fn ws_send_chat(
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
let tap_out = tx.clone();
let tap_clock = Arc::clone(&state.app.clock);
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
let tap_pump = std::thread::spawn(move || {
for event in tap_rx {
@ -1930,7 +1936,7 @@ async fn ws_send_chat(
if matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }) {
continue;
}
let chunk = reply_event_chunk(event);
let chunk = reply_event_chunk(event, u64::try_from(tap_clock.now_millis()).ok());
if tap_out
.blocking_send(ServerFrame::chat_output(None, sid, chunk))
.is_err()
@ -1978,7 +1984,10 @@ async fn ws_send_chat(
break;
}
backend::StructuredHumanTurnTransition::Failed(message) => {
let chunk = reply_event_chunk(ReplyEvent::Error { message });
let chunk = reply_event_chunk(
ReplyEvent::Error { message },
u64::try_from(state.app.clock.now_millis()).ok(),
);
tx.send(ServerFrame::chat_output(
(!sent_ack).then_some(frame.id.as_str()),
sid,
@ -2002,7 +2011,7 @@ async fn ws_send_chat(
guard.mark_alive();
}
}
let chunk = reply_event_chunk(event);
let chunk = reply_event_chunk(event, u64::try_from(state.app.clock.now_millis()).ok());
if !sent_ack {
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk))
.await
@ -2030,7 +2039,10 @@ async fn ws_send_chat(
resume_prompt = Some(prompt);
}
backend::StructuredHumanTurnTransition::Failed(message) => {
let chunk = reply_event_chunk(ReplyEvent::Error { message });
let chunk = reply_event_chunk(
ReplyEvent::Error { message },
u64::try_from(state.app.clock.now_millis()).ok(),
);
tx.send(ServerFrame::chat_output(
(!sent_ack).then_some(frame.id.as_str()),
sid,
@ -2076,7 +2088,7 @@ async fn ws_send_chat(
tx.send(ServerFrame::chat_output(
Some(&frame.id),
sid,
json!({ "kind": "final", "content": "" }),
json!({ "kind": "final", "content": "", "atMs": u64::try_from(state.app.clock.now_millis()).ok() }),
))
.await
.map_err(|_| ErrorDto {
@ -2112,16 +2124,20 @@ fn ws_detach_chat(
Ok(())
}
fn reply_event_chunk(event: ReplyEvent) -> Value {
fn reply_event_chunk(event: ReplyEvent, occurred_at_ms: Option<u64>) -> Value {
match event {
ReplyEvent::TextDelta { text } => json!({ "kind": "textDelta", "text": text }),
ReplyEvent::TextDelta { text } => {
json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms })
}
ReplyEvent::ToolActivity { label } => {
json!({ "kind": "toolActivity", "label": label })
json!({ "kind": "toolActivity", "label": label, "atMs": occurred_at_ms })
}
ReplyEvent::Progress { progress } => {
json!({ "kind": "progress", "progress": reply_progress_value(progress) })
json!({ "kind": "progress", "progress": reply_progress_value(progress, occurred_at_ms) })
}
ReplyEvent::Announcement { text } => {
json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms })
}
ReplyEvent::Announcement { text } => json!({ "kind": "textDelta", "text": text }),
ReplyEvent::Heartbeat => json!({
"kind": "progress",
"progress": {
@ -2129,6 +2145,7 @@ fn reply_event_chunk(event: ReplyEvent) -> Value {
"kind": "other",
"stage": "info",
"label": "heartbeat"
,"occurredAtMs": occurred_at_ms
}
}),
ReplyEvent::RateLimited { resets_at_ms } => json!({
@ -2138,15 +2155,20 @@ fn reply_event_chunk(event: ReplyEvent) -> Value {
"kind": "turn",
"stage": "info",
"label": "rate_limited",
"occurredAtMs": resets_at_ms
"occurredAtMs": occurred_at_ms,
"resetsAtMs": resets_at_ms
}
}),
ReplyEvent::Error { message } => json!({ "kind": "error", "message": message }),
ReplyEvent::Final { content } => json!({ "kind": "final", "content": content }),
ReplyEvent::Error { message } => {
json!({ "kind": "error", "message": message, "atMs": occurred_at_ms })
}
ReplyEvent::Final { content } => {
json!({ "kind": "final", "content": content, "atMs": occurred_at_ms })
}
}
}
fn reply_progress_value(progress: ReplyProgress) -> Value {
fn reply_progress_value(progress: ReplyProgress, occurred_at_ms: Option<u64>) -> Value {
json!({
"progressId": progress.progress_id,
"source": reply_progress_source(progress.source),
@ -2157,6 +2179,7 @@ fn reply_progress_value(progress: ReplyProgress) -> Value {
"provider": progress.provider,
"nativeEvent": progress.native_event,
"toolName": progress.tool_name,
"occurredAtMs": occurred_at_ms,
})
}
@ -3370,6 +3393,12 @@ async fn invoke(
"set_active_layout" => invoke_set_active_layout(&request.args, &state.app).await,
"read_project_context" => invoke_read_project_context(&request.args, &state.app).await,
"update_project_context" => invoke_update_project_context(&request.args, &state.app).await,
"read_user_settings" => invoke_read_user_settings(&state.app).await,
"update_user_settings" => invoke_update_user_settings(&request.args, &state.app).await,
"read_project_settings" => invoke_read_project_settings(&request.args, &state.app).await,
"update_project_settings" => {
invoke_update_project_settings(&request.args, &state.app).await
}
"first_run_state" => invoke_first_run_state(&state.app).await,
"reference_profiles" => invoke_reference_profiles(&state.app).await,
"detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await,
@ -3803,6 +3832,66 @@ async fn invoke_update_project_context(
Ok(Value::Null)
}
async fn invoke_read_user_settings(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: UserSettingsDto = state
.read_user_settings
.execute()
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_user_settings(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UserSettingsDto>("update_user_settings", args)?;
let output: UserSettingsDto = state
.update_user_settings
.execute(request.into())
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_read_project_settings(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "read_project_settings")?,
state,
)
.await?;
let output: ProjectSettingsDto = state
.read_project_settings
.execute(&project)
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_settings(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateProjectSettingsRequestDto>("update_project_settings", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let theme_color = request
.theme_color
.map(domain::ThemeColor::new)
.transpose()
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
let output: ProjectSettingsDto = state
.update_project_settings
.execute(&project, domain::ProjectSettings { theme_color })
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_first_run_state(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.first_run_state
@ -3991,6 +4080,7 @@ async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result<Val
.execute(StopLiveAgentInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
reason: request.reason,
})
.await
.map(StopLiveAgentResponseDto::from)
@ -8021,22 +8111,19 @@ mod tests {
assert_eq!(ack.kind, "chat.output");
assert_eq!(ack.reply_to.as_deref(), Some("chat-send"));
assert_eq!(ack.payload["sessionId"], session_id.to_string());
assert_eq!(
ack.payload["chunk"],
json!({ "kind": "textDelta", "text": "Hello " })
);
assert_eq!(ack.payload["chunk"]["kind"], "textDelta");
assert_eq!(ack.payload["chunk"]["text"], "Hello ");
assert!(ack.payload["chunk"]["atMs"].is_number());
assert_eq!(tool.kind, "chat.output");
assert!(tool.reply_to.is_none());
assert_eq!(
tool.payload["chunk"],
json!({ "kind": "toolActivity", "label": "read_file" })
);
assert_eq!(tool.payload["chunk"]["kind"], "toolActivity");
assert_eq!(tool.payload["chunk"]["label"], "read_file");
assert!(tool.payload["chunk"]["atMs"].is_number());
assert_eq!(final_chunk.kind, "chat.output");
assert!(final_chunk.reply_to.is_none());
assert_eq!(
final_chunk.payload["chunk"],
json!({ "kind": "final", "content": "Hello back" })
);
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
assert_eq!(final_chunk.payload["chunk"]["content"], "Hello back");
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
assert_eq!(
session.prompts.lock().unwrap().as_slice(),
&["Salut".to_owned()]
@ -8087,10 +8174,9 @@ mod tests {
assert_eq!(final_chunk.kind, "chat.output");
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-busy"));
assert_eq!(
final_chunk.payload["chunk"],
json!({ "kind": "final", "content": "released" })
);
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
assert_eq!(final_chunk.payload["chunk"]["content"], "released");
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
assert!(
!state.app.turn_watch_input.busy_state(runtime_key).is_busy(),
"direct structured human turn should be idle after final"
@ -8161,10 +8247,9 @@ mod tests {
"idea_ask_agent"
);
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-progress"));
assert_eq!(
final_chunk.payload["chunk"],
json!({ "kind": "final", "content": "done" })
);
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
assert_eq!(final_chunk.payload["chunk"]["content"], "done");
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
}
#[tokio::test]
@ -10262,6 +10347,49 @@ mod tests {
assert_eq!(tasks[0]["state"], "queued");
}
#[tokio::test]
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
let state = state();
let project_id = create_project_for_test(&state, "Settings Round Trip").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, initial_user) =
invoke_request_for_test(Arc::clone(&state), &cookie, "read_user_settings", json!({}))
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(initial_user["defaultAgentTerminalInterface"], Value::Null);
let (status, saved_user) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_user_settings",
json!({ "request": { "defaultAgentTerminalInterface": "ideaCli" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved_user["defaultAgentTerminalInterface"], "ideaCli");
let (status, initial_project) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"read_project_settings",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(initial_project["themeColor"], Value::Null);
let (status, saved_project) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({ "request": { "projectId": project_id, "themeColor": "#AA3366" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved_project["themeColor"], "#AA3366");
}
#[tokio::test]
async fn ticket_and_sprint_commands_require_authenticated_invoke() {
let state = state();