diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 99ab9d1..148ea1a 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -34,31 +34,66 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - AssignIssueAgentInput, AssignTicketToSprintInput, AuthenticateSessionInput, CloseTerminalInput, - CreateSprintInput, DeleteIssueInput, DeleteSprintInput, GetProjectWorkStateInput, - LaunchAgentInput, LinkIssuesInput, ListDevicesInput, ListIssuesInput, ListSprintsInput, + AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput, + AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput, + CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, + DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, + DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, + GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, + GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, + LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, + ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, - ReadIssueCarnetInput, ReadIssueInput, RenameDeviceInput, RenameSprintInput, - ReorderSprintsInput, ResizeTerminalInput, RevokeDeviceInput, RotateConversationLogInput, - TouchDeviceInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, + ReadAgentContextInput, ReadConversationPageInput, ReadIssueCarnetInput, ReadIssueInput, + ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, + RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, + UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, + UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateIssueCarnetInput, UpdateMemoryInput, UpdateProjectContextInput, + UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateSkillInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; use domain::IssueActor; -use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash}; +use domain::{ + AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash, SkillRef, + SkillScope, +}; use backend::dto::{ create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id, - parse_project_id, parse_ref_dto, parse_session_id, parse_sprint_id_dto, - parse_sprint_status_dto, parse_task_id, sort_ticket_rows, sprint_version_dto, update_input, - version_dto, BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, - LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, - SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, - SprintRenameRequestDto, SprintReorderRequestDto, TerminalSessionDto, TicketAssignRequestDto, - TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, - TicketLinkCommandRequestDto, TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, - TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, - TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, + 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, DetectProfilesRequestDto, + DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, + EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, + MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, + SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, + SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto, + TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, + TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, + TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, + TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, + UpdateSkillRequestDto, UpdateTemplateRequestDto, }; use backend::events::DomainEventDto; type PtyChunk = Vec; @@ -2305,6 +2340,93 @@ 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, + "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, + "reference_profiles" => invoke_reference_profiles(&state.app).await, + "detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await, + "list_profiles" => invoke_list_profiles(&state.app).await, + "save_profile" => invoke_save_profile(&request.args, &state.app).await, + "delete_profile" => invoke_delete_profile(&request.args, &state.app).await, + "configure_profiles" => invoke_configure_profiles(&request.args, &state.app).await, + "clone_opencode_profile_from_seed" => { + invoke_clone_opencode_profile_from_seed(&request.args, &state.app).await + } + "list_agents" => invoke_list_agents(&request.args, &state.app).await, + "list_live_agents" => invoke_list_live_agents(&request.args, &state.app), + "list_resumable_agents" => invoke_list_resumable_agents(&request.args, &state.app).await, + "attach_live_agent" => invoke_attach_live_agent(&request.args, &state.app).await, + "stop_live_agent" => invoke_stop_live_agent(&request.args, &state.app).await, + "create_agent" => invoke_create_agent(&request.args, &state.app).await, + "change_agent_profile" => invoke_change_agent_profile(&request.args, &state.app).await, + "read_agent_context" => invoke_read_agent_context(&request.args, &state.app).await, + "update_agent_context" => invoke_update_agent_context(&request.args, &state.app).await, + "delete_agent" => invoke_delete_agent(&request.args, &state.app).await, + "inspect_conversation" => invoke_inspect_conversation(&request.args, &state.app).await, + "list_templates" => invoke_list_templates(&state.app).await, + "create_template" => invoke_create_template(&request.args, &state.app).await, + "update_template" => invoke_update_template(&request.args, &state.app).await, + "delete_template" => invoke_delete_template(&request.args, &state.app).await, + "create_agent_from_template" => { + invoke_create_agent_from_template(&request.args, &state.app).await + } + "detect_agent_drift" => invoke_detect_agent_drift(&request.args, &state.app).await, + "sync_agent_with_template" => { + invoke_sync_agent_with_template(&request.args, &state.app).await + } + "list_skills" => invoke_list_skills(&request.args, &state.app).await, + "create_skill" => invoke_create_skill(&request.args, &state.app).await, + "update_skill" => invoke_update_skill(&request.args, &state.app).await, + "delete_skill" => invoke_delete_skill(&request.args, &state.app).await, + "assign_skill_to_agent" => invoke_assign_skill_to_agent(&request.args, &state.app).await, + "unassign_skill_from_agent" => { + invoke_unassign_skill_from_agent(&request.args, &state.app).await + } + "get_project_permissions" => { + invoke_get_project_permissions(&request.args, &state.app).await + } + "update_project_permissions" => { + invoke_update_project_permissions(&request.args, &state.app).await + } + "update_agent_permissions" => { + invoke_update_agent_permissions(&request.args, &state.app).await + } + "resolve_agent_permissions" => { + invoke_resolve_agent_permissions(&request.args, &state.app).await + } + "get_mcp_tool_permissions" => { + invoke_get_mcp_tool_permissions(&request.args, &state.app).await + } + "update_project_mcp_tool_permissions" => { + invoke_update_project_mcp_tool_permissions(&request.args, &state.app).await + } + "update_agent_mcp_tool_permissions" => { + invoke_update_agent_mcp_tool_permissions(&request.args, &state.app).await + } + "list_memories" => invoke_list_memories(&request.args, &state.app).await, + "get_memory" => invoke_get_memory(&request.args, &state.app).await, + "create_memory" => invoke_create_memory(&request.args, &state.app).await, + "update_memory" => invoke_update_memory(&request.args, &state.app).await, + "delete_memory" => invoke_delete_memory(&request.args, &state.app).await, + "read_memory_index" => invoke_read_memory_index(&request.args, &state.app).await, + "resolve_memory_links" => invoke_resolve_memory_links(&request.args, &state.app).await, + "recall_memory" => invoke_recall_memory(&request.args, &state.app).await, + "list_embedder_profiles" => invoke_list_embedder_profiles(&state.app).await, + "save_embedder_profile" => invoke_save_embedder_profile(&request.args, &state.app).await, + "delete_embedder_profile" => { + invoke_delete_embedder_profile(&request.args, &state.app).await + } + "describe_embedder_engines" => invoke_describe_embedder_engines(&state.app).await, + "git_status" => invoke_git_status(&request.args, &state.app).await, + "git_stage" => invoke_git_stage(&request.args, &state.app).await, + "git_unstage" => invoke_git_unstage(&request.args, &state.app).await, + "git_commit" => invoke_git_commit(&request.args, &state.app).await, + "git_branches" => invoke_git_branches(&request.args, &state.app).await, + "git_checkout" => invoke_git_checkout(&request.args, &state.app).await, + "git_log" => invoke_git_log(&request.args, &state.app).await, + "git_init" => invoke_git_init(&request.args, &state.app).await, + "git_graph" => invoke_git_graph(&request.args, &state.app).await, + "read_conversation_page" => invoke_read_conversation_page(&request.args, &state.app).await, "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, @@ -2373,6 +2495,294 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "read_project_context")?, + state, + ) + .await?; + let output = state + .read_project_context + .execute(ReadProjectContextInput { project }) + .await + .map_err(ErrorDto::from)? + .content; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_context( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_project_context", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .update_project_context + .execute(UpdateProjectContextInput { + project, + content: request.content, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_first_run_state(state: &BackendCore) -> Result { + let output = state + .first_run_state + .execute() + .await + .map(FirstRunStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_reference_profiles(state: &BackendCore) -> Result { + let output = state + .reference_profiles + .execute() + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_detect_profiles(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("detect_profiles", args)?; + let output = state + .detect_profiles + .execute(request.into()) + .await + .map(DetectProfilesResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_profiles(state: &BackendCore) -> Result { + let output = state + .list_profiles + .execute() + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_save_profile(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("save_profile", args)?; + let output = state + .save_profile + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_profile(args: &Value, state: &BackendCore) -> Result { + let profile_id = string_arg(args, "profileId", "delete_profile")?; + let input = backend::dto::parse_delete_profile(profile_id)?; + state + .delete_profile + .execute(input) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_configure_profiles(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("configure_profiles", args)?; + let output = state + .configure_profiles + .execute(request.into()) + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_clone_opencode_profile_from_seed( + args: &Value, + state: &BackendCore, +) -> Result { + let request = optional_request::(args)?.unwrap_or( + CloneOpenCodeProfileFromSeedRequestDto { + name: None, + opencode: None, + }, + ); + let output = state + .clone_opencode_profile_from_seed + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_agents(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "list_agents")?, state).await?; + let output = state + .list_agents + .execute(ListAgentsInput { project }) + .await + .map(AgentListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +fn invoke_list_live_agents(args: &Value, state: &BackendCore) -> Result { + let _ = parse_project_id(string_arg(args, "projectId", "list_live_agents")?)?; + let live = LiveSessions::new( + Arc::clone(&state.terminal_sessions), + Arc::clone(&state.structured_sessions), + ); + let output = LiveAgentListDto::from_snapshots(live.live_agent_snapshots()); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_resumable_agents( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "list_resumable_agents")?, + state, + ) + .await?; + let output = state + .list_resumable_agents + .execute(ListResumableAgentsInput { project }) + .await + .map(ResumableAgentListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_attach_live_agent(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("attach_live_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .attach_live_agent + .execute(AttachLiveAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + node_id: parse_node_id(&request.node_id)?, + }) + .map(AttachLiveAgentResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("stop_live_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .stop_live_agent + .execute(StopLiveAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + }) + .await + .map(StopLiveAgentResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_agent(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_agent + .execute(CreateAgentInput { + project, + name: request.name, + profile_id: parse_profile_id(&request.profile_id)?, + initial_content: request.initial_content, + }) + .await + .map(AgentDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_change_agent_profile(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("change_agent_profile", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .change_agent_profile + .execute(ChangeAgentProfileInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + profile_id: parse_profile_id(&request.profile_id)?, + rows: request.rows, + cols: request.cols, + }) + .await + .map(ChangeAgentProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_read_agent_context(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "read_agent_context")?, state) + .await?; + let agent_id = parse_agent_id(string_arg(args, "agentId", "read_agent_context")?)?; + let output = state + .read_agent_context + .execute(ReadAgentContextInput { project, agent_id }) + .await + .map(ReadAgentContextResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_agent_context(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_agent_context", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .update_agent_context + .execute(UpdateAgentContextInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + content: request.content, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_delete_agent(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "delete_agent")?, state).await?; + let agent_id = parse_agent_id(string_arg(args, "agentId", "delete_agent")?)?; + state + .delete_agent + .execute(DeleteAgentInput { project, agent_id }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_inspect_conversation(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("inspect_conversation", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .inspect_conversation + .execute(InspectConversationInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + conversation_id: request.conversation_id, + }) + .await + .map(ConversationDetailsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_get_project_work_state( args: &Value, state: &BackendCore, @@ -2790,6 +3200,673 @@ async fn invoke_sprint_delete(args: &Value, state: &BackendCore) -> Result Result { + let output = state + .list_templates + .execute() + .await + .map(TemplateListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_template(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_template", args)?; + let output = state + .create_template + .execute(request.into_input()?) + .await + .map(TemplateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_template(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_template", args)?; + let output = state + .update_template + .execute(request.into_input()?) + .await + .map(TemplateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_template(args: &Value, state: &BackendCore) -> Result { + let template_id = parse_template_id(string_arg(args, "templateId", "delete_template")?)?; + state + .delete_template + .execute(DeleteTemplateInput { template_id }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_create_agent_from_template( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("create_agent_from_template", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_agent_from_template + .execute(request.into_input(project)?) + .await + .map(|out| AgentDto(out.agent)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_detect_agent_drift(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "detect_agent_drift")?, state) + .await?; + let output = state + .detect_agent_drift + .execute(DetectAgentDriftInput { project }) + .await + .map(AgentDriftListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_sync_agent_with_template( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("sync_agent_with_template", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .sync_agent_with_template + .execute(SyncAgentWithTemplateInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + }) + .await + .map(SyncResultDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_skills(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "list_skills")?, state).await?; + let scope = skill_scope_arg(args, "list_skills")?; + let output = state + .list_skills + .execute(ListSkillsInput { + scope, + project_root: project.root, + }) + .await + .map(SkillListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_skill", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_skill + .execute(CreateSkillInput { + name: request.name, + description: None, + content: request.content, + scope: request.scope, + project_root: project.root, + }) + .await + .map(SkillDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_skill(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_skill", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_skill + .execute(UpdateSkillInput { + scope: request.scope, + skill_id: parse_skill_id(&request.skill_id)?, + content: request.content, + project_root: project.root, + }) + .await + .map(SkillDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_skill(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "delete_skill")?, state).await?; + let scope = skill_scope_arg(args, "delete_skill")?; + let skill_id = parse_skill_id(string_arg(args, "skillId", "delete_skill")?)?; + state + .delete_skill + .execute(DeleteSkillInput { + scope, + skill_id, + project_root: project.root, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_assign_skill_to_agent( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::("assign_skill_to_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .assign_skill + .execute(AssignSkillToAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + skill: SkillRef::new(parse_skill_id(&request.skill_id)?, request.scope), + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_unassign_skill_from_agent( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::("unassign_skill_from_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .unassign_skill + .execute(UnassignSkillFromAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + skill_id: parse_skill_id(&request.skill_id)?, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_get_project_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "get_project_permissions")?, + state, + ) + .await?; + let output = state + .get_project_permissions + .execute(application::GetProjectPermissionsInput { project }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_project_permissions", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_project_permissions + .execute(UpdateProjectPermissionsInput { + project, + permissions: request.permissions, + }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_agent_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_agent_permissions", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_agent_permissions + .execute(UpdateAgentPermissionsInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + permissions: request.permissions, + }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_resolve_agent_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("resolve_agent_permissions", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .resolve_agent_permissions + .execute(ResolveAgentPermissionsInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + }) + .await + .map(|out| out.effective.map(EffectivePermissionsDto)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_get_mcp_tool_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "get_mcp_tool_permissions")?, + state, + ) + .await?; + let output = state + .read_mcp_tool_permissions + .execute(ReadMcpToolPermissionsInput { project }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_mcp_tool_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "update_project_mcp_tool_permissions", + args, + )?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_project_mcp_tool_permissions + .execute(UpdateProjectMcpToolPermissionsInput { + project, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_agent_mcp_tool_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "update_agent_mcp_tool_permissions", + args, + )?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_agent_mcp_tool_permissions + .execute(UpdateAgentMcpToolPermissionsInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_memories(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?; + let output = state + .list_memories + .execute(ListMemoriesInput { + project_root: project.root, + }) + .await + .map(MemoryListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_get_memory(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "get_memory")?, state).await?; + let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "get_memory")?)?; + let output = state + .get_memory + .execute(GetMemoryInput { + project_root: project.root, + slug, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_memory(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_memory", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_memory + .execute(CreateMemoryInput { + project_root: project.root, + name: request.name, + description: request.description, + r#type: request.r#type, + content: request.content, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_memory(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_memory", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_memory + .execute(UpdateMemoryInput { + project_root: project.root, + slug: backend::dto::parse_memory_slug(&request.slug)?, + description: request.description, + r#type: request.r#type, + content: request.content, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_memory(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "delete_memory")?, state).await?; + let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "delete_memory")?)?; + state + .delete_memory + .execute(DeleteMemoryInput { + project_root: project.root, + slug, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_read_memory_index(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "read_memory_index")?, state) + .await?; + let output = state + .read_memory_index + .execute(ReadMemoryIndexInput { + project_root: project.root, + }) + .await + .map(MemoryIndexDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_resolve_memory_links(args: &Value, state: &BackendCore) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "resolve_memory_links")?, + state, + ) + .await?; + let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "resolve_memory_links")?)?; + let output = state + .resolve_memory_links + .execute(ResolveMemoryLinksInput { + project_root: project.root, + slug, + }) + .await + .map(MemoryLinksDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_recall_memory(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("recall_memory", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .recall_memory + .execute(RecallMemoryInput { + project_root: project.root, + text: request.text, + token_budget: request.token_budget, + }) + .await + .map(MemoryIndexDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_embedder_profiles(state: &BackendCore) -> Result { + let output = state + .list_embedder_profiles + .execute() + .await + .map(EmbedderProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_save_embedder_profile( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::("save_embedder_profile", args)?; + let output = state + .save_embedder_profile + .execute(request.into()) + .await + .map(EmbedderProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_embedder_profile( + args: &Value, + state: &BackendCore, +) -> Result { + let id = string_arg(args, "embedderId", "delete_embedder_profile")?.to_owned(); + state + .delete_embedder_profile + .execute(DeleteEmbedderProfileInput { id }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_describe_embedder_engines(state: &BackendCore) -> Result { + let output = state + .describe_embedder_engines + .execute() + .await + .map(EmbedderEnginesDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_status(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_status")?, state).await?; + let output = state + .git_status + .execute(GitStatusInput { + root: project.root.as_str().to_owned(), + }) + .await + .map(GitStatusListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_stage(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_stage", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .git_stage + .execute(GitStagePathInput { + root: project.root.as_str().to_owned(), + path: request.path, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_unstage(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_unstage", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .git_unstage + .execute(GitStagePathInput { + root: project.root.as_str().to_owned(), + path: request.path, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_commit(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_commit", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .git_commit + .execute(GitCommitInput { + project_id: project.id, + root: project.root.as_str().to_owned(), + message: request.message, + }) + .await + .map(GitCommitDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_branches(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_branches")?, state).await?; + let output = state + .git_branches + .execute(GitBranchesInput { + root: project.root.as_str().to_owned(), + }) + .await + .map(GitBranchesDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_checkout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_checkout", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .git_checkout + .execute(GitCheckoutInput { + project_id: project.id, + root: project.root.as_str().to_owned(), + branch: request.branch, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_log(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_log")?, state).await?; + let output = state + .git_log + .execute(GitLogInput { + root: project.root.as_str().to_owned(), + limit: usize_arg(args, "limit").unwrap_or(50), + }) + .await + .map(GitCommitListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_init(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_init")?, state).await?; + state + .git_init + .execute(GitInitInput { + project_id: project.id, + root: project.root.as_str().to_owned(), + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_graph(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_graph")?, state).await?; + let output = state + .git_graph + .execute(GitGraphInput { + root: project.root.as_str().to_owned(), + limit: usize_arg(args, "limit").unwrap_or(200), + }) + .await + .map(GraphCommitListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_read_conversation_page( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("read_conversation_page", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let conversation = Uuid::parse_str(&request.conversation_id) + .map(domain::ConversationId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid conversation id: {}", request.conversation_id), + })?; + let output = state + .read_conversation_page + .execute(ReadConversationPageInput { + project_root: project.root, + conversation, + cursor: request.cursor(), + limit: request.limit.unwrap_or(0), + }) + .await + .map(TurnPageDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + fn task_id_arg(args: &Value, command: &str) -> Result { args.get("taskId") .and_then(Value::as_str) @@ -2800,6 +3877,45 @@ fn task_id_arg(args: &Value, command: &str) -> Result .and_then(parse_task_id) } +fn arg_container(args: &Value) -> Result<&Value, ErrorDto> { + match args { + Value::Object(map) => Ok(map.get("request").unwrap_or(args)), + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: "args must be an object".to_owned(), + }), + } +} + +fn string_arg<'a>(args: &'a Value, key: &str, command: &str) -> Result<&'a str, ErrorDto> { + arg_container(args)? + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.{key}"), + }) +} + +fn usize_arg(args: &Value, key: &str) -> Option { + arg_container(args) + .ok() + .and_then(|container| container.get(key)) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} + +fn skill_scope_arg(args: &Value, command: &str) -> Result { + let raw = arg_container(args)? + .get("scope") + .cloned() + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.scope"), + })?; + serde_json::from_value(raw).map_err(invalid_args_error) +} + async fn verify_background_task_project( args: &Value, state: &BackendCore, @@ -4226,6 +5342,29 @@ mod tests { (status, body) } + async fn assert_invoke_command_is_routed(state: Arc, cookie: &str, command: &str) { + let (status, body) = invoke_request_for_test(state, cookie, command, json!({})).await; + + assert_ne!( + body["code"], "UNKNOWN_COMMAND", + "{command} must be allowlisted via /api/invoke" + ); + assert!( + status == StatusCode::OK || status == StatusCode::BAD_REQUEST, + "{command} returned unexpected status {status}: {body}" + ); + } + + async fn assert_invoke_commands_are_routed( + state: &Arc, + cookie: &str, + commands: &[&str], + ) { + for command in commands { + assert_invoke_command_is_routed(Arc::clone(state), cookie, command).await; + } + } + async fn create_raw_cli_agent_for_test( state: &Arc, name: &str, @@ -6346,6 +7485,178 @@ mod tests { } } + #[tokio::test] + async fn web_invoke_routes_context_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &["read_project_context", "update_project_context"], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_profile_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "first_run_state", + "reference_profiles", + "detect_profiles", + "list_profiles", + "save_profile", + "delete_profile", + "configure_profiles", + "clone_opencode_profile_from_seed", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_agent_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_agents", + "list_live_agents", + "list_resumable_agents", + "attach_live_agent", + "stop_live_agent", + "create_agent", + "change_agent_profile", + "read_agent_context", + "update_agent_context", + "delete_agent", + "inspect_conversation", + "read_conversation_page", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_template_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_templates", + "create_template", + "update_template", + "delete_template", + "create_agent_from_template", + "detect_agent_drift", + "sync_agent_with_template", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_skill_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_skills", + "create_skill", + "update_skill", + "delete_skill", + "assign_skill_to_agent", + "unassign_skill_from_agent", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_permission_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "get_project_permissions", + "update_project_permissions", + "update_agent_permissions", + "resolve_agent_permissions", + "get_mcp_tool_permissions", + "update_project_mcp_tool_permissions", + "update_agent_mcp_tool_permissions", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_memory_and_embedder_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_memories", + "get_memory", + "create_memory", + "update_memory", + "delete_memory", + "read_memory_index", + "resolve_memory_links", + "recall_memory", + "list_embedder_profiles", + "save_embedder_profile", + "delete_embedder_profile", + "describe_embedder_engines", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_git_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "git_status", + "git_stage", + "git_unstage", + "git_commit", + "git_branches", + "git_checkout", + "git_log", + "git_init", + "git_graph", + ], + ) + .await; + } + #[tokio::test] async fn authorized_ticket_invoke_routes_ticket_carnet_link_and_assign_commands() { let state = state(); diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index d9cb050..6a2d7f9 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -1,38 +1,53 @@ /** - * Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`) - * only when the HTTP transport is selected (`VITE_TRANSPORT="http"`). + * Web client root — ticket #13, lot F2, extended by #90 (missing menus). + * Mounted (instead of the desktop `App`) only when the HTTP transport is + * selected (`VITE_TRANSPORT="http"`). * * Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen}; - * paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's - * `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing - * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the - * server session (`POST /api/logout`), tears down the live WS singleton, and - * returns to pairing. + * paired ⇒ the read-only {@link WebWorkspace}, or a Settings section opened from + * the "Paramètres" menu (#90). It subscribes to the session's `onUnauthorized` + * signal so a `401` from any `/api/invoke` (expired/missing cookie) drops back to + * pairing automatically. "Se déconnecter" (F6) revokes the server session + * (`POST /api/logout`), tears down the live WS singleton, and returns to pairing. * - * "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component - * the desktop shows under `Paramètres → Appareils`. It is what makes a headless - * install usable: generating a pairing code from an already-paired phone instead - * of restarting the server with `--new-code`. + * "Paramètres" (#90) replaces the former single "Appareils" header toggle with a + * menu of sections: **Profils IA** (mounts the transport-neutral + * {@link ProfilesSettings}), **Appareils** (#77, mounts the shared + * {@link DevicesScreen} — what makes a headless install usable: generating a + * pairing code from an already-paired phone instead of restarting the server with + * `--new-code`), and **Déploiement**, disabled — the embedded server is + * desktop-only (a web client is *served by* it, so it must not reconfigure or + * stop it, see `adapters/http/unsupported.ts`). */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; import { Button } from "@/shared"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { DevicesScreen } from "@/features/devices"; +import { ProfilesSettings } from "@/features/first-run"; import { PairingScreen } from "./PairingScreen"; import { WebWorkspace } from "./WebWorkspace"; +import { WebMenuSheet } from "./WebMenuSheet"; interface WebAppProps { /** Injectable session (tests); defaults to the shared singleton. */ session?: WebSession; } +type WebSettingsSection = "aiProfiles" | "devices"; + +const SETTINGS_SECTION_LABEL: Record = { + aiProfiles: "Profils IA", + devices: "Appareils", +}; + export function WebApp({ session }: WebAppProps = {}) { const webSession = session ?? getWebSession(); const [paired, setPaired] = useState(() => webSession.isPaired()); const [signingOut, setSigningOut] = useState(false); - const [showDevices, setShowDevices] = useState(false); + const [settingsSection, setSettingsSection] = useState(null); + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); useEffect(() => { // A 401 anywhere clears the flag and fires this: return to pairing. The live @@ -40,7 +55,8 @@ export function WebApp({ session }: WebAppProps = {}) { // path for a *suffered* revocation (#77): another device revoked us, the // server rejects the next call, and we land back on pairing. return webSession.onUnauthorized(() => { - setShowDevices(false); + setSettingsSection(null); + setSettingsMenuOpen(false); setPaired(false); }); }, [webSession]); @@ -55,7 +71,8 @@ export function WebApp({ session }: WebAppProps = {}) { } finally { disconnectWebLive(); setSigningOut(false); - setShowDevices(false); + setSettingsSection(null); + setSettingsMenuOpen(false); setPaired(false); } } @@ -69,7 +86,8 @@ export function WebApp({ session }: WebAppProps = {}) { const onSessionEnded = useCallback(() => { webSession.forget(); disconnectWebLive(); - setShowDevices(false); + setSettingsSection(null); + setSettingsMenuOpen(false); setPaired(false); }, [webSession]); @@ -90,10 +108,10 @@ export function WebApp({ session }: WebAppProps = {}) { + {title} + +
+
{children}
+
); } diff --git a/frontend/src/features/web/WebMenuSheet.tsx b/frontend/src/features/web/WebMenuSheet.tsx new file mode 100644 index 0000000..1837243 --- /dev/null +++ b/frontend/src/features/web/WebMenuSheet.tsx @@ -0,0 +1,101 @@ +/** + * Full-screen navigation sheet for the web client (ticket #90): a mobile-first + * menu — never a desktop dropdown — reused by both the "Panneaux" menu + * ({@link WebWorkspace}) and the "Paramètres" menu ({@link WebApp}). Same shell + * as {@link WebTicketPickerSheet}: fixed header with a close action, an optional + * shared note, and a scrollable list of entries. The active entry is marked with + * "●"; a disabled entry stays visible (never removed) so its reason is + * discoverable, via `hint` (title tooltip) and/or `secondaryLabel` (always-visible + * text — the only way to communicate "why" on a touch device with no hover). + */ + +import { useEffect, useRef } from "react"; + +import { Button, cn, zIndex } from "@/shared"; + +export interface WebMenuEntry { + id: string; + label: string; + active: boolean; + disabled?: boolean; + /** Tooltip shown on hover/focus (desktop browsers); not relied on for touch. */ + hint?: string; + /** Always-visible secondary text, e.g. "Desktop uniquement". */ + secondaryLabel?: string; + onSelect: () => void; +} + +export interface WebMenuSheetProps { + title: string; + entries: WebMenuEntry[]; + /** Shared note shown once above the list (e.g. why some entries are disabled). */ + note?: string; + onClose: () => void; +} + +export function WebMenuSheet({ title, entries, note, onClose }: WebMenuSheetProps) { + const closeRef = useRef(null); + + useEffect(() => { + closeRef.current?.focus(); + }, []); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + return ( +
+
+ {title} + +
+ + {note && ( +

{note}

+ )} + +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/features/web/WebMenus.test.tsx b/frontend/src/features/web/WebMenus.test.tsx new file mode 100644 index 0000000..b04b45f --- /dev/null +++ b/frontend/src/features/web/WebMenus.test.tsx @@ -0,0 +1,158 @@ +/** + * Ticket #90 — the web "Panneaux" (WebWorkspace) and "Paramètres" (WebApp) menus. + * + * Both replace the previous ad-hoc navigation (a 3-tab tablist, a single + * "Appareils" header toggle) with a full-screen {@link WebMenuSheet}: this pins + * the new contract — entry list, active marker, disabled-with-reason state, and + * that selecting an entry routes the main surface without ever crashing on a + * desktop-only action. + */ +import { describe, it, expect } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { createMockGateways } from "@/adapters/mock"; +import { WebSession } from "@/adapters/http"; +import type { FetchLike } from "@/adapters/http/httpInvoker"; +import type { FlagStore } from "@/adapters/http/webSession"; +import { WebApp } from "./WebApp"; + +function memStore(): FlagStore { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +const okFetch: FetchLike = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", +}); + +async function seededGateways(): Promise { + const gateways = createMockGateways(); + await gateways.project.createProject("Demo", "/srv/demo"); + return gateways; +} + +function renderPairedWebApp(gateways: Gateways) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + return render( + + + , + ); +} + +describe("WebWorkspace « Panneaux » menu (#90)", () => { + it("only enables Projets while no project is open, with a help note", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Projets" })); + expect( + await screen.findByText("Ouvrir un projet pour utiliser ce panneau."), + ).toBeTruthy(); + + const contextEntry = screen.getByRole("button", { name: "Contexte projet" }); + expect(contextEntry).toHaveProperty("disabled", true); + const projectsEntry = screen.getByRole("button", { name: "Projets" }); + expect(projectsEntry).toHaveProperty("disabled", false); + + // A disabled entry does nothing. + fireEvent.click(contextEntry); + expect(screen.queryByText("Contexte projet")).toBeTruthy(); // sheet still open, menu entry still there + }); + + it("routes the main surface to the selected panel once a project is open", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByText("Demo")); + expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); + const agentsEntry = await screen.findByRole("button", { name: "Agents" }); + expect(agentsEntry).toHaveProperty("disabled", false); + fireEvent.click(agentsEntry); + + expect(await screen.findByRole("button", { name: "Panneaux : Agents" })).toBeTruthy(); + }); + + it("closes on Escape without changing the panel", async () => { + renderPairedWebApp(await seededGateways()); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByRole("button", { name: "Panneaux : Travail" }); + + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); + await screen.findByRole("dialog", { name: "Panneaux" }); + fireEvent.keyDown(window, { key: "Escape" }); + + expect(screen.queryByRole("dialog", { name: "Panneaux" })).toBeNull(); + expect(screen.getByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); + }); +}); + +describe("WebProjectsPanel — web project creation (#90)", () => { + it("creates a project via a manual path and opens it", async () => { + renderPairedWebApp(await seededGateways()); + + await screen.findByText("Demo"); + fireEvent.change(screen.getByLabelText("Nom du projet"), { + target: { value: "Nouveau" }, + }); + fireEvent.change(screen.getByLabelText("Chemin du projet"), { + target: { value: "/srv/nouveau" }, + }); + expect( + screen.getByText("Saisir un chemin absolu accessible depuis le serveur IdeA."), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Créer le projet" })); + + // Creating opens it — routes straight to the Travail panel, like opening + // an existing project from the list. + expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); + }); +}); + +describe("WebApp « Paramètres » menu (#90)", () => { + it("opens Profils IA and returns to the workspace via « ← Retour »", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Paramètres" })); + fireEvent.click(await screen.findByRole("button", { name: "Profils IA" })); + + expect(await screen.findByRole("heading", { name: "Profils IA" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^Panneaux :/ })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "← Retour" })); + expect(await screen.findByRole("button", { name: /^Panneaux :/ })).toBeTruthy(); + }); + + it("opens Appareils from the settings menu", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Paramètres" })); + fireEvent.click(await screen.findByRole("button", { name: "Appareils" })); + + expect(await screen.findByRole("button", { name: "← Retour" })).toBeTruthy(); + }); + + it("shows Déploiement as disabled, labelled desktop-only, and does nothing on click", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Paramètres" })); + const deploymentEntry = await screen.findByRole("button", { name: /Déploiement/ }); + expect(deploymentEntry.textContent).toContain("Desktop uniquement"); + expect(deploymentEntry).toHaveProperty("disabled", true); + + fireEvent.click(deploymentEntry); + // Still on the menu — no navigation happened. + expect(screen.getByRole("dialog", { name: "Paramètres" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/web/WebMobile.test.tsx b/frontend/src/features/web/WebMobile.test.tsx index 40ed361..eeeccb1 100644 --- a/frontend/src/features/web/WebMobile.test.tsx +++ b/frontend/src/features/web/WebMobile.test.tsx @@ -100,6 +100,19 @@ describe("web client on a phone viewport", () => { expect(screen.getByTestId("terminal-key-bar")).toBeTruthy(); }); + it("opens the Panneaux menu as a full-screen sheet at 360px (no fixed-width overflow)", async () => { + await renderPairedWebApp(); + + fireEvent.click(await screen.findByText("Demo")); + fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Travail" })); + + const sheet = await screen.findByRole("dialog", { name: "Panneaux" }); + // Full-viewport overlay (`fixed inset-0`), not a fixed-width dropdown that + // could overflow a 360px screen. + expect(sheet.className).toContain("fixed"); + expect(sheet.className).toContain("inset-0"); + }); + it("sizes the agent cell against the dynamic viewport, not a fixed 256px", async () => { await renderPairedWebApp(); diff --git a/frontend/src/features/web/WebProjectsPanel.tsx b/frontend/src/features/web/WebProjectsPanel.tsx new file mode 100644 index 0000000..894432e --- /dev/null +++ b/frontend/src/features/web/WebProjectsPanel.tsx @@ -0,0 +1,124 @@ +/** + * `WebProjectsPanel` — the "Projects" panel, web variant (ticket #90). + * + * The desktop `ProjectsView` project manager depends on `SystemGateway.pickFolder` + * (a native folder-browse dialog), which is desktop-only (see + * `adapters/http/unsupported.ts`). The web client cannot reuse it as-is: project + * creation here takes the root as a manually typed absolute path, resolved + * server-side (the machine running the IdeA server, not the browsing device). + */ + +import { useState } from "react"; + +import type { GatewayError, Project } from "@/domain"; +import { Button, Input, Panel, Spinner } from "@/shared"; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +export interface WebProjectsPanelProps { + projects: Project[] | null; + openId: string | null; + onOpen: (projectId: string) => void; + onCreate: (name: string, root: string) => Promise; + onRefresh: () => void; +} + +export function WebProjectsPanel({ + projects, + openId, + onOpen, + onCreate, + onRefresh, +}: WebProjectsPanelProps) { + const [name, setName] = useState(""); + const [root, setRoot] = useState(""); + const [busy, setBusy] = useState(false); + const [createError, setCreateError] = useState(null); + + const canCreate = name.trim().length > 0 && root.trim().length > 0 && !busy; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!canCreate) return; + setBusy(true); + setCreateError(null); + try { + const created = await onCreate(name.trim(), root.trim()); + setName(""); + setRoot(""); + onOpen(created.id); + } catch (e) { + setCreateError(describe(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Projets

+ +
+ + +
void submit(e)} className="flex flex-col gap-2"> + setName(e.target.value)} + /> + setRoot(e.target.value)} + /> +

+ Saisir un chemin absolu accessible depuis le serveur IdeA. +

+ {createError && ( +

+ {createError} +

+ )} + +
+
+ + {projects === null ? ( + + Chargement des projets… + + ) : projects.length === 0 ? ( +

Aucun projet.

+ ) : ( +
    + {projects.map((p) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 185b6b4..9bc3ed6 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -1,18 +1,22 @@ /** - * Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces). + * Live web workspace — ticket #13/#86, extended by #90 (missing menus). * - * After pairing: list projects, open one read-only, and show its **live** - * work-state — agents (live/idle/busy), background tasks (with cancel/retry) and - * per-agent inbox — updated in real time. The live mechanism is the desktop's own - * transport-neutral {@link useProjectWorkState} hook (refreshes the read-model on - * relevant `event.domain` events pushed over the WS, B7); {@link useLiveReconnect} - * re-synchronises after a WS outage. Background cancel/retry go through the - * {@link WorkStateGateway} — writes via DI, no direct transport. A CLI agent can - * be opened into a live cell (F4). No component touches `@tauri-apps/api`. + * After pairing: list/create projects (web variant — no native folder browse, + * see {@link WebProjectsPanel}), open one read-only, and route the main surface + * through the **"Panneaux" menu** (#90) across the transport-neutral panels also + * used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/ + * skills/permissions/memory/git/projects) plus the web-specific live work-state + * view below. Selecting a panel replaces the main surface — never a floating + * window or dock (desktop-only chrome, out of scope for web, per Architect). * - * Read-only allowlist used (B4/B7): `list_projects`, `open_project`, - * `get_project_work_state`, plus the background `cancel`/`retry` commands and the - * `event.domain` live stream. + * The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted + * implementation rather than the desktop `ProjectWorkStatePanel`: that component's + * "open" affordance attaches into a `LayoutGrid` cell, which the web client never + * mounts (#69 pins this — no `layout-*` grid on web). This panel's own "Ouvrir" + * instead mounts {@link WebAgentCell}, a touch-drivable terminal cell (F4) — the + * mobile equivalent, kept transport-neutral via the same {@link useProjectWorkState} + * hook and {@link WorkStateGateway} writes (cancel/retry). {@link useLiveReconnect} + * re-synchronises the read-model after a WS outage. */ import { useCallback, useEffect, useState } from "react"; @@ -25,12 +29,22 @@ import type { } from "@/domain"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; +import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel"; +import { AgentsPanel } from "@/features/agents"; +import { TemplatesPanel } from "@/features/templates"; +import { SkillsPanel } from "@/features/skills"; +import { PermissionsPanel } from "@/features/permissions"; +import { MemoryPanel } from "@/features/memory"; +import { EmbedderSettings } from "@/features/embedder"; +import { GitPanel } from "@/features/git"; import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; import { useLiveReconnect } from "./useLiveReconnect"; import { useLiveConnectionState } from "./useLiveConnectionState"; import { WebTicketsView } from "./tickets/WebTicketsView"; import { WebSprintsView } from "./tickets/WebSprintsView"; +import { WebProjectsPanel } from "./WebProjectsPanel"; +import { WebMenuSheet } from "./WebMenuSheet"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -39,11 +53,57 @@ function describe(e: unknown): string { return String(e); } +/** The panels reachable from the web "Panneaux" menu (#90). */ +export type WebProjectPanelId = + | "context" + | "work" + | "tickets" + | "sprints" + | "agents" + | "templates" + | "skills" + | "permissions" + | "memory" + | "git" + | "projects"; + +/** Menu order + French labels (ticket #78 — the human UI defaults to French). */ +const WEB_PANEL_ORDER: WebProjectPanelId[] = [ + "context", + "work", + "tickets", + "sprints", + "agents", + "templates", + "skills", + "permissions", + "memory", + "git", + "projects", +]; + +const WEB_PANEL_LABEL: Record = { + context: "Contexte projet", + work: "Travail", + tickets: "Tickets", + sprints: "Sprints", + agents: "Agents", + templates: "Templates", + skills: "Skills", + permissions: "Permissions", + memory: "Mémoire", + git: "Git", + projects: "Projets", +}; + export function WebWorkspace() { const { project } = useGateways(); const [projects, setProjects] = useState(null); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); + const [panel, setPanel] = useState("projects"); + // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. + const [focusSprintId, setFocusSprintId] = useState(null); const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; @@ -69,6 +129,7 @@ export function WebWorkspace() { // its work-state. No layout/PTY is mounted here. await project.openProject(projectId); setOpenId(projectId); + setPanel("work"); } catch (e) { setError(describe(e)); } @@ -76,18 +137,29 @@ export function WebWorkspace() { [project], ); + const createProject = useCallback( + async (name: string, root: string): Promise => { + setError(null); + try { + const created = await project.createProject(name, root); + await refresh(); + return created; + } catch (e) { + setError(describe(e)); + throw e; + } + }, + [project, refresh], + ); + return (
-
-

Projets

- -
+ + {error && ( @@ -95,134 +167,120 @@ export function WebWorkspace() { )} - {projects === null ? ( - - Chargement des projets… - - ) : projects.length === 0 ? ( -

Aucun projet.

+ {panel === "projects" ? ( + void openReadOnly(id)} + onCreate={createProject} + onRefresh={() => void refresh()} + /> + ) : !openId ? ( +

Ouvrir un projet pour utiliser ce panneau.

) : ( -
    - {projects.map((p) => ( -
  • - -
  • - ))} -
- )} - - {openId && ( - + { + setFocusSprintId(sprintId); + setPanel("tickets"); + }} + /> )}
); } -type ProjectTab = "live" | "tickets" | "sprints"; - -const PROJECT_TABS: { id: ProjectTab; label: string }[] = [ - { id: "live", label: "Live" }, - { id: "tickets", label: "Tickets" }, - { id: "sprints", label: "Sprints" }, -]; - -/** - * Project navigation for the web workspace (ticket #86): `Live` / `Tickets` / - * `Sprints`, a compact tablist above the project surfaces — never the desktop - * dock/layout-grid/floating-window shell (carnet #86). Each tab keeps its own - * data (own hook instance), so switching away and back re-fetches fresh rather - * than caching stale state across tabs. - */ -function ProjectTabs({ projectId, root }: { projectId: string; root: string | null }) { - const [tab, setTab] = useState("live"); - // Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab. - const [focusSprintId, setFocusSprintId] = useState(null); - +/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */ +function WebPanelMenuButton({ + activePanel, + hasProject, + onSelect, +}: { + activePanel: WebProjectPanelId; + hasProject: boolean; + onSelect: (panel: WebProjectPanelId) => void; +}) { + const [open, setOpen] = useState(false); return ( -
-
- {PROJECT_TABS.map((t) => ( - - ))} -
- - - - +
+ + {open && ( + setOpen(false)} + entries={WEB_PANEL_ORDER.map((id) => { + const disabled = id !== "projects" && !hasProject; + return { + id, + label: WEB_PANEL_LABEL[id], + active: id === activePanel, + disabled, + hint: disabled ? "Ouvrir un projet pour utiliser ce panneau." : undefined, + onSelect: () => { + onSelect(id); + setOpen(false); + }, + }; + })} + /> + )}
); } +/** Renders the requested panel's body for the opened project. */ +function WebPanelBody({ + panel, + projectId, + root, + focusSprintId, + onViewSprintTickets, +}: { + panel: Exclude; + projectId: string; + root: string | null; + focusSprintId: string | null; + onViewSprintTickets: (sprintId: string) => void; +}) { + switch (panel) { + case "context": + return ; + case "work": + return ; + case "tickets": + return ; + case "sprints": + return ( + + ); + case "agents": + return ; + case "templates": + return ; + case "skills": + return ; + case "permissions": + return ; + case "memory": + return ( +
+ + +
+ ); + case "git": + return ; + } +} + /** * Global reconnection banner (F6). The F3 terminal path writes a "déconnecté" * notice into xterm, but live-only surfaces (no terminal open) had no visible diff --git a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx index dd31a18..879d027 100644 --- a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx +++ b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx @@ -74,28 +74,28 @@ function renderWorkspace(gateways: Gateways) { ); } -async function openProjectAndTab(gateways: Gateways, tabName: string) { +/** Opens the panel named `panelLabel` ("Tickets"/"Sprints"/…) via the "Panneaux" menu. */ +async function openProjectAndTab(gateways: Gateways, panelLabel: string) { renderWorkspace(gateways); fireEvent.click(await screen.findByText("IdeA")); - await screen.findByRole("tablist", { name: "Navigation du projet" }); - fireEvent.click(screen.getByRole("tab", { name: tabName })); + fireEvent.click(await screen.findByRole("button", { name: /^Panneaux :/ })); + fireEvent.click(await screen.findByRole("button", { name: panelLabel })); } describe("WebWorkspace — project tabs (ticket #86)", () => { - it("defaults to the Live tab and lets the user switch to Tickets/Sprints", async () => { + it("defaults to the Travail panel and lets the user switch to Tickets/Sprints via Panneaux", async () => { const { gateways } = await setup(); renderWorkspace(gateways); fireEvent.click(await screen.findByText("IdeA")); - const tablist = await screen.findByRole("tablist", { name: "Navigation du projet" }); - const tabs = within(tablist).getAllByRole("tab"); - expect(tabs.map((t) => t.textContent)).toEqual(["Live", "Tickets", "Sprints"]); - expect(screen.getByRole("tab", { name: "Live" }).getAttribute("aria-selected")).toBe("true"); + expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Tickets" })); + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); + fireEvent.click(await screen.findByRole("button", { name: "Tickets" })); expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Sprints" })); + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Tickets" })); + fireEvent.click(await screen.findByRole("button", { name: "Sprints" })); expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy(); });