feat(ui): friendly RPC errors + pause-on-hover/ellipsis toasts

Two of the systemic error-surfacing foundations from the UX audit:

- util::friendlyRpcError() maps the common raw daemon/JSON-RPC errors
  (warming up / -28, locked / -13, method-not-found / -32601, insufficient
  funds, unreachable node) to short translated guidance with a next step,
  falling back to the trimmed raw text. Wired into the send (money) path: the
  toast shows the friendly cause, the overlay keeps the raw text below it for
  diagnostics. The helper is reusable for the remaining raw-error sites.
- Toasts now pause their auto-dismiss while hovered and ellipsize over-long
  text with the full message in a hover tooltip, instead of hard-clipping it
  mid-word (durations were already lengthened earlier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 16:53:37 -05:00
co-authored by Claude Opus 4.8
parent 4a8d07edce
commit 9db73323b6
5 changed files with 83 additions and 5 deletions
+29 -2
View File
@@ -14,6 +14,11 @@ namespace ui {
void Notifications::render()
{
// Pause-on-hover: while the pointer rests on the toast, keep it alive so a long error message can
// actually be read (errors/warnings already last longer, but hovering holds any of them open).
if (toast_hovered_ && !notifications_.empty())
notifications_.back().created_at = std::chrono::steady_clock::now();
// Remove every expired notification; the newest remaining one is then both the last and a live one.
pruneExpired(notifications_, std::chrono::steady_clock::now());
@@ -146,13 +151,35 @@ void Notifications::render()
cursorX += iconW + iconGap;
}
// Message text (clipped to pill bounds)
// Message text — ellipsized to fit the (viewport-clamped) pill rather than hard-clipped mid-word,
// with the full text shown in a tooltip on hover.
bool clipped = false;
if (textFont) {
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
const float availTextW = (pMax.x - pillPadX) - cursorX;
std::string shown = notif.message;
if (availTextW > 0.0f &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
clipped = true;
const std::string ell = "\xE2\x80\xA6"; // "…"
while (!shown.empty() &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f,
(shown + ell).c_str()).x > availTextW) {
shown.pop_back();
while (!shown.empty() && (static_cast<unsigned char>(shown.back()) & 0xC0) == 0x80)
shown.pop_back(); // drop any trailing UTF-8 continuation bytes
}
shown += ell;
}
dl->PushClipRect(ImVec2(cursorX, pillY), ImVec2(pMax.x - pillPadX, pMax.y), true);
dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, notif.message.c_str());
dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, shown.c_str());
dl->PopClipRect();
}
// Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped.
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
if (toast_hovered_ && clipped)
ImGui::SetTooltip("%s", notif.message.c_str());
}
} // namespace ui
+1
View File
@@ -192,6 +192,7 @@ private:
std::deque<Notification> notifications_;
size_t max_notifications_ = 5;
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
std::chrono::steady_clock::time_point last_drawn_{};
std::function<void(const std::string&, bool)> console_callback_;
+6 -2
View File
@@ -927,10 +927,14 @@ void RenderSendConfirmPopup(App* app) {
s_memo[0] = '\0';
s_send_max = false;
} else {
s_tx_status = std::string(TR("send_error_prefix")) + result;
// Show a friendly cause in the toast; keep the raw backend text below it in
// the overlay (copyable) so the detail isn't lost for diagnostics.
const std::string friendly = util::friendlyRpcError(result);
s_tx_status = std::string(TR("send_error_prefix")) + friendly +
(friendly != result ? "\n\n" + result : std::string());
s_result_txid.clear();
s_status_success = false;
Notifications::instance().error(std::string(TR("send_tx_failed")) + result);
Notifications::instance().error(std::string(TR("send_tx_failed")) + friendly);
}
}
);
+37 -1
View File
@@ -9,6 +9,7 @@
#include <fstream>
#include <cstdio>
#include <cstring>
#include <cctype>
#include <vector>
#include <nlohmann/json.hpp>
@@ -1911,6 +1912,13 @@ void I18n::loadBuiltinEnglish()
strings_["console_not_connected_lite"] = "Error: no wallet open";
strings_["console_stop_confirm_node"] = "'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm.";
strings_["console_destructive_confirm"] = "This command can move funds or reveal secret keys. Never run a command someone else gave you. Run the exact same command again to confirm.";
// Friendly mappings for common daemon/RPC errors (see util::friendlyRpcError).
strings_["err_warming_up"] = "The node is still starting up. Try again in a moment.";
strings_["err_wallet_locked"] = "Unlock your wallet first, then try again.";
strings_["err_method_missing"] = "Your node doesn't support this action. Updating the daemon may fix it.";
strings_["err_insufficient"] = "Not enough spendable balance to cover the amount and fee.";
strings_["err_cant_reach"] = "Can't reach the node. Check that it's running and try again.";
strings_["err_generic"] = "Something went wrong. Please try again.";
strings_["console_capturing_output"] = "Capturing daemon output...";
strings_["console_clear"] = "Clear";
strings_["console_clear_console"] = "Clear Console";
@@ -2935,9 +2943,37 @@ void I18n::registerLanguage(const std::string& code, const std::string& name)
for (const auto& lang : available_languages_) {
if (lang.first == code) return;
}
available_languages_.emplace_back(code, name);
}
std::string friendlyRpcError(const std::string& raw)
{
// Lowercased copy for case-insensitive matching.
std::string lc = raw;
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
auto has = [&](const char* needle) { return lc.find(needle) != std::string::npos; };
// Order matters: the most specific / most common user-facing causes first.
if (has("-28") || has("warming up") || has("loading block") || has("initializing") || has("rewinding"))
return tr("err_warming_up");
if (has("-13") || has("walletpassphrase") || has("wallet is locked") || has("please enter the wallet passphrase"))
return tr("err_wallet_locked");
if (has("-32601") || has("method not found"))
return tr("err_method_missing");
if (has("insufficient") || has("not enough funds") || has("no funds"))
return tr("err_insufficient");
if (has("connection refused") || has("couldn't connect") || has("could not connect") ||
has("failed to connect") || has("timed out") || has("timeout") || has("no response"))
return tr("err_cant_reach");
// Nothing recognized — hand back the raw text, trimmed of surrounding whitespace so a stray
// newline/indent from the daemon doesn't look like a formatting glitch.
const auto b = raw.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return tr("err_generic");
const auto e = raw.find_last_not_of(" \t\r\n");
return raw.substr(b, e - b + 1);
}
} // namespace util
} // namespace dragonx
+10
View File
@@ -79,6 +79,16 @@ inline const char* tr(const char* key) {
return I18n::instance().translate(key);
}
/**
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
*
* Recognizes the common cases a user actually hits — a warming-up node, a locked wallet, an RPC the
* daemon is too old to have, insufficient funds, and an unreachable node — and returns plain guidance
* with a next step. Falls back to the raw text (trimmed) when nothing matches, so detail is never lost.
* Keep the raw text available elsewhere (a Details view / the console) for diagnostics.
*/
std::string friendlyRpcError(const std::string& raw);
} // namespace util
} // namespace dragonx