merge(web): routage layout /api/invoke + fallback restauration — #212 (QA verte)

This commit is contained in:
2026-08-15 11:40:53 +02:00
6 changed files with 348 additions and 41 deletions

View File

@ -1,6 +1,15 @@
--- ---
issueRef: "#212" issueRef: "#212"
version: 2 version: 5
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786786013613 updatedAt: 1786786758650
--- ---
2026-08-15 Main triage + implementation + QA.
- Symptome confirme par lecture code: frontend web invoque `load_layout`, mais `crates/web-server/src/lib.rs` ne route pas cette commande et tombe sur `UNKNOWN_COMMAND`.
- Cadrage Architect: fixer l'adapter web `/api/invoke` en alignement avec Tauri; idealement couvrir tout le sous-ensemble layout (`load_layout`, `mutate_layout`, `list_layouts`, `create_layout`, `rename_layout`, `delete_layout`, `set_active_layout`) + tests de non-regression.
- Cadrage UX: ne jamais exposer `unknown command: load_layout`; en cas d'echec de restauration, message utilisateur `Impossible de restaurer l’espace de travail de ce projet.` avec fallback vers un layout par defaut/non bloquant.
- Git: branche de travail `fix/212-web-load-layout-unknown-command`, creee depuis `develop` local `cf2f0dc`, rien pousse.
- DevBackend: web-server route maintenant toute la famille layout dans `/api/invoke`; tests ajoutes `authorized_layout_invokes_match_tauri_contracts` et `web_invoke_routes_layout_commands`; `cargo test -p web-server layout` OK. Suite complete `cargo test -p web-server` non verte dans cet environnement a cause d'echecs WS/PTY non lies au ticket.
- DevFrontend: `useLayout` ne propage plus l'erreur brute; message utilisateur controle + fallback `singleLeafTree()`; test UI cible ajoute et vert; `npm run typecheck` OK.
- QA: VERT pour #212. Commandes vertes: `cargo test -p web-server authorized_layout_invokes_match_tauri_contracts -- --nocapture`, `cargo test -p web-server web_invoke_routes_layout_commands -- --nocapture`, `npm --prefix frontend test -- LayoutGrid.test.tsx`. Aucun echec bloquant sur le perimetre du ticket.
- Prochaine etape: commit Git puis decision de merge local vers `develop`.

View File

@ -2,7 +2,7 @@
id: "1dbe01ec-94f4-404d-9ffb-2ceeceb0557b" id: "1dbe01ec-94f4-404d-9ffb-2ceeceb0557b"
number: 212 number: 212
title: "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet" title: "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un 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: 1786786004974 createdAt: 1786786004974
updatedAt: 1786786013613 updatedAt: 1786786758650
version: 2 version: 5
--- ---
A l'ouverture d'un projet dans l'interface web desktop/PC, l'application affiche `unknown command: load_layout`. Triage et correctif a produire avec cycle UX/Architect/Git/Dev/QA. A l'ouverture d'un projet dans l'interface web desktop/PC, l'application affiche `unknown command: load_layout`. Triage et correctif a produire avec cycle UX/Architect/Git/Dev/QA.

View File

@ -2862,7 +2862,7 @@
"issueRef": "#212", "issueRef": "#212",
"path": "212", "path": "212",
"title": "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet", "title": "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet",
"status": "inProgress", "status": "qa",
"priority": "high", "priority": "high",
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
@ -2870,7 +2870,7 @@
"kind": "agent", "kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1786786013613 "updatedAt": 1786786758650
} }
] ]
} }

View File

