From 86f6c7ebf87c62706bff81ed34b7e4c9cf15d10c Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 15 Aug 2026 11:40:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20route=20la=20famille=20layout=20dan?= =?UTF-8?q?s=20/api/invoke=20+=20fallback=20UX=20restauration=20=E2=80=94?= =?UTF-8?q?=20#212?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le web-server ne routait pas load_layout (UNKNOWN_COMMAND a l'ouverture d'un projet web PC). Alignement de l'adapter web sur les contrats Tauri: load_layout, mutate_layout, list_layouts, create_layout, rename_layout, delete_layout, set_active_layout. useLayout ne propage plus l'erreur brute: message utilisateur + fallback singleLeafTree. --- crates/web-server/src/lib.rs | 330 ++++++++++++++++-- .../src/features/layout/LayoutGrid.test.tsx | 25 ++ frontend/src/features/layout/useLayout.ts | 11 +- 3 files changed, 332 insertions(+), 34 deletions(-) diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 53e8e233..97802a05 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -40,24 +40,25 @@ use zip::write::SimpleFileOptions; use application::{ AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput, - ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput, - CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput, - DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, - DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput, - GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, - GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, - LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, - ListMemoriesInput, ListProjectFilesInput, ListResumableAgentsInput, ListSkillsInput, - ListSprintsInput, LiveSessions, MarkIssueAttachmentSummarizedInput, McpRuntime, - OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, + ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateLayoutInput, + CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, + DeleteEmbedderProfileInput, DeleteIssueInput, DeleteLayoutInput, DeleteMemoryInput, + DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, + GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, + GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, + GitStatusInput, InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, + ListDevicesInput, ListIssuesInput, ListLayoutsInput, ListMemoriesInput, ListProjectFilesInput, + ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, LoadLayoutInput, + MarkIssueAttachmentSummarizedInput, McpRuntime, MutateLayoutInput, OpenProjectInput, + PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, - ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, - RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, - TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, - UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput, - UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput, + ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, + ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, + UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput, + UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, @@ -71,31 +72,33 @@ use domain::{ use backend::dto::{ create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto, - parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, parse_ref_dto, - parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, - parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto, - AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, - AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, - ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, + parse_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, + parse_ref_dto, parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, + parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, + version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, + AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, + ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, + CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, + CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, - MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, - ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, - ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, + OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, + ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, - SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, - SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, - SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, - TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, + SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, + SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, + SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto, TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, @@ -2872,6 +2875,23 @@ struct InvokeRequest { args: Value, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LoadLayoutInvokeArgs { + project_id: String, + #[serde(default)] + layout_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MutateLayoutInvokeArgs { + project_id: String, + #[serde(default)] + layout_id: Option, + operation: LayoutOperationDto, +} + async fn invoke( body: Bytes, state: Arc, @@ -2893,6 +2913,13 @@ async fn invoke( "health" => invoke_health(&request.args, &state.app), "list_projects" => invoke_list_projects(&state.app).await, "open_project" => invoke_open_project(&request.args, &state.app).await, + "load_layout" => invoke_load_layout(&request.args, &state.app).await, + "mutate_layout" => invoke_mutate_layout(&request.args, &state.app).await, + "list_layouts" => invoke_list_layouts(&request.args, &state.app).await, + "create_layout" => invoke_create_layout(&request.args, &state.app).await, + "rename_layout" => invoke_rename_layout(&request.args, &state.app).await, + "delete_layout" => invoke_delete_layout(&request.args, &state.app).await, + "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, "first_run_state" => invoke_first_run_state(&state.app).await, @@ -3072,6 +3099,124 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result Result { + let request = required_request::("load_layout", args)?; + let project_id = parse_project_id(&request.project_id)?; + let layout_id = request + .layout_id + .as_deref() + .map(parse_layout_id) + .transpose()?; + let output = state + .load_layout + .execute(LoadLayoutInput { + project_id, + layout_id, + }) + .await + .map(LayoutDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_mutate_layout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("mutate_layout", args)?; + let project_id = parse_project_id(&request.project_id)?; + let layout_id = request + .layout_id + .as_deref() + .map(parse_layout_id) + .transpose()?; + let operation = request.operation.into_operation()?; + let output = state + .mutate_layout + .execute(MutateLayoutInput { + project_id, + layout_id, + operation, + }) + .await + .map(LayoutDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_layouts(args: &Value, state: &BackendCore) -> Result { + let project_id = parse_project_id(string_arg(args, "projectId", "list_layouts")?)?; + let output = state + .list_layouts + .execute(ListLayoutsInput { project_id }) + .await + .map(ListLayoutsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_layout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_layout", args)?; + let project_id = parse_project_id(&request.project_id)?; + let kind = request.parse_kind()?; + let output = state + .create_layout + .execute(CreateLayoutInput { + project_id, + name: request.name, + kind, + }) + .await + .map(CreateLayoutResultDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_rename_layout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("rename_layout", args)?; + let project_id = parse_project_id(&request.project_id)?; + let layout_id = parse_layout_id(&request.layout_id)?; + state + .rename_layout + .execute(RenameLayoutInput { + project_id, + layout_id, + name: request.name, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_delete_layout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("delete_layout", args)?; + let project_id = parse_project_id(&request.project_id)?; + let layout_id = parse_layout_id(&request.layout_id)?; + let output = state + .delete_layout + .execute(DeleteLayoutInput { + project_id, + layout_id, + }) + .await + .map(DeleteLayoutResultDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_set_active_layout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("set_active_layout", args)?; + let project_id = parse_project_id(&request.project_id)?; + let layout_id = parse_layout_id(&request.layout_id)?; + let output = state + .set_active_layout + .execute(SetActiveLayoutInput { + project_id, + layout_id, + }) + .await + .map(SetActiveLayoutResultDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_read_project_context(args: &Value, state: &BackendCore) -> Result { let project = resolve_project_readonly( string_arg(args, "projectId", "read_project_context")?, @@ -8252,6 +8397,104 @@ mod tests { assert!(body["root"].is_string()); } + #[tokio::test] + async fn authorized_layout_invokes_match_tauri_contracts() { + let state = state(); + let project_id = create_project_for_test(&state, "Web Layouts").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let (load_status, layout) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "load_layout", + json!({ "projectId": project_id }), + ) + .await; + assert_eq!(load_status, StatusCode::OK); + assert_eq!(layout["root"]["type"], "leaf"); + + let (list_status, listed) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "list_layouts", + json!({ "projectId": project_id }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert!(listed["activeId"].is_string()); + assert_eq!(listed["layouts"].as_array().unwrap().len(), 1); + + let (create_status, created) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "create_layout", + json!({ "projectId": project_id, "name": "Second", "kind": "terminal" }), + ) + .await; + assert_eq!(create_status, StatusCode::OK); + let created_layout_id = created["layoutId"].as_str().unwrap().to_owned(); + + let (rename_status, renamed) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "rename_layout", + json!({ "projectId": project_id, "layoutId": created_layout_id, "name": "Renamed" }), + ) + .await; + assert_eq!(rename_status, StatusCode::OK); + assert_eq!(renamed, Value::Null); + + let (activate_status, activated) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "set_active_layout", + json!({ "projectId": project_id, "layoutId": created_layout_id }), + ) + .await; + assert_eq!(activate_status, StatusCode::OK); + assert_eq!(activated["activeId"], created_layout_id); + + let (load_created_status, created_layout) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "load_layout", + json!({ "projectId": project_id, "layoutId": created_layout_id }), + ) + .await; + assert_eq!(load_created_status, StatusCode::OK); + + let (mutate_status, mutated) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "mutate_layout", + json!({ + "projectId": project_id, + "layoutId": created_layout_id, + "operation": { + "type": "setCellConversation", + "target": created_layout["root"]["node"]["id"], + "conversationId": "web-conversation" + } + }), + ) + .await; + assert_eq!(mutate_status, StatusCode::OK); + assert_eq!( + mutated["root"]["node"]["conversationId"], + "web-conversation" + ); + + let (delete_status, deleted) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "delete_layout", + json!({ "projectId": project_id, "layoutId": created_layout_id }), + ) + .await; + assert_eq!(delete_status, StatusCode::OK); + assert!(deleted["activeId"].is_string()); + } + #[tokio::test] async fn authorized_project_files_lists_relative_paths_and_hides_ideai() { let state = state(); @@ -8556,6 +8799,27 @@ mod tests { .await; } + #[tokio::test] + async fn web_invoke_routes_layout_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "load_layout", + "mutate_layout", + "list_layouts", + "create_layout", + "rename_layout", + "delete_layout", + "set_active_layout", + ], + ) + .await; + } + #[tokio::test] async fn web_invoke_routes_profile_commands() { let state = state(); diff --git a/frontend/src/features/layout/LayoutGrid.test.tsx b/frontend/src/features/layout/LayoutGrid.test.tsx index aab68f44..6f1b44cb 100644 --- a/frontend/src/features/layout/LayoutGrid.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.test.tsx @@ -35,6 +35,31 @@ function renderGrid(layout: MockLayoutGateway, projectId = "p1") { } describe("LayoutGrid (with MockLayoutGateway)", () => { + it("falls back to an empty workspace with a user-facing message when layout restore fails", async () => { + const layout = new MockLayoutGateway(); + vi.spyOn(layout, "loadLayout").mockRejectedValueOnce( + new Error("unknown command: load_layout"), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + renderGrid(layout); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain( + "Impossible de restaurer l’espace de travail de ce projet.", + ); + expect(alert.textContent).not.toContain("unknown command"); + await waitFor(() => { + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1); + }); + expect(warn).toHaveBeenCalledWith( + "Layout restore failed", + expect.objectContaining({ message: "unknown command: load_layout" }), + ); + + warn.mockRestore(); + }); + it("renders a single-leaf layout without throwing", async () => { const layout = new MockLayoutGateway(); expect(() => renderGrid(layout)).not.toThrow(); diff --git a/frontend/src/features/layout/useLayout.ts b/frontend/src/features/layout/useLayout.ts index 31f116dc..dec6004f 100644 --- a/frontend/src/features/layout/useLayout.ts +++ b/frontend/src/features/layout/useLayout.ts @@ -20,9 +20,13 @@ import { useGateways } from "@/app/di"; import { leaves, replaceCustomPluginLayoutWithTerminal, + singleLeafTree, splitOp, } from "./layout"; +const LAYOUT_RESTORE_ERROR = + "Impossible de restaurer l’espace de travail de ce projet. Un espace vide a été ouvert ; rechargez le projet pour réessayer."; + /** What the layout grid UI needs from this hook. */ export interface LayoutViewModel { /** The current layout tree, or `null` until loaded. */ @@ -109,13 +113,18 @@ export function useLayout( } let cancelled = false; setBusy(true); + setError(null); gateway .loadLayout(projectId, layoutId) .then((tree) => { if (!cancelled) applyTree(tree); }) .catch((e: unknown) => { - if (!cancelled) setError(describe(e)); + if (!cancelled) { + console.warn("Layout restore failed", e); + applyTree(singleLeafTree()); + setError(LAYOUT_RESTORE_ERROR); + } }) .finally(() => { if (!cancelled) setBusy(false);