From 15c1181aef77a2a44d8d0f9628a72c89e3899ad7 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 18 Oct 2026 11:55:40 -0500 Subject: [PATCH] feat(shield): colour shield/merge status by outcome + spinner; friendly errors The shield/merge status line was amber only while in-flight and uncoloured for every terminal state (success, failure, uncertain). It now tracks an OpOutcome (display-only) and colours the terminal line Success/Error/Warning accordingly, shows a LoadingDots spinner while the 120s scan runs, and maps the raw daemon error through friendlyRpcError. The actual shield/merge submit logic is unchanged (no "Try again" that could double-submit a fund move). Co-Authored-By: Claude Opus 4.8 --- src/ui/windows/shield_dialog.cpp | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/ui/windows/shield_dialog.cpp b/src/ui/windows/shield_dialog.cpp index 212774b..fb4071a 100644 --- a/src/ui/windows/shield_dialog.cpp +++ b/src/ui/windows/shield_dialog.cpp @@ -41,6 +41,8 @@ static bool s_advanced = false; // Advanced (fee + batch size) static bool s_confirm = false; // inline "confirm before moving funds" phase static bool s_operation_pending = false; static bool s_op_terminal = false; // async op reached success/failed — freeze inputs +enum class OpOutcome { None, Success, Failed, Uncertain }; +static OpOutcome s_op_outcome = OpOutcome::None; // tags the terminal status for colour (display only) static std::string s_operation_id; static std::string s_status_message; static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds) @@ -68,6 +70,7 @@ static void resetTransient() ++s_poll_session; s_operation_pending = false; s_op_terminal = false; + s_op_outcome = OpOutcome::None; s_confirm = false; s_status_message.clear(); s_operation_id.clear(); @@ -232,6 +235,7 @@ static void submitOperation(App* app) if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it s_operation_pending = true; s_op_terminal = false; + s_op_outcome = OpOutcome::None; s_status_message = TR("shield_submitting"); s_last_poll = ImGui::GetTime(); @@ -253,8 +257,9 @@ static void submitOperation(App* app) app->trackOperation(s_operation_id); } else { s_operation_pending = false; s_op_terminal = true; - s_status_message = std::string(TR("shield_error_prefix")) + error; - Notifications::instance().error(std::string(TR("shield_send_failed")) + error); + s_op_outcome = OpOutcome::Failed; + s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error); + Notifications::instance().error(std::string(TR("shield_send_failed")) + util::friendlyRpcError(error)); } }; }); @@ -309,13 +314,15 @@ static void submitOperation(App* app) app->trackOperation(s_operation_id); } else if (uncertain) { s_operation_pending = false; s_op_terminal = true; + s_op_outcome = OpOutcome::Uncertain; s_confirm = false; // don't leave Confirm armed for a merge that may still be running s_status_message = TR("merge_submit_uncertain"); Notifications::instance().warning(TR("merge_submit_uncertain")); } else { s_operation_pending = false; s_op_terminal = true; - s_status_message = std::string(TR("shield_error_prefix")) + error; - Notifications::instance().error(std::string(TR("merge_send_failed")) + error); + s_op_outcome = OpOutcome::Failed; + s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error); + Notifications::instance().error(std::string(TR("merge_send_failed")) + util::friendlyRpcError(error)); } }; }); @@ -340,6 +347,7 @@ static void submitPerAddress(App* app) s_confirm = false; s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op s_op_terminal = false; + s_op_outcome = OpOutcome::None; } } @@ -384,6 +392,7 @@ static void pollOperation(App* app) // client popped it with z_getoperationresult). It registers an op before returning its id, // so this is no race. The outcome can't be confirmed; stop polling. s_operation_pending = false; s_op_terminal = true; + s_op_outcome = OpOutcome::Uncertain; s_status_message = TR("send_status_unconfirmed"); Notifications::instance().warning(TR("send_status_unconfirmed")); return; @@ -393,6 +402,7 @@ static void pollOperation(App* app) ? op["status"].get() : std::string(); if (status == "success") { s_operation_pending = false; s_op_terminal = true; + s_op_outcome = OpOutcome::Success; s_status_message = TR("shield_completed"); Notifications::instance().success(TR("shield_merge_done")); } else if (status == "failed" || status == "cancelled" || status == "canceled") { @@ -401,6 +411,7 @@ static void pollOperation(App* app) op["error"]["message"].is_string()) msg = op["error"]["message"].get(); s_operation_pending = false; s_op_terminal = true; + s_op_outcome = OpOutcome::Failed; s_status_message = std::string(TR("shield_op_failed")) + msg; // No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the // one failure toast, whether this dialog is open or not. @@ -746,10 +757,18 @@ void ShieldDialog::render(App* app) // ── Live progress / status ────────────────────────────────────────────────────────────────── if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's - if (s_operation_pending && !s_op_terminal) - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str()); + const bool inFlight = s_operation_pending && !s_op_terminal; + const ImU32 stCol = inFlight ? material::Warning() + : s_op_outcome == OpOutcome::Success ? material::Success() + : s_op_outcome == OpOutcome::Failed ? material::Error() + : s_op_outcome == OpOutcome::Uncertain ? material::Warning() + : material::OnSurfaceMedium(); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(stCol)); + if (inFlight) + ImGui::Text("%s%s", s_status_message.c_str(), material::LoadingDots()); // spinner while scanning else ImGui::TextWrapped("%s", s_status_message.c_str()); + ImGui::PopStyleColor(); ImGui::Spacing(); }