From d699d1133d216960c71b80362e94f8eb2aef3b65 Mon Sep 17 00:00:00 2001 From: scawful Date: Sat, 4 Oct 2025 15:14:53 -0400 Subject: [PATCH] feat: Add collaboration features to AgentChatWidget and AgentCollaborationCoordinator --- src/app/editor/editor.cmake | 9 +- src/app/editor/editor_manager.cc | 36 ++ src/app/editor/editor_manager.h | 2 + .../editor/system/agent_chat_history_codec.cc | 10 +- .../editor/system/agent_chat_history_codec.h | 1 + src/app/editor/system/agent_chat_widget.cc | 114 ++++-- src/app/editor/system/agent_chat_widget.h | 18 +- .../system/agent_collaboration_coordinator.cc | 350 ++++++++++++++++++ .../system/agent_collaboration_coordinator.h | 66 ++++ src/app/editor/system/settings_editor.cc | 6 +- src/app/test/test.cmake | 46 ++- src/yaze.cc | 50 ++- 12 files changed, 642 insertions(+), 66 deletions(-) create mode 100644 src/app/editor/system/agent_collaboration_coordinator.cc create mode 100644 src/app/editor/system/agent_collaboration_coordinator.h diff --git a/src/app/editor/editor.cmake b/src/app/editor/editor.cmake index b92c71af..48dd4a69 100644 --- a/src/app/editor/editor.cmake +++ b/src/app/editor/editor.cmake @@ -33,13 +33,12 @@ set( app/editor/system/shortcut_manager.cc app/editor/system/popup_manager.cc app/editor/system/agent_chat_history_codec.cc - app/test/test_manager.cc - app/test/integrated_test_suite.h - app/test/rom_dependent_test_suite.h - app/test/unit_test_suite.h app/editor/system/proposal_drawer.cc ) if(YAZE_WITH_GRPC) - list(APPEND YAZE_APP_EDITOR_SRC app/editor/system/agent_chat_widget.cc) + list(APPEND YAZE_APP_EDITOR_SRC + app/editor/system/agent_chat_widget.cc + app/editor/system/agent_collaboration_coordinator.cc + ) endif() \ No newline at end of file diff --git a/src/app/editor/editor_manager.cc b/src/app/editor/editor_manager.cc index 6364fa5f..18d370a3 100644 --- a/src/app/editor/editor_manager.cc +++ b/src/app/editor/editor_manager.cc @@ -5,6 +5,7 @@ #include #include "absl/status/status.h" +#include "absl/status/statusor.h" #include "absl/strings/match.h" #include "absl/strings/str_cat.h" #include "app/core/features.h" @@ -226,6 +227,41 @@ void EditorManager::Initialize(const std::string& filename) { #ifdef YAZE_WITH_GRPC agent_chat_widget_.SetToastManager(&toast_manager_); agent_chat_widget_.SetProposalDrawer(&proposal_drawer_); + AgentChatWidget::CollaborationCallbacks collab_callbacks; + collab_callbacks.host_session = + [this](const std::string& session_name) + -> absl::StatusOr { + ASSIGN_OR_RETURN(auto session, + collaboration_coordinator_.HostSession(session_name)); + AgentChatWidget::CollaborationCallbacks::SessionContext context; + context.session_id = session.session_id; + context.session_name = session.session_name; + context.participants = session.participants; + return context; + }; + collab_callbacks.join_session = + [this](const std::string& session_code) + -> absl::StatusOr { + ASSIGN_OR_RETURN(auto session, + collaboration_coordinator_.JoinSession(session_code)); + AgentChatWidget::CollaborationCallbacks::SessionContext context; + context.session_id = session.session_id; + context.session_name = session.session_name; + context.participants = session.participants; + return context; + }; + collab_callbacks.leave_session = + [this]() { return collaboration_coordinator_.LeaveSession(); }; + collab_callbacks.refresh_session = + [this]() -> absl::StatusOr { + ASSIGN_OR_RETURN(auto session, collaboration_coordinator_.RefreshSession()); + AgentChatWidget::CollaborationCallbacks::SessionContext context; + context.session_id = session.session_id; + context.session_name = session.session_name; + context.participants = session.participants; + return context; + }; + agent_chat_widget_.SetCollaborationCallbacks(collab_callbacks); #endif // Load critical user settings first diff --git a/src/app/editor/editor_manager.h b/src/app/editor/editor_manager.h index d4c0ab48..afde45a8 100644 --- a/src/app/editor/editor_manager.h +++ b/src/app/editor/editor_manager.h @@ -22,6 +22,7 @@ #include "app/editor/system/popup_manager.h" #include "app/editor/system/proposal_drawer.h" #ifdef YAZE_WITH_GRPC +#include "app/editor/system/agent_collaboration_coordinator.h" #include "app/editor/system/agent_chat_widget.h" #endif #include "app/editor/system/settings_editor.h" @@ -184,6 +185,7 @@ class EditorManager { #ifdef YAZE_WITH_GRPC // Agent chat widget + AgentCollaborationCoordinator collaboration_coordinator_; AgentChatWidget agent_chat_widget_; #endif diff --git a/src/app/editor/system/agent_chat_history_codec.cc b/src/app/editor/system/agent_chat_history_codec.cc index 5288a25b..261dd736 100644 --- a/src/app/editor/system/agent_chat_history_codec.cc +++ b/src/app/editor/system/agent_chat_history_codec.cc @@ -194,6 +194,8 @@ absl::StatusOr AgentChatHistoryCodec::Load( const auto& collab_json = json["collaboration"]; snapshot.collaboration.active = collab_json.value("active", false); snapshot.collaboration.session_id = collab_json.value("session_id", ""); + snapshot.collaboration.session_name = + collab_json.value("session_name", ""); snapshot.collaboration.participants.clear(); if (collab_json.contains("participants") && collab_json["participants"].is_array()) { @@ -208,6 +210,11 @@ absl::StatusOr AgentChatHistoryCodec::Load( snapshot.collaboration.last_synced = ParseTimestamp(collab_json["last_synced"]); } + if (snapshot.collaboration.session_name.empty() && + !snapshot.collaboration.session_id.empty()) { + snapshot.collaboration.session_name = + snapshot.collaboration.session_id; + } } if (json.contains("multimodal") && json["multimodal"].is_object()) { @@ -241,7 +248,7 @@ absl::Status AgentChatHistoryCodec::Save( const std::filesystem::path& path, const Snapshot& snapshot) { #if defined(YAZE_WITH_JSON) Json json; - json["version"] = 2; + json["version"] = 3; json["messages"] = Json::array(); for (const auto& message : snapshot.history) { @@ -284,6 +291,7 @@ absl::Status AgentChatHistoryCodec::Save( Json collab_json; collab_json["active"] = snapshot.collaboration.active; collab_json["session_id"] = snapshot.collaboration.session_id; + collab_json["session_name"] = snapshot.collaboration.session_name; collab_json["participants"] = snapshot.collaboration.participants; if (snapshot.collaboration.last_synced != absl::InfinitePast()) { collab_json["last_synced"] = absl::FormatTime( diff --git a/src/app/editor/system/agent_chat_history_codec.h b/src/app/editor/system/agent_chat_history_codec.h index aaa93015..368e31c4 100644 --- a/src/app/editor/system/agent_chat_history_codec.h +++ b/src/app/editor/system/agent_chat_history_codec.h @@ -23,6 +23,7 @@ class AgentChatHistoryCodec { struct CollaborationState { bool active = false; std::string session_id; + std::string session_name; std::vector participants; absl::Time last_synced = absl::InfinitePast(); }; diff --git a/src/app/editor/system/agent_chat_widget.cc b/src/app/editor/system/agent_chat_widget.cc index 3179bc94..e69e73e6 100644 --- a/src/app/editor/system/agent_chat_widget.cc +++ b/src/app/editor/system/agent_chat_widget.cc @@ -3,11 +3,13 @@ #include #include #include +#include #include #include #include #include +#include "absl/status/status.h" #include "absl/strings/str_format.h" #include "absl/time/clock.h" #include "absl/time/time.h" @@ -173,8 +175,13 @@ void AgentChatWidget::EnsureHistoryLoaded() { collaboration_state_.active = snapshot.collaboration.active; collaboration_state_.session_id = snapshot.collaboration.session_id; + collaboration_state_.session_name = snapshot.collaboration.session_name; collaboration_state_.participants = snapshot.collaboration.participants; collaboration_state_.last_synced = snapshot.collaboration.last_synced; + if (collaboration_state_.session_name.empty() && + !collaboration_state_.session_id.empty()) { + collaboration_state_.session_name = collaboration_state_.session_id; + } multimodal_state_.last_capture_path = snapshot.multimodal.last_capture_path; @@ -202,6 +209,7 @@ void AgentChatWidget::PersistHistory() { snapshot.history = agent_service_.GetHistory(); snapshot.collaboration.active = collaboration_state_.active; snapshot.collaboration.session_id = collaboration_state_.session_id; + snapshot.collaboration.session_name = collaboration_state_.session_name; snapshot.collaboration.participants = collaboration_state_.participants; snapshot.collaboration.last_synced = collaboration_state_.last_synced; snapshot.multimodal.last_capture_path = @@ -486,8 +494,21 @@ void AgentChatWidget::RenderCollaborationPanel() { const bool connected = collaboration_state_.active; ImGui::Text("Status: %s", connected ? "Connected" : "Not connected"); + if (!collaboration_state_.session_name.empty()) { + ImGui::Text("Session Name: %s", + collaboration_state_.session_name.c_str()); + } if (!collaboration_state_.session_id.empty()) { - ImGui::Text("Session ID: %s", collaboration_state_.session_id.c_str()); + ImGui::Text("Session Code: %s", + collaboration_state_.session_id.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Copy Code")) { + ImGui::SetClipboardText(collaboration_state_.session_id.c_str()); + if (toast_manager_) { + toast_manager_->Show("Session code copied", + ToastType::kInfo, 2.5f); + } + } } if (collaboration_state_.last_synced != absl::InfinitePast()) { ImGui::TextDisabled( @@ -498,9 +519,10 @@ void AgentChatWidget::RenderCollaborationPanel() { ImGui::Separator(); - bool can_host = static_cast(collaboration_callbacks_.host_session); - bool can_join = static_cast(collaboration_callbacks_.join_session); - bool can_leave = static_cast(collaboration_callbacks_.leave_session); + const bool can_host = static_cast(collaboration_callbacks_.host_session); + const bool can_join = static_cast(collaboration_callbacks_.join_session); + const bool can_leave = static_cast(collaboration_callbacks_.leave_session); + const bool can_refresh = static_cast(collaboration_callbacks_.refresh_session); ImGui::InputTextWithHint("##session_name", "Session name", session_name_buffer_, @@ -515,23 +537,23 @@ void AgentChatWidget::RenderCollaborationPanel() { ToastType::kWarning, 3.0f); } } else { - absl::Status status = - collaboration_callbacks_.host_session(name); - if (status.ok()) { - collaboration_state_.active = true; - collaboration_state_.session_id = name; - collaboration_state_.participants.clear(); - collaboration_state_.last_synced = absl::Now(); - last_collaboration_action_ = absl::Now(); - RefreshParticipants(); + auto session_or = collaboration_callbacks_.host_session(name); + if (session_or.ok()) { + ApplyCollaborationSession(session_or.value(), /*update_action_timestamp=*/true); + std::snprintf(join_code_buffer_, sizeof(join_code_buffer_), "%s", + collaboration_state_.session_id.c_str()); + session_name_buffer_[0] = '\0'; if (toast_manager_) { - toast_manager_->Show("Hosting collaborative session", - ToastType::kSuccess, 3.5f); + toast_manager_->Show( + absl::StrFormat("Hosting session %s", + collaboration_state_.session_id.c_str()), + ToastType::kSuccess, 3.5f); } MarkHistoryDirty(); } else if (toast_manager_) { toast_manager_->Show( - absl::StrFormat("Failed to host: %s", status.message()), + absl::StrFormat("Failed to host: %s", + session_or.status().message()), ToastType::kError, 5.0f); } } @@ -556,23 +578,22 @@ void AgentChatWidget::RenderCollaborationPanel() { ToastType::kWarning, 3.0f); } } else { - absl::Status status = - collaboration_callbacks_.join_session(code); - if (status.ok()) { - collaboration_state_.active = true; - collaboration_state_.session_id = code; - collaboration_state_.last_synced = absl::Now(); - last_collaboration_action_ = absl::Now(); - RefreshParticipants(); + auto session_or = collaboration_callbacks_.join_session(code); + if (session_or.ok()) { + ApplyCollaborationSession(session_or.value(), /*update_action_timestamp=*/true); + std::snprintf(join_code_buffer_, sizeof(join_code_buffer_), "%s", + collaboration_state_.session_id.c_str()); if (toast_manager_) { toast_manager_->Show( - absl::StrFormat("Joined session %s", code.c_str()), + absl::StrFormat("Joined session %s", + collaboration_state_.session_id.c_str()), ToastType::kSuccess, 3.5f); } MarkHistoryDirty(); } else if (toast_manager_) { toast_manager_->Show( - absl::StrFormat("Failed to join: %s", status.message()), + absl::StrFormat("Failed to join: %s", + session_or.status().message()), ToastType::kError, 5.0f); } } @@ -592,6 +613,7 @@ void AgentChatWidget::RenderCollaborationPanel() { : absl::OkStatus(); if (status.ok()) { collaboration_state_ = CollaborationState{}; + join_code_buffer_[0] = '\0'; if (toast_manager_) { toast_manager_->Show("Left collaborative session", ToastType::kInfo, 3.0f); @@ -608,9 +630,14 @@ void AgentChatWidget::RenderCollaborationPanel() { if (connected) { ImGui::Separator(); + if (!can_refresh) ImGui::BeginDisabled(); if (ImGui::Button("Refresh Participants")) { - RefreshParticipants(); + RefreshCollaboration(); } + if (!can_refresh && ImGui::IsItemHovered()) { + ImGui::SetTooltip("Provide refresh_session callback to enable"); + } + if (!can_refresh) ImGui::EndDisabled(); if (collaboration_state_.participants.empty()) { ImGui::TextDisabled("Awaiting participant list..."); } else { @@ -727,26 +754,45 @@ void AgentChatWidget::RenderMultimodalPanel() { } } -void AgentChatWidget::RefreshParticipants() { - if (!collaboration_callbacks_.refresh_participants) { +void AgentChatWidget::RefreshCollaboration() { + if (!collaboration_callbacks_.refresh_session) { return; } - auto participants_or = collaboration_callbacks_.refresh_participants(); - if (!participants_or.ok()) { + auto session_or = collaboration_callbacks_.refresh_session(); + if (!session_or.ok()) { + if (session_or.status().code() == absl::StatusCode::kNotFound) { + collaboration_state_ = CollaborationState{}; + join_code_buffer_[0] = '\0'; + MarkHistoryDirty(); + } if (toast_manager_) { toast_manager_->Show( absl::StrFormat("Failed to refresh participants: %s", - participants_or.status().message()), + session_or.status().message()), ToastType::kError, 5.0f); } return; } - collaboration_state_.participants = participants_or.value(); - collaboration_state_.last_synced = absl::Now(); + ApplyCollaborationSession(session_or.value(), /*update_action_timestamp=*/false); MarkHistoryDirty(); } +void AgentChatWidget::ApplyCollaborationSession( + const CollaborationCallbacks::SessionContext& context, + bool update_action_timestamp) { + collaboration_state_.active = true; + collaboration_state_.session_id = context.session_id; + collaboration_state_.session_name = context.session_name.empty() + ? context.session_id + : context.session_name; + collaboration_state_.participants = context.participants; + collaboration_state_.last_synced = absl::Now(); + if (update_action_timestamp) { + last_collaboration_action_ = absl::Now(); + } +} + void AgentChatWidget::MarkHistoryDirty() { history_dirty_ = true; const absl::Time now = absl::Now(); diff --git a/src/app/editor/system/agent_chat_widget.h b/src/app/editor/system/agent_chat_widget.h index ba646e5c..0b7a489f 100644 --- a/src/app/editor/system/agent_chat_widget.h +++ b/src/app/editor/system/agent_chat_widget.h @@ -30,10 +30,16 @@ class AgentChatWidget { void SetRomContext(Rom* rom); struct CollaborationCallbacks { - std::function host_session; - std::function join_session; + struct SessionContext { + std::string session_id; + std::string session_name; + std::vector participants; + }; + + std::function(const std::string&)> host_session; + std::function(const std::string&)> join_session; std::function leave_session; - std::function>()> refresh_participants; + std::function()> refresh_session; }; struct MultimodalCallbacks { @@ -61,6 +67,7 @@ class AgentChatWidget { struct CollaborationState { bool active = false; std::string session_id; + std::string session_name; std::vector participants; absl::Time last_synced = absl::InfinitePast(); }; @@ -86,7 +93,10 @@ class AgentChatWidget { int new_total_proposals); void RenderCollaborationPanel(); void RenderMultimodalPanel(); - void RefreshParticipants(); + void RefreshCollaboration(); + void ApplyCollaborationSession( + const CollaborationCallbacks::SessionContext& context, + bool update_action_timestamp); void MarkHistoryDirty(); cli::agent::ConversationalAgentService agent_service_; diff --git a/src/app/editor/system/agent_collaboration_coordinator.cc b/src/app/editor/system/agent_collaboration_coordinator.cc new file mode 100644 index 00000000..8a537169 --- /dev/null +++ b/src/app/editor/system/agent_collaboration_coordinator.cc @@ -0,0 +1,350 @@ +#include "app/editor/system/agent_collaboration_coordinator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/strip.h" +#include "app/core/platform/file_dialog.h" +#include "util/macro.h" + +namespace yaze { +namespace editor { + +namespace { + +std::filesystem::path ExpandUserPath(std::string path) { + if (!path.empty() && path.front() == '~') { + const char* home = std::getenv("HOME"); + if (home != nullptr) { + path.replace(0, 1, home); + } + } + return std::filesystem::path(path); +} + +std::string Trimmed(const std::string& value) { + return std::string(absl::StripAsciiWhitespace(value)); +} + +} // namespace + +AgentCollaborationCoordinator::AgentCollaborationCoordinator() + : local_user_(LocalUserName()) {} + +absl::StatusOr +AgentCollaborationCoordinator::HostSession(const std::string& session_name) { + const std::string trimmed = Trimmed(session_name); + if (trimmed.empty()) { + return absl::InvalidArgumentError("Session name cannot be empty"); + } + + RETURN_IF_ERROR(EnsureDirectory()); + + SessionFileData data; + data.session_name = trimmed; + data.session_code = GenerateSessionCode(); + data.host = local_user_; + data.participants.push_back(local_user_); + + std::filesystem::path path = SessionFilePath(data.session_code); + + // Collision avoidance (extremely unlikely but cheap to guard against). + int attempts = 0; + while (std::filesystem::exists(path) && attempts++ < 5) { + data.session_code = GenerateSessionCode(); + path = SessionFilePath(data.session_code); + } + if (std::filesystem::exists(path)) { + return absl::InternalError( + "Unable to allocate a new collaboration session code"); + } + + RETURN_IF_ERROR(WriteSessionFile(path, data)); + + active_ = true; + hosting_ = true; + session_id_ = data.session_code; + session_name_ = data.session_name; + + SessionInfo info; + info.session_id = data.session_code; + info.session_name = data.session_name; + info.participants = data.participants; + return info; +} + +absl::StatusOr +AgentCollaborationCoordinator::JoinSession(const std::string& session_code) { + const std::string normalized = NormalizeSessionCode(session_code); + if (normalized.empty()) { + return absl::InvalidArgumentError("Session code cannot be empty"); + } + + RETURN_IF_ERROR(EnsureDirectory()); + + std::filesystem::path path = SessionFilePath(normalized); + ASSIGN_OR_RETURN(SessionFileData data, LoadSessionFile(path)); + + const auto already_joined = std::find(data.participants.begin(), + data.participants.end(), local_user_); + if (already_joined == data.participants.end()) { + data.participants.push_back(local_user_); + RETURN_IF_ERROR(WriteSessionFile(path, data)); + } + + active_ = true; + hosting_ = false; + session_id_ = data.session_code.empty() ? normalized : data.session_code; + session_name_ = data.session_name.empty() ? session_id_ : data.session_name; + + SessionInfo info; + info.session_id = session_id_; + info.session_name = session_name_; + info.participants = data.participants; + return info; +} + +absl::Status AgentCollaborationCoordinator::LeaveSession() { + if (!active_) { + return absl::FailedPreconditionError("No collaborative session active"); + } + + const std::filesystem::path path = SessionFilePath(session_id_); + absl::Status status = absl::OkStatus(); + + if (hosting_) { + std::error_code ec; + std::filesystem::remove(path, ec); + if (ec) { + status = absl::InternalError( + absl::StrFormat("Failed to clean up session file: %s", ec.message())); + } + } else { + auto data_or = LoadSessionFile(path); + if (data_or.ok()) { + SessionFileData data = std::move(data_or.value()); + auto end = std::remove(data.participants.begin(), data.participants.end(), + local_user_); + data.participants.erase(end, data.participants.end()); + + if (data.participants.empty()) { + std::error_code ec; + std::filesystem::remove(path, ec); + if (ec) { + status = absl::InternalError(absl::StrFormat( + "Failed to remove empty session file: %s", ec.message())); + } + } else { + status = WriteSessionFile(path, data); + } + } else { + // If the session file has already disappeared, treat it as success. + status = absl::OkStatus(); + } + } + + active_ = false; + hosting_ = false; + session_id_.clear(); + session_name_.clear(); + + return status; +} + +absl::StatusOr +AgentCollaborationCoordinator::RefreshSession() { + if (!active_) { + return absl::FailedPreconditionError("No collaborative session active"); + } + + const std::filesystem::path path = SessionFilePath(session_id_); + auto data_or = LoadSessionFile(path); + if (!data_or.ok()) { + absl::Status status = data_or.status(); + if (absl::IsNotFound(status)) { + active_ = false; + hosting_ = false; + session_id_.clear(); + session_name_.clear(); + } + return status; + } + + SessionFileData data = std::move(data_or.value()); + session_name_ = data.session_name.empty() ? session_id_ : data.session_name; + SessionInfo info; + info.session_id = session_id_; + info.session_name = session_name_; + info.participants = data.participants; + return info; +} + +absl::Status AgentCollaborationCoordinator::EnsureDirectory() const { + std::error_code ec; + std::filesystem::create_directories(SessionsDirectory(), ec); + if (ec) { + return absl::InternalError(absl::StrFormat( + "Failed to create collaboration directory: %s", ec.message())); + } + return absl::OkStatus(); +} + +std::string AgentCollaborationCoordinator::LocalUserName() const { + const char* override_name = std::getenv("YAZE_USER_NAME"); + const char* user = override_name != nullptr ? override_name : std::getenv("USER"); + if (user == nullptr) { + user = std::getenv("USERNAME"); + } + std::string base = (user != nullptr && std::strlen(user) > 0) + ? std::string(user) + : std::string("Player"); + + const char* host = std::getenv("HOSTNAME"); +#if defined(_WIN32) + if (host == nullptr) { + host = std::getenv("COMPUTERNAME"); + } +#endif + if (host != nullptr && std::strlen(host) > 0) { + return absl::StrCat(base, "@", host); + } + return base; +} + +std::string AgentCollaborationCoordinator::NormalizeSessionCode( + const std::string& input) const { + std::string normalized = Trimmed(input); + normalized.erase(std::remove_if(normalized.begin(), normalized.end(), + [](unsigned char c) { + return !std::isalnum( + static_cast(c)); + }), + normalized.end()); + std::transform(normalized.begin(), normalized.end(), normalized.begin(), + [](unsigned char c) { + return static_cast( + std::toupper(static_cast(c))); + }); + return normalized; +} + +std::string AgentCollaborationCoordinator::GenerateSessionCode() const { + static constexpr char kAlphabet[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution dist(0, sizeof(kAlphabet) - 2); + + std::string code(6, '0'); + for (char& ch : code) { + ch = kAlphabet[dist(rng)]; + } + return code; +} + +std::filesystem::path AgentCollaborationCoordinator::SessionsDirectory() const { + std::filesystem::path base = ExpandUserPath(core::GetConfigDirectory()); + if (base.empty()) { + base = ExpandUserPath(".yaze"); + } + return base / "agent" / "sessions"; +} + +std::filesystem::path AgentCollaborationCoordinator::SessionFilePath( + const std::string& code) const { + return SessionsDirectory() / (code + ".session"); +} + +absl::StatusOr +AgentCollaborationCoordinator::LoadSessionFile( + const std::filesystem::path& path) const { + std::ifstream file(path); + if (!file.is_open()) { + return absl::NotFoundError( + absl::StrFormat("Session %s does not exist", path.string())); + } + + SessionFileData data; + data.session_code = path.stem().string(); + + std::string line; + while (std::getline(file, line)) { + auto pos = line.find(':'); + if (pos == std::string::npos) { + continue; + } + std::string key = line.substr(0, pos); + std::string value = Trimmed(line.substr(pos + 1)); + if (key == "name") { + data.session_name = value; + } else if (key == "code") { + data.session_code = NormalizeSessionCode(value); + } else if (key == "host") { + data.host = value; + data.participants.push_back(value); + } else if (key == "participant") { + if (std::find(data.participants.begin(), data.participants.end(), value) == + data.participants.end()) { + data.participants.push_back(value); + } + } + } + + if (data.session_name.empty()) { + data.session_name = data.session_code; + } + if (!data.host.empty()) { + auto host_it = std::find(data.participants.begin(), data.participants.end(), + data.host); + if (host_it == data.participants.end()) { + data.participants.insert(data.participants.begin(), data.host); + } else if (host_it != data.participants.begin()) { + std::rotate(data.participants.begin(), host_it, + std::next(host_it)); + } + } + + return data; +} + +absl::Status AgentCollaborationCoordinator::WriteSessionFile( + const std::filesystem::path& path, const SessionFileData& data) const { + std::ofstream file(path, std::ios::trunc); + if (!file.is_open()) { + return absl::InternalError( + absl::StrFormat("Failed to write session file: %s", path.string())); + } + + file << "name:" << data.session_name << "\n"; + file << "code:" << data.session_code << "\n"; + file << "host:" << data.host << "\n"; + + std::set seen; + seen.insert(data.host); + for (const auto& participant : data.participants) { + if (seen.insert(participant).second) { + file << "participant:" << participant << "\n"; + } + } + + file.flush(); + if (!file.good()) { + return absl::InternalError( + absl::StrFormat("Failed to flush session file: %s", path.string())); + } + return absl::OkStatus(); +} + +} // namespace editor +} // namespace yaze diff --git a/src/app/editor/system/agent_collaboration_coordinator.h b/src/app/editor/system/agent_collaboration_coordinator.h new file mode 100644 index 00000000..cc443c04 --- /dev/null +++ b/src/app/editor/system/agent_collaboration_coordinator.h @@ -0,0 +1,66 @@ +#ifndef YAZE_APP_EDITOR_SYSTEM_AGENT_COLLABORATION_COORDINATOR_H_ +#define YAZE_APP_EDITOR_SYSTEM_AGENT_COLLABORATION_COORDINATOR_H_ + +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" + +namespace yaze { +namespace editor { + +// Coordinates lightweight collaboration features for the agent chat widget. +// This implementation uses the local filesystem as a shared backing store so +// multiple editor instances on the same machine can experiment with +// collaborative sessions while a full backend service is under development. +class AgentCollaborationCoordinator { + public: + struct SessionInfo { + std::string session_id; + std::string session_name; + std::vector participants; + }; + + AgentCollaborationCoordinator(); + + absl::StatusOr HostSession(const std::string& session_name); + absl::StatusOr JoinSession(const std::string& session_code); + absl::Status LeaveSession(); + absl::StatusOr RefreshSession(); + + bool active() const { return active_; } + const std::string& session_id() const { return session_id_; } + const std::string& session_name() const { return session_name_; } + + private: + struct SessionFileData { + std::string session_name; + std::string session_code; + std::string host; + std::vector participants; + }; + + absl::Status EnsureDirectory() const; + std::string LocalUserName() const; + std::string NormalizeSessionCode(const std::string& input) const; + std::string GenerateSessionCode() const; + std::filesystem::path SessionsDirectory() const; + std::filesystem::path SessionFilePath(const std::string& code) const; + absl::StatusOr LoadSessionFile( + const std::filesystem::path& path) const; + absl::Status WriteSessionFile(const std::filesystem::path& path, + const SessionFileData& data) const; + + bool active_ = false; + bool hosting_ = false; + std::string session_id_; + std::string session_name_; + std::string local_user_; +}; + +} // namespace editor +} // namespace yaze + +#endif // YAZE_APP_EDITOR_SYSTEM_AGENT_COLLABORATION_COORDINATOR_H_ diff --git a/src/app/editor/system/settings_editor.cc b/src/app/editor/system/settings_editor.cc index 9d8b4196..a2d75bac 100644 --- a/src/app/editor/system/settings_editor.cc +++ b/src/app/editor/system/settings_editor.cc @@ -2,7 +2,7 @@ #include "app/editor/system/settings_editor.h" #include "absl/status/status.h" -#include "app/core/features.h" +#include "app/gui/feature_flags_menu.h" #include "app/gfx/performance_profiler.h" #include "app/gui/style.h" #include "imgui/imgui.h" @@ -10,11 +10,9 @@ namespace yaze { namespace editor { -using ImGui::BeginChild; using ImGui::BeginTabBar; using ImGui::BeginTabItem; using ImGui::BeginTable; -using ImGui::EndChild; using ImGui::EndTabBar; using ImGui::EndTabItem; using ImGui::EndTable; @@ -50,7 +48,7 @@ absl::Status SettingsEditor::Update() { } void SettingsEditor::DrawGeneralSettings() { - static core::FlagsMenu flags; + static gui::FlagsMenu flags; if (BeginTable("##SettingsTable", 4, ImGuiTableFlags_Reorderable | ImGuiTableFlags_Hideable | diff --git a/src/app/test/test.cmake b/src/app/test/test.cmake index 1b216278..b1e8b441 100644 --- a/src/app/test/test.cmake +++ b/src/app/test/test.cmake @@ -1,21 +1,33 @@ -# Testing system components for YAZE +# ============================================================================== +# Yaze Test Support Library +# ============================================================================== +# This library contains the core test manager and infrastructure for running +# tests within the application. +# +# It is intended to be linked by test executables, not by the main +# application itself. +# +# Dependencies: All major yaze libraries. +# ============================================================================== -set(YAZE_TEST_CORE_SOURCES - app/test/test_manager.cc - app/test/test_manager.h - app/test/unit_test_suite.h - app/test/integrated_test_suite.h - app/test/rom_dependent_test_suite.h - app/test/e2e_test_suite.h - app/test/zscustomoverworld_test_suite.h +add_library(yaze_test_support STATIC app/test/test_manager.cc) + +target_include_directories(yaze_test_support PUBLIC + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/incl + ${PROJECT_BINARY_DIR} ) -# Add test sources to the main app target if testing is enabled -if(BUILD_TESTING) - list(APPEND YAZE_APP_SRC ${YAZE_TEST_CORE_SOURCES}) -endif() +# The test support library needs to link against all the major app libraries +# to be able to test them. +target_link_libraries(yaze_test_support PUBLIC + yaze_editor + yaze_core_lib + yaze_gui + yaze_zelda3 + yaze_gfx + yaze_util + yaze_common +) -# Set up test-specific compiler flags and definitions -if(BUILD_TESTING) - target_compile_definitions(yaze_lib PRIVATE YAZE_ENABLE_TESTING=1) -endif() +message(STATUS "✓ yaze_test_support library configured") \ No newline at end of file diff --git a/src/yaze.cc b/src/yaze.cc index 710c9b6b..4993b452 100644 --- a/src/yaze.cc +++ b/src/yaze.cc @@ -4,8 +4,11 @@ #include #include #include +#include #include #include +#include +#include #include "app/core/controller.h" #include "app/core/platform/app_delegate.h" @@ -13,21 +16,65 @@ #include "app/rom.h" #include "app/zelda3/overworld/overworld.h" #include "util/flag.h" +#include "util/log.h" #include "yaze_config.h" DEFINE_FLAG(std::string, rom_file, "", "Path to the ROM file to load. " "If not specified, the app will run without a ROM."); +DEFINE_FLAG( + std::string, log_level, "info", + "Minimum log level to output (e.g., debug, info, warn, error, fatal)."); +DEFINE_FLAG(std::string, log_file, "", + "Path to the log file. If empty, logs to stderr."); +DEFINE_FLAG(std::string, log_categories, "", + "Comma-separated list of log categories to enable."); + // Static variables for library state static bool g_library_initialized = false; int yaze_app_main(int argc, char** argv) { yaze::util::FlagParser parser(yaze::util::global_flag_registry()); RETURN_IF_EXCEPTION(parser.Parse(argc, argv)); + + // --- Configure Logging System --- + auto string_to_log_level = [](const std::string& s) { + std::string upper_s; + std::transform(s.begin(), s.end(), std::back_inserter(upper_s), + ::toupper); + if (upper_s == "DEBUG") return yaze::util::LogLevel::DEBUG; + if (upper_s == "INFO") return yaze::util::LogLevel::INFO; + if (upper_s == "WARN" || upper_s == "WARNING") + return yaze::util::LogLevel::WARNING; + if (upper_s == "ERROR") return yaze::util::LogLevel::ERROR; + if (upper_s == "FATAL") return yaze::util::LogLevel::FATAL; + return yaze::util::LogLevel::INFO; // Default + }; + + auto split_categories = [](const std::string& s) { + std::set result; + std::stringstream ss(s); + std::string item; + while (std::getline(ss, item, ',')) { + if (!item.empty()) { + result.insert(item); + } + } + return result; + }; + + yaze::util::LogManager::instance().configure( + string_to_log_level(FLAGS_log_level->Get()), FLAGS_log_file->Get(), + split_categories(FLAGS_log_categories->Get())); + + LOG_INFO("App", "Yaze starting up..."); + LOG_INFO("App", "Version: %s", YAZE_VERSION_STRING); + std::string rom_filename = ""; if (!FLAGS_rom_file->Get().empty()) { rom_filename = FLAGS_rom_file->Get(); + LOG_INFO("App", "Loading ROM file: %s", rom_filename); } #ifdef __APPLE__ @@ -39,12 +86,13 @@ int yaze_app_main(int argc, char** argv) { while (controller->IsActive()) { controller->OnInput(); if (auto status = controller->OnLoad(); !status.ok()) { - std::cerr << status.message() << std::endl; + LOG_ERROR("App", "Controller OnLoad failed: %s", status.message()); break; } controller->DoRender(); } controller->OnExit(); + LOG_INFO("App", "Yaze shutting down."); return EXIT_SUCCESS; }