@ -40,24 +40,25 @@ use zip::write::SimpleFileOptions;
use application::{ use application::{
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput, AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput, ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateLayoutInput,
CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, DeleteEmbedderProfileInput, DeleteIssueInput, DeleteLayoutInput, DeleteMemoryInput,
DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput,
ListMemoriesInput, ListProjectFilesInput, ListResumableAgentsInput, ListSkillsInput, ListDevicesInput, ListIssuesInput, ListLayoutsInput, ListMemoriesInput, ListProjectFilesInput,
ListSprintsInput, LiveSessions, MarkIssueAttachmentSummarizedInput, McpRuntime, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, LoadLayoutInput,
OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, MarkIssueAttachmentSummarizedInput, McpRuntime, MutateLayoutInput, OpenProjectInput,
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
@ -71,31 +72,33 @@ use domain::{
use backend::dto::{ use backend::dto::{
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_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_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id,
parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_ref_dto, parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto,
parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto, parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input,
AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto,
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto, TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto, TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
@ -2872,6 +2875,23 @@ struct InvokeRequest {
args: Value, args: Value,
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LoadLayoutInvokeArgs {
project_id: String,
#[serde(default)]
layout_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MutateLayoutInvokeArgs {
project_id: String,
#[serde(default)]
layout_id: Option<String>,
operation: LayoutOperationDto,
}
async fn invoke( async fn invoke(
body: Bytes, body: Bytes,
state: Arc<ServerState>, state: Arc<ServerState>,
@ -2893,6 +2913,13 @@ async fn invoke(
"health" => invoke_health(&request.args, &state.app), "health" => invoke_health(&request.args, &state.app),
"list_projects" => invoke_list_projects(&state.app).await, "list_projects" => invoke_list_projects(&state.app).await,
"open_project" => invoke_open_project(&request.args, &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, "read_project_context" => invoke_read_project_context(&request.args, &state.app).await,
"update_project_context" => invoke_update_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, "first_run_state" => invoke_first_run_state(&state.app).await,
@ -3072,6 +3099,124 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result<Value,
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error) serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
} }
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<LoadLayoutInvokeArgs>("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<Value, ErrorDto> {
let request = required_request::<MutateLayoutInvokeArgs>("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<Value, ErrorDto> {
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<Value, ErrorDto> {
let request = required_request::<CreateLayoutRequestDto>("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<Value, ErrorDto> {
let request = required_request::<RenameLayoutRequestDto>("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<Value, ErrorDto> {
let request = required_request::<DeleteLayoutRequestDto>("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<Value, ErrorDto> {
let request = required_request::<SetActiveLayoutRequestDto>("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<Value, ErrorDto> { async fn invoke_read_project_context(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly( let project = resolve_project_readonly(
string_arg(args, "projectId", "read_project_context")?, string_arg(args, "projectId", "read_project_context")?,
@ -8252,6 +8397,104 @@ mod tests {
assert!(body["root"].is_string()); 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] #[tokio::test]
async fn authorized_project_files_lists_relative_paths_and_hides_ideai() { async fn authorized_project_files_lists_relative_paths_and_hides_ideai() {
let state = state(); let state = state();
@ -8556,6 +8799,27 @@ mod tests {
.await; .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] #[tokio::test]
async fn web_invoke_routes_profile_commands() { async fn web_invoke_routes_profile_commands() {
let state = state(); let state = state();

View File

@ -35,6 +35,31 @@ function renderGrid(layout: MockLayoutGateway, projectId = "p1") {
} }
describe("LayoutGrid (with MockLayoutGateway)", () => { 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 () => { it("renders a single-leaf layout without throwing", async () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
expect(() => renderGrid(layout)).not.toThrow(); expect(() => renderGrid(layout)).not.toThrow();

View File

@ -20,9 +20,13 @@ import { useGateways } from "@/app/di";
import { import {
leaves, leaves,
replaceCustomPluginLayoutWithTerminal, replaceCustomPluginLayoutWithTerminal,
singleLeafTree,
splitOp, splitOp,
} from "./layout"; } 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. */ /** What the layout grid UI needs from this hook. */
export interface LayoutViewModel { export interface LayoutViewModel {
/** The current layout tree, or `null` until loaded. */ /** The current layout tree, or `null` until loaded. */
@ -109,13 +113,18 @@ export function useLayout(
} }
let cancelled = false; let cancelled = false;
setBusy(true); setBusy(true);
setError(null);
gateway gateway
.loadLayout(projectId, layoutId) .loadLayout(projectId, layoutId)
.then((tree) => { .then((tree) => {
if (!cancelled) applyTree(tree); if (!cancelled) applyTree(tree);
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
if (!cancelled) setError(describe(e)); if (!cancelled) {
console.warn("Layout restore failed", e);
applyTree(singleLeafTree());
setError(LAYOUT_RESTORE_ERROR);
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setBusy(false); if (!cancelled) setBusy(false);