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 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 11:55:40 -05:00
co-authored by Claude Opus 4.8
parent 0101cc707c
commit 15c1181aef
+25 -6
View File
@@ -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>() : 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<std::string>();
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();
}