Step 2 - Admin Dispute Management & Chat Interface#26
Conversation
- Add admin_disputes table to store SolverDisputeInfo - Save dispute info to database when admin takes a dispute - Prevent taking multiple disputes (only one InProgress allowed) - Show error popup when attempting to take dispute while another is active - Add AdminDispute model with get_all() and has_in_progress_dispute() methods
- Rename admin tabs: 'Disputes' -> 'Disputes Pending', 'Chat' -> 'Disputes in Progress' - Add 'Disputes in Progress' tab with sidebar list, party tabs (Buyer/Seller), chat history, input field, and action hints - Add state management: admin_disputes_in_progress list, chat party selection, and message input - Fix dispute status: Set to 'InProgress' when saving to database - Auto-refresh disputes list after taking a dispute - Extract helper functions for DRY: execute_add_solver_action, execute_take_order_action
- Add user ratings display (stars, reviews, operating days) in dispute header - Show buyer/seller roles with pubkeys in chat selection buttons - Implement database support for UserInfo with migration - Refactor into disputes_in_progress_tab.rs module - Fix navigation and improve privacy indicators
…ions Implements comprehensive dispute resolution interface allowing admins to review full dispute details and take finalization actions through an intuitive popup dialog. Key features: - New finalization popup showing complete dispute information (parties, ratings, amounts, privacy status, timestamps) - AdminSettle action to pay buyer in full (settle dispute in buyer's favor) - AdminCancel action to refund seller in full (cancel order, seller gets refund) - Three-button interface (Pay Buyer/Refund Seller/Exit) with keyboard navigation - Up/Down arrow navigation for dispute selection in sidebar - Updated footer with clearer shortcuts (removed S/X/C, added Enter to finalize) - Comprehensive documentation in docs/FINALIZE_DISPUTES.md Protocol compliance: - Follows exact Mostro protocol from mostro-cli for AdminSettle/AdminCancel - Uses Message::new_dispute with correct action types - No payload required, only dispute ID UI/UX improvements: - Large popup (80% x 70%) for detailed review before action - Color-coded buttons (green=pay buyer, red=refund seller) - Left/Right arrow navigation between action buttons - Enter to execute, Esc to cancel - All dispute fields update correctly when selecting different disputes - Async task handling with result feedback Files changed: - New: src/ui/dispute_finalization_popup.rs - New: src/util/order_utils/execute_admin_settle.rs - New: src/util/order_utils/execute_admin_cancel.rs - New: docs/FINALIZE_DISPUTES.md - Updated: Key handlers (enter, esc, navigation) for finalization flow - Updated: UI modes with ReviewingDisputeForFinalization and WaitingDisputeFinalization - Fixed: Up/Down navigation in ManagingDispute mode for dispute selection
- Add ChatSender enum and DisputeChatMessage struct for message management - Dynamic input box grows/shrinks based on text length (up to 10 lines) - Word-boundary text wrapping with trim for clean alignment - Scrollable chat with PageUp/PageDown navigation - Auto-mockup responses from buyer/seller for testing - Persistent focus after sending messages - Visual styling: Admin (cyan, right arrow), Buyer/Seller (colored, left arrow)
- Updated TUI_INTERFACE.md with exit confirmation and chat improvements
WalkthroughAdds admin dispute management: DB migrations and new AdminDispute model; admin TUI tabs (Disputes Pending, Disputes In Progress, Exit) with per‑dispute chat, finalization popups, and chat persistence; Nostr-backed admin actions (take/settle/cancel/finalize) with async utilities; key/input handler refactors and docs/tests updates. Changes
Sequence Diagram(s)sequenceDiagram
participant Admin
participant UI as "Admin UI"
participant Client as "Nostr Client"
participant Mostro as "Mostro Daemon"
participant DB as "Database"
Admin->>UI: Select pending dispute & confirm "Take"
UI->>Client: Send AdminTakeDispute DM (signed)
Client->>Mostro: Deliver DM
Mostro->>Client: Respond with AdminTookDispute + SolverDisputeInfo
Client->>UI: Deliver response (OrderResult)
UI->>DB: Save AdminDispute and set InProgress
DB-->>UI: Ack
UI->>Admin: Enter ManagingDispute (chat UI)
sequenceDiagram
participant Admin
participant UI as "Admin UI"
participant Client as "Nostr Client"
participant Mostro as "Mostro Daemon"
participant DB as "Database"
Admin->>UI: In finalization popup selects Pay Buyer / Refund Seller
UI->>Client: Send AdminSettle or AdminCancel DM (signed)
Client->>Mostro: Deliver DM
Mostro->>Client: Acknowledge
Client->>UI: Signal success via OrderResult
UI->>DB: Update AdminDispute status (Settled / SellerRefunded)
DB-->>UI: Ack
UI->>Admin: Refresh UI and return to ManagingDispute
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Fix all issues with AI agents
In `@docs/ADMIN_DISPUTES.md`:
- Around line 589-592: The docs reference a stale field admin_chat_scroll_offset
in AppState — update the documentation to reflect the current implementation by
removing that field and replacing mentions with admin_chat_list_state (or
document how admin_chat_list_state is used to track scroll/selection state);
ensure the AppState section lists pub admin_dispute_chats: HashMap<String,
Vec<DisputeChatMessage>> and documents that admin_chat_list_state is used for
scroll/selection instead of admin_chat_scroll_offset.
- Around line 477-481: Update the "Color-coded messages" section in
ADMIN_DISPUTES.md to remove references to the arrow prefixes (▶ / ◀) and instead
describe the actual chat header rendering as "Sender - date - time" along with
the color usage for admin/buyer/seller messages; keep the note about the dynamic
input box growing 1–10 lines but ensure the list entry names ("Color-coded
messages" and "Dynamic input box") match the doc headings so readers can find
the changes easily.
In `@src/db.rs`:
- Around line 109-143: The migration currently treats any query error as "column
missing" and runs two ALTERs outside a transaction; update
migrate_db(SqlitePool) to explicitly check that the admin_disputes table exists
and whether initiator_info and counterpart_info columns are missing (e.g., query
PRAGMA table_info('admin_disputes') and inspect column names) and only attempt
ALTER TABLE for the specific missing columns, and wrap all ALTER TABLE
statements in a single transaction (use a sqlx transaction via pool.begin() /
tx.commit()) so that either both columns are added or none are applied; also
handle and propagate non-"table/column not found" errors separately instead of
treating every error as "missing column."
In `@src/main.rs`:
- Around line 192-220: Replace the fragile string check that builds
is_dispute_taken from OrderResult::Info with a structured signal: add a
dedicated enum variant (e.g., crate::ui::OrderResult::DisputeTaken or
OrderResult::Info{ kind: InfoKind::DisputeTaken, .. }) or attach explicit
metadata to OrderResult::Info, then update the call site (where
handle_order_result(result, &mut app) is invoked) to match on that new variant
instead of matching substrings; specifically, change the is_dispute_taken logic
to match OrderResult::DisputeTaken (or Info with InfoKind::DisputeTaken) and
keep the existing AdminDispute::get_all refresh and assignment to
app.admin_disputes_in_progress unchanged so the refresh only runs for the typed
dispute-taken result.
In `@src/ui/dispute_finalization_popup.rs`:
- Around line 93-100: The current fallback makes both buyer_pubkey and
seller_pubkey default to dispute.initiator_pubkey when absent, which can show
the same pubkey for both parties; instead, change the logic in the dispute
finalization UI where buyer_pubkey and seller_pubkey are derived (variables
named buyer_pubkey and seller_pubkey) to detect None and use a clear placeholder
like "Unknown" (or an Option-aware display) rather than
unwrap_or(&dispute.initiator_pubkey); update any downstream display code to
handle the placeholder so admins see an explicit missing value instead of the
initiator_pubkey.
- Around line 22-36: The error popup only renders a titled Block with no
explanatory text when selected_dispute is None; update the else branch in
dispute_finalization_popup.rs (the match binding using let
Some(selected_dispute) = dispute else { ... }) to create and render a Paragraph
(or Text) widget with a clear message such as "Dispute not found" (styled to
match the Block, e.g., Color::Red or a warning style) inside the same popup area
after rendering the Block; ensure you call f.render_widget on that Paragraph (or
Text) so the message appears centered or padded within the popup.
In `@src/ui/disputes_in_progress_tab.rs`:
- Around line 33-47: The current code slices dispute IDs with &d.id[..20] which
will panic for short IDs; update the mapping in the iterator that builds
ListItem (the closure referencing app.admin_disputes_in_progress,
selected_in_progress_idx, and the format!("ID: {}...", &d.id[..20]) call) to
safely truncate the ID instead—e.g., produce an id snippet using a safe
truncation like d.id.chars().take(20).collect::<String>() (or use str::get with
a fallback) and use that snippet in the format! to avoid runtime crashes on
short IDs.
In `@src/ui/key_handler/enter_handlers.rs`:
- Around line 152-161: The code calls SETTINGS.get().unwrap() inside the
tokio::spawn before calling crate::util::take_order, which can panic if SETTINGS
is uninitialized; change this to perform a guarded lookup (e.g., SETTINGS.get())
and if it returns None, short-circuit by returning/sending an OrderResult::Error
(or otherwise mapping to the error-handling path used by take_order) instead of
unwrapping, so the UI surfaces a recoverable error rather than crashing; update
the async block around the take_order call to check SETTINGS.get() and handle
the None case before invoking take_order.
🧹 Nitpick comments (12)
src/util/filters.rs (1)
8-14: Rename/update docs or restore the 7‑day constraintThe function name/doc still promise “last 7 days,” but the filter now only constrains author/kind/limit. This likely pulls full history and can inflate query sizes or change UI behavior. If the 7‑day window is still intended, reintroduce a time constraint; otherwise rename/update docs (and call sites) to reflect the new behavior.
♻️ Minimal rename/doc alignment (if 7‑day window is intentionally removed)
-/// Create a filter for events from the last 7 days -pub fn create_seven_days_filter(kind: u16, pubkey: PublicKey) -> Result<Filter> { +/// Create a filter for events of a given kind authored by `pubkey` +pub fn create_kind_filter(kind: u16, pubkey: PublicKey) -> Result<Filter> { Ok(Filter::new() .author(pubkey) .limit(50) .kind(nostr_sdk::Kind::Custom(kind))) } @@ - ListKind::Orders => create_seven_days_filter(NOSTR_ORDER_EVENT_KIND, pubkey), - ListKind::Disputes => create_seven_days_filter(NOSTR_DISPUTE_EVENT_KIND, pubkey), + ListKind::Orders => create_kind_filter(NOSTR_ORDER_EVENT_KIND, pubkey), + ListKind::Disputes => create_kind_filter(NOSTR_DISPUTE_EVENT_KIND, pubkey),Also applies to: 23-24
src/ui/key_handler/confirmation.rs (1)
285-291: Redundantdefault_modecalculation.The
default_modeis already computed at the top ofhandle_cancel_key(lines 251-254). The recalculation inside this branch is unnecessary.♻️ Suggested simplification
} else if let UiMode::AdminMode(AdminMode::ConfirmTakeDispute(_, _)) = &app.mode { // User cancelled taking the dispute - let default_mode = match app.user_role { - UserRole::User => UiMode::UserMode(UserMode::Normal), - UserRole::Admin => UiMode::AdminMode(AdminMode::Normal), - }; app.mode = default_mode; }src/ui/key_handler/navigation.rs (2)
177-184: Consider removing the redundant tab check in ManagingDispute handling.Since
ManagingDisputemode should only be active when on theDisputesInProgresstab (as set inhandle_tab_switch), the inner tab check is redundant. However, keeping it provides defensive coding against potential state inconsistencies, so this is a minor observation.Also applies to: 274-284
348-370: Tab and BackTab perform the same action.Both
TabandBackTabtoggle between Buyer and Seller chat parties, making them functionally identical. This is acceptable for a two-party toggle but differs from typical Tab/BackTab behavior (forward vs. backward navigation). If intentional for UX simplicity, consider adding a brief comment.src/ui/tab_content.rs (1)
41-64: Block created twice with identical configuration.The
Blockwidget with title "Exit" and identical styling is constructed twice: once at lines 41-45 to computeinner_area, and again at lines 58-63 for rendering. Consider storing the block in a variable and reusing it.♻️ Suggested refactor
pub fn render_exit_tab(f: &mut ratatui::Frame, area: Rect) { let logo_lines = get_mostro_logo(); - // Create a layout to center the logo vertically - let inner_area = Block::default() + let block = Block::default() .title("Exit") .borders(Borders::ALL) - .style(Style::default().bg(BACKGROUND_COLOR)) - .inner(area); + .style(Style::default().bg(BACKGROUND_COLOR)); + + // Create a layout to center the logo vertically + let inner_area = block.inner(area); let logo_height = logo_lines.len() as u16; let available_height = inner_area.height; @@ -57,12 +55,7 @@ }; // Render the block - f.render_widget( - Block::default() - .title("Exit") - .borders(Borders::ALL) - .style(Style::default().bg(BACKGROUND_COLOR)), - area, - ); + f.render_widget(block, area);src/ui/key_handler/input_helpers.rs (1)
1-25: Minor inconsistency in type path usage.The import at line 1 brings
InvoiceInputStateandKeyInputStateinto scope, which is used in the function signatures (lines 57, 63). However, the trait implementations at lines 9 and 18 still use the fully qualifiedcrate::ui::InvoiceInputStateandcrate::ui::KeyInputStatepaths. Consider using the imported names consistently.♻️ Suggested fix for consistency
-impl TextInputState for crate::ui::InvoiceInputState { +impl TextInputState for InvoiceInputState { fn get_input_mut(&mut self) -> &mut String { &mut self.invoice_input } fn get_just_pasted_mut(&mut self) -> &mut bool { &mut self.just_pasted } } -impl TextInputState for crate::ui::KeyInputState { +impl TextInputState for KeyInputState { fn get_input_mut(&mut self) -> &mut String { &mut self.key_input }docs/TUI_INTERFACE.md (1)
248-248: Overlapping line references may cause confusion.Line 248 references
src/ui/key_handler/mod.rs:155-172for Shift+I toggle andmod.rs:168-172forhandle_admin_chat_input. The ranges overlap (168-172 is within 155-172), which could confuse readers trying to locate the specific code sections.Consider clarifying with distinct, non-overlapping ranges or combining into a single reference if they describe related functionality in the same code block.
src/main.rs (1)
141-159: Consider extracting duplicate filtering logic.The same filtering logic for "InProgress" disputes appears here and again at lines 202-210. Extracting this to a helper method (e.g.,
filter_in_progress_disputes) would reduce duplication and ensure consistent behavior.♻️ Suggested helper
fn filter_in_progress_disputes(disputes: Vec<crate::models::AdminDispute>) -> Vec<crate::models::AdminDispute> { disputes .into_iter() .filter(|d| d.status.as_ref().map(|s| s == "InProgress").unwrap_or(false)) .collect() }Then use it in both locations:
-app.admin_disputes_in_progress = all_disputes - .into_iter() - .filter(|d| { - d.status - .as_ref() - .map(|s| s == "InProgress") - .unwrap_or(false) - }) - .collect(); +app.admin_disputes_in_progress = filter_in_progress_disputes(all_disputes);src/ui/dispute_finalization_popup.rs (1)
373-382: Duplicate helper and missing bounds check.This
center_rectfunction duplicateshelpers::create_centered_popupbut lacks the safety check that clamps dimensions to the available area. On small terminals, this could cause layout issues.Consider reusing the existing helper from
super::helpers.♻️ Suggested change
-/// Helper function to center a rect -fn center_rect(area: Rect, width: u16, height: u16) -> Rect { - let [popup] = Layout::horizontal([Constraint::Length(width)]) - .flex(Flex::Center) - .areas(area); - let [popup] = Layout::vertical([Constraint::Length(height)]) - .flex(Flex::Center) - .areas(popup); - popup -}Then update the import and usage:
+use super::helpers; // ... -let popup = center_rect(area, popup_width, popup_height); +let popup = helpers::create_centered_popup(area, popup_width, popup_height);src/ui/exit_confirm.rs (1)
26-37: Layout constraints may exceed available height.The vertical layout allocates 8 lines total (1+1+1+3+1+1), but the popup height is 11 with 2 lines consumed by borders, leaving 9 lines for content. This works, but the magic numbers make it fragile.
Consider using
Constraint::Min(0)for a spacer to absorb any discrepancy, or add a comment documenting the height calculation.src/util/order_utils/execute_admin_settle.rs (1)
33-73: LGTM with optional consolidation opportunity.The implementation correctly handles the AdminSettle action. However, this function shares ~90% of its code with
execute_admin_cancel. Consider extracting the common logic into a shared helper that accepts theActionvariant as a parameter.♻️ Optional consolidation
async fn execute_admin_action( dispute_id: &Uuid, client: &Client, mostro_pubkey: PublicKey, action: Action, action_name: &str, ) -> Result<()> { let settings = SETTINGS.get().ok_or(anyhow::anyhow!("Settings not initialized"))?; if settings.admin_privkey.is_empty() { return Err(anyhow::anyhow!("Admin private key not configured")); } let admin_keys = Keys::parse(&settings.admin_privkey)?; let message = Message::new_dispute(Some(*dispute_id), None, None, action, None) .as_json() .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; send_dm(client, Some(&admin_keys), &admin_keys, &mostro_pubkey, message, None, false).await?; log::info!("✅ Admin {} message sent for dispute {}", action_name, dispute_id); Ok(()) } // Then wrap as: pub async fn execute_admin_settle(...) -> Result<()> { execute_admin_action(dispute_id, client, mostro_pubkey, Action::AdminSettle, "settle (pay buyer)").await }src/ui/mod.rs (1)
568-578: Remove redundant selected_dispute_idx reset.
There are two consecutive resets of the same field; one can be removed.♻️ Suggested cleanup
self.selected_dispute_idx = 0; self.selected_settings_option = 0; - self.selected_dispute_idx = 0; self.selected_in_progress_idx = 0;
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/ui/dispute_finalization_popup.rs`:
- Around line 93-134: The current logic computes is_initiator_buyer using string
fallbacks so a missing buyer_pubkey falsely makes initiator appear as seller;
change it to a tri-state check by comparing Option values directly (e.g. compare
dispute.initiator_pubkey.as_ref() to dispute.buyer_pubkey.as_ref()) and treat
None as "unknown". Update the downstream logic that sets buyer_privacy and
seller_privacy and any initiator badges to first branch on this tri-state
(buyer/seller/unknown) and when unknown show "Unknown" or omit
initiator-specific labels/rating indicators; keep using truncate_pubkey,
created_str and taken_str as before but ensure privacy/rating display is
conditional on a known role.
♻️ Duplicate comments (1)
src/ui/dispute_finalization_popup.rs (1)
22-35: Error popup still shows no message body.
Users only see the title; please render a short message (e.g., “Dispute not found”) inside the popup.
🧹 Nitpick comments (1)
src/ui/dispute_finalization_popup.rs (1)
1-5: Enable wrapping to avoid clipped lines in the details view.
Long IDs, pubkeys, or payment method strings will be cut off. ConsiderWrap { trim: true }for theParagraph.♻️ Proposed change
-use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; @@ - let paragraph = Paragraph::new(lines); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); f.render_widget(paragraph, area);Also applies to: 288-289
| let buyer_pubkey = dispute | ||
| .buyer_pubkey | ||
| .as_ref() | ||
| .map(|s| s.as_str()) | ||
| .unwrap_or("Unknown"); | ||
| let seller_pubkey = dispute | ||
| .seller_pubkey | ||
| .as_ref() | ||
| .map(|s| s.as_str()) | ||
| .unwrap_or("Unknown"); | ||
|
|
||
| let is_initiator_buyer = &dispute.initiator_pubkey == buyer_pubkey; | ||
| let buyer_pubkey_display = truncate_pubkey(buyer_pubkey); | ||
| let seller_pubkey_display = truncate_pubkey(seller_pubkey); | ||
|
|
||
| // Format timestamps | ||
| let created_date = DateTime::from_timestamp(dispute.created_at, 0); | ||
| let created_str = created_date | ||
| .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) | ||
| .unwrap_or_else(|| "Unknown".to_string()); | ||
|
|
||
| let taken_date = DateTime::from_timestamp(dispute.taken_at, 0); | ||
| let taken_str = taken_date | ||
| .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) | ||
| .unwrap_or_else(|| "Unknown".to_string()); | ||
|
|
||
| // Privacy indicators | ||
| let buyer_privacy = if dispute.initiator_full_privacy && is_initiator_buyer | ||
| || dispute.counterpart_full_privacy && !is_initiator_buyer | ||
| { | ||
| "🕶️ Private" | ||
| } else { | ||
| "👁️ Public" | ||
| }; | ||
| let seller_privacy = if dispute.initiator_full_privacy && !is_initiator_buyer | ||
| || dispute.counterpart_full_privacy && is_initiator_buyer | ||
| { | ||
| "🕶️ Private" | ||
| } else { | ||
| "👁️ Public" | ||
| }; | ||
|
|
There was a problem hiding this comment.
Handle unknown buyer/seller roles explicitly.
When buyer_pubkey is missing, is_initiator_buyer becomes false, which can misattribute privacy and rating info (and initiator badges) to the wrong party. Consider treating the initiator role as tri‑state (buyer/seller/unknown) and showing “Unknown” or omitting initiator-specific labels when pubkeys are absent.
🤖 Prompt for AI Agents
In `@src/ui/dispute_finalization_popup.rs` around lines 93 - 134, The current
logic computes is_initiator_buyer using string fallbacks so a missing
buyer_pubkey falsely makes initiator appear as seller; change it to a tri-state
check by comparing Option values directly (e.g. compare
dispute.initiator_pubkey.as_ref() to dispute.buyer_pubkey.as_ref()) and treat
None as "unknown". Update the downstream logic that sets buyer_privacy and
seller_privacy and any initiator badges to first branch on this tri-state
(buyer/seller/unknown) and when unknown show "Unknown" or omit
initiator-specific labels/rating indicators; keep using truncate_pubkey,
created_str and taken_str as before but ensure privacy/rating display is
conditional on a known role.
- Replace SETTINGS.get().unwrap() with guarded lookups - Add atomic database migrations with proper error handling - Fix unsafe string truncation with length checks - Display clear error messages in popups - Update documentation Prevents panics and provides better user feedback.
…e sent to - Update filtering logic in 4 locations to respect target_party - Admin messages only show in the chat view of the recipient party - Update documentation for chat filtering behavior
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@src/db.rs`:
- Around line 113-136: The check_column_exists function currently treats any
error other than "no such column" as fatal, which breaks migrations when the
admin_disputes table itself is missing; update check_column_exists to also
detect "no such table" errors (e.g., error_msg.contains("no such table")) and
return Ok(false) for that case so callers treat missing table/column as
non-existent rather than propagating an error; keep existing behavior of
propagating other unexpected errors.
In `@src/ui/dispute_finalization_popup.rs`:
- Around line 42-47: The wrap width calculation for building error_lines can be
zero when inner.width.saturating_sub(2) yields 0, which makes chunks(0) panic;
compute a non-zero wrap width (e.g. let wrap_width =
inner.width.saturating_sub(2).max(1) as usize) and use chunks(wrap_width) when
mapping error_msg into Line::from so the popup always clamps to at least 1
column.
In `@src/ui/disputes_in_progress_tab.rs`:
- Around line 122-134: The current logic uses selected_dispute.initiator_pubkey
as a fallback for both buyer_pubkey and seller_pubkey which can make both
parties appear identical; instead treat missing buyer_pubkey/seller_pubkey as
None (do not default to initiator_pubkey) and change is_initiator_buyer to
Option<bool> computed only when both initiator_pubkey and a concrete
buyer_pubkey are present; update downstream UI rendering to show "Unknown" for
privacy/ratings when buyer_pubkey/seller_pubkey or is_initiator_buyer is None so
roles are not misattributed.
- Around line 76-90: The current wrapping logic in the input height calculation
(iterating app.admin_chat_input words, using Span::raw to compute word_width)
treats any single token wider than available_width as one line, causing
clipping; update the loop to handle long words by splitting them across multiple
lines: compute space_width as before, compute remaining_in_line = if
current_width>0 { available_width - current_width - space_width } else {
available_width }, if word_width <= remaining_in_line place it on the current
line, otherwise if word_width > remaining_in_line and remaining_in_line > 0
consume the remaining_in_line (increment lines by 1 and reduce word_width by
remaining_in_line), then compute full_chunks = word_width / available_width and
leftover = word_width % available_width, increment lines by full_chunks, set
current_width = leftover (or 0 if leftover == 0) and if leftover > 0 ensure
lines++ counted for that partial chunk; use the same Span::raw width measurement
(word_width) and update variables lines and current_width accordingly so very
long tokens wrap into multiple lines correctly.
In `@src/ui/key_handler/confirmation.rs`:
- Around line 231-251: The ConfirmTakeDispute branch currently uses the variable
selected_button to decide whether to call execute_take_dispute_action, causing
the 'y' key to be ignored if the UI button state is false; change the handler so
that a forced-YES input always triggers the action just like
ConfirmAddSolver/ConfirmAdminKey: when synthesizing a forced-yes (i.e., handling
the 'y' key) call execute_take_dispute_action(app, dispute_id, client,
mostro_pubkey, pool, order_result_tx) unconditionally (or pass selected_button =
true into the shared confirmation helper), and only use selected_button for
actual button navigation; update
UiMode::AdminMode(AdminMode::ConfirmTakeDispute(dispute_id, selected_button))
handling to mirror ConfirmAddSolver/handle_confirmation_enter usage so 'y'
always performs the take-dispute action.
♻️ Duplicate comments (2)
src/ui/dispute_finalization_popup.rs (1)
121-156: Avoid mislabeling initiator/ratings when buyer or seller pubkeys are missing.
When a pubkey isNone,is_initiator_buyerstill gets forced into a buyer/seller decision, which can misattribute privacy and rating info. Consider a tri‑state (buyer/seller/unknown) role and show “Unknown” for privacy/ratings when role is not determinable.🔧 Suggested direction (sketch)
- let buyer_pubkey = dispute.buyer_pubkey.as_deref().unwrap_or("Unknown"); - let seller_pubkey = dispute.seller_pubkey.as_deref().unwrap_or("Unknown"); - let is_initiator_buyer = dispute.initiator_pubkey == buyer_pubkey; + let buyer_pubkey = dispute.buyer_pubkey.as_deref(); + let seller_pubkey = dispute.seller_pubkey.as_deref(); + let initiator_role = if buyer_pubkey == Some(dispute.initiator_pubkey.as_str()) { + Some(true) // buyer + } else if seller_pubkey == Some(dispute.initiator_pubkey.as_str()) { + Some(false) // seller + } else { + None + };Then derive privacy/rating/role labels with a
match initiator_role { Some(true) => ..., Some(false) => ..., None => "Unknown" }.docs/ADMIN_DISPUTES.md (1)
603-607: Docs still mention ▶/◀ party indicators that the UI doesn’t render.
The chat header is “Sender - date - time” without arrow prefixes. Please remove or update this line to match the actual UI.
🧹 Nitpick comments (2)
src/util/dm_utils/mod.rs (1)
203-207: Minor: Existingunwrap()onchecked_sub_signedcould panic.While unlikely with reasonable
since_timevalues,checked_sub_signedreturnsNoneon overflow. Consider usingunwrap_or_elsewith a fallback or propagating the error for consistency with the improved error handling elsewhere.♻️ Suggested fix
if let Some(since_time) = since { - let since_time = chrono::Utc::now() + let since_time = match chrono::Utc::now() .checked_sub_signed(chrono::Duration::minutes(*since_time)) - .unwrap() - .timestamp() as u64; + { + Some(t) => t.timestamp() as u64, + None => { + log::warn!("Invalid since_time value: {}", since_time); + continue; + } + };src/ui/key_handler/confirmation.rs (1)
295-301: Minor: Redundantdefault_modecomputation.
default_modeis already computed at line 261-264 and is in scope. The re-computation at lines 297-300 is unnecessary.♻️ Suggested fix
} else if let UiMode::AdminMode(AdminMode::ConfirmTakeDispute(_, _)) = &app.mode { // User cancelled taking the dispute - let default_mode = match app.user_role { - UserRole::User => UiMode::UserMode(UserMode::Normal), - UserRole::Admin => UiMode::AdminMode(AdminMode::Normal), - }; app.mode = default_mode; }
| async fn check_column_exists(pool: &SqlitePool, column_name: &str) -> Result<bool> { | ||
| let result = sqlx::query(&format!( | ||
| "SELECT {} FROM admin_disputes LIMIT 1", | ||
| column_name | ||
| )) | ||
| .fetch_optional(pool) | ||
| .await; | ||
|
|
||
| match result { | ||
| Ok(_) => Ok(true), // Column exists (query succeeded) | ||
| Err(e) => { | ||
| // Check if error is specifically "no such column" | ||
| // If it's a different error (table doesn't exist, connection issue, etc.), | ||
| // we'll propagate it rather than incorrectly assuming the column doesn't exist | ||
| let error_msg = e.to_string().to_lowercase(); | ||
| if error_msg.contains("no such column") { | ||
| Ok(false) | ||
| } else { | ||
| // Re-propagate non-column-related errors | ||
| Err(e.into()) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check all references to admin_disputes and see the context
rg -n "admin_disputes" --type rust -B2 -A2
# Also check for table creation
rg -n "CREATE TABLE" --type rust -A5Repository: MostroP2P/mostrix
Length of output: 12634
🏁 Script executed:
# Get the full src/db.rs file to understand initialization and migration flow
wc -l src/db.rsRepository: MostroP2P/mostrix
Length of output: 74
🏁 Script executed:
cat -n src/db.rsRepository: MostroP2P/mostrix
Length of output: 7745
🏁 Script executed:
# Check if there are any comments or history about when admin_disputes was added
rg -i "admin_disputes" --type rust -B5 | head -30
# Check if there's any fallback table creation for existing DBs
rg -n "CREATE TABLE" src/db.rs -A2
# Check the git log to see when admin_disputes was introduced (if possible)
git log --oneline --all -- src/db.rs | head -20Repository: MostroP2P/mostrix
Length of output: 2678
🏁 Script executed:
# Let's check what's in the commits related to admin_disputes
git show 8b33b14:src/db.rs 2>/dev/null | grep -A20 "admin_disputes" | head -30
# Check if the initial commit had admin_disputes
git show 84ed8e5:src/db.rs 2>/dev/null | grep -c "admin_disputes" || echo "not found in initial commit"Repository: MostroP2P/mostrix
Length of output: 955
🏁 Script executed:
# Verify the initial commit didn't have admin_disputes
git show 84ed8e5:src/db.rs 2>/dev/null | head -100
# Check if there are any queries that assume admin_disputes exists in models.rs
rg -n "INSERT INTO admin_disputes|SELECT.*FROM admin_disputes" src/models.rs | head -10Repository: MostroP2P/mostrix
Length of output: 3103
🏁 Script executed:
# Check if there's any code that handles missing admin_disputes table elsewhere
rg -n "admin_disputes" src/db.rs
# Check if models.rs has any fallback or table creation logic
rg -n "CREATE TABLE.*admin_disputes" --type rust
# See the full error handling context in models.rs
rg -B5 -A5 "INSERT INTO admin_disputes" src/models.rs | head -20Repository: MostroP2P/mostrix
Length of output: 1021
Migration will fail for databases created before admin_disputes table was added.
The check_column_exists helper only catches "no such column" errors. For old databases that predate the admin_disputes table entirely, the query will fail with "no such table" instead, causing the migration to crash unexpectedly.
Add handling for the "no such table" case:
Suggested fix
async fn check_column_exists(pool: &SqlitePool, column_name: &str) -> Result<bool> {
let result = sqlx::query(&format!(
"SELECT {} FROM admin_disputes LIMIT 1",
column_name
))
.fetch_optional(pool)
.await;
match result {
Ok(_) => Ok(true), // Column exists (query succeeded)
Err(e) => {
let error_msg = e.to_string().to_lowercase();
if error_msg.contains("no such column") {
Ok(false)
+ } else if error_msg.contains("no such table") {
+ // Table doesn't exist yet - skip migration
+ Ok(true)
} else {
Err(e.into())
}
}
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async fn check_column_exists(pool: &SqlitePool, column_name: &str) -> Result<bool> { | |
| let result = sqlx::query(&format!( | |
| "SELECT {} FROM admin_disputes LIMIT 1", | |
| column_name | |
| )) | |
| .fetch_optional(pool) | |
| .await; | |
| match result { | |
| Ok(_) => Ok(true), // Column exists (query succeeded) | |
| Err(e) => { | |
| // Check if error is specifically "no such column" | |
| // If it's a different error (table doesn't exist, connection issue, etc.), | |
| // we'll propagate it rather than incorrectly assuming the column doesn't exist | |
| let error_msg = e.to_string().to_lowercase(); | |
| if error_msg.contains("no such column") { | |
| Ok(false) | |
| } else { | |
| // Re-propagate non-column-related errors | |
| Err(e.into()) | |
| } | |
| } | |
| } | |
| } | |
| async fn check_column_exists(pool: &SqlitePool, column_name: &str) -> Result<bool> { | |
| let result = sqlx::query(&format!( | |
| "SELECT {} FROM admin_disputes LIMIT 1", | |
| column_name | |
| )) | |
| .fetch_optional(pool) | |
| .await; | |
| match result { | |
| Ok(_) => Ok(true), // Column exists (query succeeded) | |
| Err(e) => { | |
| // Check if error is specifically "no such column" | |
| // If it's a different error (table doesn't exist, connection issue, etc.), | |
| // we'll propagate it rather than incorrectly assuming the column doesn't exist | |
| let error_msg = e.to_string().to_lowercase(); | |
| if error_msg.contains("no such column") { | |
| Ok(false) | |
| } else if error_msg.contains("no such table") { | |
| // Table doesn't exist yet - skip migration | |
| Ok(true) | |
| } else { | |
| // Re-propagate non-column-related errors | |
| Err(e.into()) | |
| } | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@src/db.rs` around lines 113 - 136, The check_column_exists function currently
treats any error other than "no such column" as fatal, which breaks migrations
when the admin_disputes table itself is missing; update check_column_exists to
also detect "no such table" errors (e.g., error_msg.contains("no such table"))
and return Ok(false) for that case so callers treat missing table/column as
non-existent rather than propagating an error; keep existing behavior of
propagating other unexpected errors.
| // Process each word (ratatui's wrap with trim: true splits on whitespace) | ||
| for word in app.admin_chat_input.split_whitespace() { | ||
| // Use ratatui's Span to get Unicode-aware width | ||
| let word_span = Span::raw(word); | ||
| let word_width = word_span.width(); | ||
| let space_width = if current_width > 0 { 1 } else { 0 }; // Space before word | ||
|
|
||
| if current_width + space_width + word_width > available_width { | ||
| // Word doesn't fit, wrap to next line | ||
| lines += 1; | ||
| current_width = word_width; | ||
| } else { | ||
| // Word fits on current line | ||
| current_width += space_width + word_width; | ||
| } |
There was a problem hiding this comment.
Handle single long “word” wrapping in input height calculation.
A long token (e.g., pubkey) wider than the available width only increments lines by 1, so the input box can be too short and text will clip.
🔧 Proposed fix
- if current_width + space_width + word_width > available_width {
- // Word doesn't fit, wrap to next line
- lines += 1;
- current_width = word_width;
+ if current_width + space_width + word_width > available_width {
+ // Word doesn't fit, wrap to next line
+ lines += 1;
+ // If the word itself exceeds the width, account for extra lines
+ if word_width > available_width {
+ let full_lines = word_width / available_width;
+ lines += full_lines;
+ current_width = word_width % available_width;
+ if current_width == 0 {
+ current_width = available_width;
+ }
+ } else {
+ current_width = word_width;
+ }
} else {🤖 Prompt for AI Agents
In `@src/ui/disputes_in_progress_tab.rs` around lines 76 - 90, The current
wrapping logic in the input height calculation (iterating app.admin_chat_input
words, using Span::raw to compute word_width) treats any single token wider than
available_width as one line, causing clipping; update the loop to handle long
words by splitting them across multiple lines: compute space_width as before,
compute remaining_in_line = if current_width>0 { available_width - current_width
- space_width } else { available_width }, if word_width <= remaining_in_line
place it on the current line, otherwise if word_width > remaining_in_line and
remaining_in_line > 0 consume the remaining_in_line (increment lines by 1 and
reduce word_width by remaining_in_line), then compute full_chunks = word_width /
available_width and leftover = word_width % available_width, increment lines by
full_chunks, set current_width = leftover (or 0 if leftover == 0) and if
leftover > 0 ensure lines++ counted for that partial chunk; use the same
Span::raw width measurement (word_width) and update variables lines and
current_width accordingly so very long tokens wrap into multiple lines
correctly.
| // Determine who is buyer and who is seller by comparing pubkeys | ||
| let buyer_pubkey = selected_dispute | ||
| .buyer_pubkey | ||
| .as_ref() | ||
| .unwrap_or(&selected_dispute.initiator_pubkey); | ||
| let seller_pubkey = selected_dispute | ||
| .seller_pubkey | ||
| .as_ref() | ||
| .unwrap_or(&selected_dispute.initiator_pubkey); | ||
|
|
||
| // Check who initiated the dispute | ||
| let is_initiator_buyer = &selected_dispute.initiator_pubkey == buyer_pubkey; | ||
|
|
There was a problem hiding this comment.
Avoid using initiator pubkey as fallback for buyer/seller.
If buyer/seller pubkeys are missing, this displays the same key for both parties and can misattribute privacy/ratings. Prefer an explicit “Unknown” and treat the initiator role as unknown when pubkeys are absent.
🔧 Proposed fix
- let buyer_pubkey = selected_dispute
- .buyer_pubkey
- .as_ref()
- .unwrap_or(&selected_dispute.initiator_pubkey);
- let seller_pubkey = selected_dispute
- .seller_pubkey
- .as_ref()
- .unwrap_or(&selected_dispute.initiator_pubkey);
+ let buyer_pubkey = selected_dispute.buyer_pubkey.as_deref().unwrap_or("Unknown");
+ let seller_pubkey = selected_dispute.seller_pubkey.as_deref().unwrap_or("Unknown");Consider making is_initiator_buyer an Option<bool> and showing “Unknown” for privacy/ratings when role can’t be inferred.
🤖 Prompt for AI Agents
In `@src/ui/disputes_in_progress_tab.rs` around lines 122 - 134, The current logic
uses selected_dispute.initiator_pubkey as a fallback for both buyer_pubkey and
seller_pubkey which can make both parties appear identical; instead treat
missing buyer_pubkey/seller_pubkey as None (do not default to initiator_pubkey)
and change is_initiator_buyer to Option<bool> computed only when both
initiator_pubkey and a concrete buyer_pubkey are present; update downstream UI
rendering to show "Unknown" for privacy/ratings when buyer_pubkey/seller_pubkey
or is_initiator_buyer is None so roles are not misattributed.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/TUI_INTERFACE.md (1)
124-166: Update doc references to match the refactored key handler and exit shortcut.The key handler lives under
src/ui/key_handler/mod.rsnow, and theQshortcut for exit was removed (Exit tab only). Please update the doc text and example paths accordingly.✏️ Suggested doc update
-Input handling is centralized in `src/ui/key_handler.rs`. +Input handling is centralized in `src/ui/key_handler/mod.rs` (and its submodules). -**Exit Confirmation**: Pressing `Q` or selecting the Exit tab shows a confirmation popup before exiting the application. +**Exit Confirmation**: Selecting the Exit tab shows a confirmation popup before exiting the application.src/ui/mod.rs (1)
569-579: Reset admin chat input state on role switch and remove duplicate assignment.Right now
admin_chat_input_enabledandadmin_chat_list_statepersist across role switches, andselected_dispute_idxis set twice. This can leave chat input disabled unexpectedly after switching back to Admin.🧹 Suggested fix
self.selected_dispute_idx = 0; self.selected_settings_option = 0; - self.selected_dispute_idx = 0; self.selected_in_progress_idx = 0; self.active_chat_party = ChatParty::Buyer; self.admin_chat_input.clear(); + self.admin_chat_input_enabled = true; + self.admin_chat_list_state = ratatui::widgets::ListState::default();
🤖 Fix all issues with AI agents
In `@docs/FINALIZE_DISPUTES.md`:
- Around line 160-164: Update the "Color-coded senders" doc entry to match the
UI by removing the `▶`/`◀` arrow prefixes and describing the header format used
in the chat UI: "Sender - date - time" with color-coded names (cyan for Admin,
green for Buyer, red for Seller). Keep the bullet points for colors but replace
the examples that include arrow prefixes and ensure the wording clarifies that
only the sender name is color-coded in the header.
In `@src/ui/helpers.rs`:
- Around line 95-101: The code interpolates dispute_id directly into file_path
before opening it (see file_path, mostrix_dir, and OpenOptions::new()), so
validate or sanitize dispute_id first: verify it matches an expected format
(e.g., parse as a UUID using uuid::Uuid::parse_str or restrict to a safe
character class like alphanumeric, hyphen, underscore) and return an error early
if invalid; only after successful validation construct
mostrix_dir.join(format!("{}.txt", dispute_id)) and proceed with
OpenOptions::new() to prevent path traversal or injection.
♻️ Duplicate comments (1)
docs/ADMIN_DISPUTES.md (1)
609-612: Remove arrow-prefix wording; UI uses sender/date/time headers instead.The chat UI doesn’t render
▶/◀prefixes; it shows"Sender - date - time"headers with color. Please adjust the doc accordingly.✏️ Suggested doc update
-- **Party indicators**: `▶` for admin, `◀` for parties +- **Chat header format**: "Sender - date - time" with sender-specific color
🧹 Nitpick comments (2)
src/ui/key_handler/enter_handlers.rs (2)
74-104: Minor: Redundant clone ofsolver_pubkey.
solver_pubkeyis already an ownedStringpassed by value. The clone on line 84 is unnecessary—you can move it directly into the async block.♻️ Suggested simplification
pub(crate) fn execute_add_solver_action( app: &mut AppState, solver_pubkey: String, client: &Client, mostro_pubkey: nostr_sdk::PublicKey, order_result_tx: &UnboundedSender<crate::ui::OrderResult>, ) { // Stay on Settings tab after confirmation app.mode = UiMode::AdminMode(AdminMode::Normal); - let solver_pubkey_clone = solver_pubkey.clone(); let client_clone = client.clone(); let result_tx = order_result_tx.clone(); let mostro_pubkey_clone = mostro_pubkey; tokio::spawn(async move { - match execute_admin_add_solver(&solver_pubkey_clone, &client_clone, mostro_pubkey_clone) + match execute_admin_add_solver(&solver_pubkey, &client_clone, mostro_pubkey_clone) .await
325-444: Consider extracting the ManagingDispute handler into a separate function.This match arm spans ~120 lines and handles multiple concerns: chat input validation, message persistence, mock response generation, auto-scrolling, and fallback to finalization popup. Extracting it to a dedicated
handle_enter_managing_dispute()helper would improve readability and testability, consistent with otherhandle_enter_*functions in this file.
Catrya
left a comment
There was a problem hiding this comment.
Hi @arkanoider great job!
These are the details I’ve found:
- The fiat currency of the order is missing. The admin has no way to know which currency is being traded.
- Regarding user privacy, I assume it shows as “green” when users are not in privacy mode, but this is not clear and could be confusing. Maybe using a simple yes/no would be more explicit and easier to understand.
-
I find it a bit difficult to reach “Finalize dispute” by pressing Enter on users. I think it would be more intuitive to explicitly show something like “Resolve Dispute” and let the admin select it, since this is on of the most important actions here (just my opinion, of course, lets wait for more opinions).
-
In both “Dispute info” and “Finalize dispute” it says “Dispute ID”, but the value shown is actually the Order ID. I think both should be displayed to avoid confusion, or it should be clarified that the Order ID is being shown.
- If the dispute is no longer in the “Initiated” status, I don’t think it should appear under “Pending Disputes”. If it does remain there, it should at least not allow taking the dispute again, since that will result in a
cant-doresponse and doesn’t make sense.
- Even after a dispute has been finalized, it still allows selecting “AdminSettle” and “AdminCancel”. Those options shouldn’t be available anymore, since they will also result in a
cant-doresponse and don’t make sense.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/ui/helpers.rs`:
- Around line 131-203: The current early return only checks messages.is_empty(),
so when messages exist but none match active_chat_party the UI shows blank;
change the logic to first compute the filtered message iterator (the block
starting with messages.iter().filter_map(|msg| { ... }) used to build ListItem
entries), collect the resulting ListItems into a Vec, and if that filtered Vec
is empty return the placeholder ListItem (the same "No messages yet. Start the
conversation!" Span) instead of an empty list; update references to messages,
active_chat_party, ChatSender and the ListItem-building branch so the
placeholder is returned after party filtering.
…provements - Add second confirmation popup for dispute finalization - Add database status updates when disputes are finalized - Add filter toggle (F key) to switch between In Progress and Finalized disputes - Fix NOT NULL constraint error for dispute_id in database insert - Fix chat list showing blank when no messages match active party - Fix potential panic in error message wrapping (chunks(0)) - Fix incorrect pubkey fallback logic (show "Unknown" instead of misattributed data) - Fix all clippy warnings and apply formatting - Add Shift+F key combo indication in status bar All changes follow CODING_STANDARDS.md and pass cargo fmt/clippy checks.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/ui/mod.rs (1)
501-520: Reset new admin dispute/chat state on role switch.
switch_roleclears some fields but leaves the new admin chat/dispute state intact and duplicatesselected_dispute_idx, which can leak stale UI state between roles.♻️ Proposed fix
self.mode = UiMode::Normal; self.selected_dispute_idx = 0; self.selected_settings_option = 0; - self.selected_dispute_idx = 0; self.selected_in_progress_idx = 0; self.active_chat_party = ChatParty::Buyer; self.admin_chat_input.clear(); + self.admin_chat_input_enabled = true; + self.admin_dispute_chats.clear(); + self.admin_chat_list_state = ratatui::widgets::ListState::default(); + self.admin_disputes_in_progress.clear(); + self.dispute_filter = DisputeFilter::InProgress;Also applies to: 556-577, 580-590
🤖 Fix all issues with AI agents
In `@src/ui/dispute_finalization_popup.rs`:
- Around line 127-132: The current comparison sets is_initiator_buyer using
buyer_pubkey string which becomes "Unknown" when the Option is None, causing
false negatives; update the logic to compare dispute.initiator_pubkey against
the original Option values (dispute.buyer_pubkey and dispute.seller_pubkey)
before defaulting to "Unknown" — e.g., compute is_initiator_buyer and
is_initiator_seller by mapping the Option
(dispute.buyer_pubkey.as_ref().map(|bp| dispute.initiator_pubkey ==
*bp).unwrap_or(false)) or use Option<bool> to represent an unknown initiator
state, and only use truncate_pubkey(buyer_pubkey) /
truncate_pubkey(seller_pubkey) for display after determining initiator role.
In `@src/ui/key_handler/enter_handlers.rs`:
- Around line 757-772: The filter currently hard-codes dispute.status ==
"initiated" which is brittle; update the filter in the DisputesPending branch to
use a case-insensitive comparison (e.g.,
dispute.status.eq_ignore_ascii_case("initiated")) or, preferably, convert the
string status to the typed enum from mostro-core (e.g.,
DisputeStatus::Initiated) and compare against that; update the
initiated_disputes construction and any comments accordingly so app.mode is only
set when the status matches reliably (symbols to edit:
Tab::Admin(AdminTab::DisputesPending), disputes_lock, initiated_disputes,
app.selected_dispute_idx, UiMode::AdminMode(AdminMode::ConfirmTakeDispute)).
♻️ Duplicate comments (3)
src/db.rs (1)
115-138: Missing "no such table" error handling in migration.The
check_column_existshelper only handles "no such column" errors. For databases that existed before theadmin_disputestable was introduced, this function will fail with "no such table" and propagate the error, preventing the application from starting.🔧 Suggested fix
match result { Ok(_) => Ok(true), // Column exists (query succeeded) Err(e) => { let error_msg = e.to_string().to_lowercase(); if error_msg.contains("no such column") { Ok(false) + } else if error_msg.contains("no such table") { + // Table doesn't exist yet - migration not needed for this column + // The table will be created when needed by other code paths + Ok(true) } else { Err(e.into()) } } }src/main.rs (1)
192-220: String-based message detection remains fragile.The detection of dispute-taken results still relies on substring matching (
msg.contains("Dispute") && msg.contains("taken successfully")). This was flagged in a previous review and acknowledged for improvement.src/ui/disputes_in_progress_tab.rs (1)
121-128: Long words exceeding line width not fully handled.When a single word (e.g., a pubkey or long URL) exceeds
available_width, the current logic incrementslinesby 1 and setscurrent_width = word_width. However, ifword_width > available_width, this doesn't account for the word wrapping across multiple lines in ratatui'sParagraphwidget withWrap { trim: true }.This was noted in a previous review but appears unaddressed. The input box height may be underestimated for very long tokens.
🧹 Nitpick comments (10)
src/ui/disputes_tab.rs (1)
56-81: Consider documenting the purpose of_original_idx.The original index is captured but unused. If it's reserved for future use (e.g., mapping selection back to the unfiltered list), a brief comment would clarify intent. Otherwise, simplify to
.map(|(display_idx, (_, dispute))| ...)or even drop the enumerate on the initial filter if unneeded.src/ui/dispute_finalization_confirm.rs (1)
17-62: Consider extracting shared dispute lookup and error popup logic.This dispute lookup pattern and error popup rendering is duplicated in
dispute_finalization_popup.rs. Extract to a shared helper insrc/ui/helpers.rsto reduce duplication.♻️ Example helper signature
// In src/ui/helpers.rs pub fn find_dispute_by_id<'a>( disputes: &'a [AdminDispute], dispute_id: &uuid::Uuid, ) -> Option<&'a AdminDispute> { disputes.iter().find(|d| { d.dispute_id == dispute_id.to_string() || d.id == dispute_id.to_string() }) } pub fn render_error_popup( f: &mut ratatui::Frame, title: &str, message: &str, dismiss_hint: &str, );src/main.rs (1)
141-159: Extract duplicate dispute filtering logic.The filter for
InProgressdisputes is duplicated at startup (lines 145-153) and after dispute-taken refresh (lines 202-210). Extract to a helper for consistency.♻️ Suggested extraction
fn filter_in_progress_disputes(disputes: Vec<AdminDispute>) -> Vec<AdminDispute> { disputes .into_iter() .filter(|d| d.status.as_ref().map(|s| s == "InProgress").unwrap_or(false)) .collect() }Also applies to: 199-220
src/ui/dispute_finalization_popup.rs (1)
428-437: Consider using the existingcreate_centered_popuphelper.This
center_rectfunction duplicateshelpers::create_centered_popup(shown in relevant snippets fromsrc/ui/helpers.rs). Consolidate to avoid duplicate implementations.♻️ Suggested change
-fn center_rect(area: Rect, width: u16, height: u16) -> Rect { - let [popup] = Layout::horizontal([Constraint::Length(width)]) - .flex(Flex::Center) - .areas(area); - let [popup] = Layout::vertical([Constraint::Length(height)]) - .flex(Flex::Center) - .areas(popup); - popup -} +// Use: super::helpers::create_centered_popup(area, width, height)Then replace
center_rect(...)calls withsuper::helpers::create_centered_popup(...).src/util/order_utils/execute_take_dispute.rs (1)
104-127: Potentially redundant status update.The code sets
status = "InProgress"on the cloneddispute_info_clone(line 106) before callingAdminDispute::new(), which should persist with that status. Then it immediately callsset_status_in_progress()again on lines 119-120.If
AdminDispute::new()succeeds with an insert, the second update is unnecessary. If it hits the upsert path (unique constraint violation → update), theupdate_dbmethod already persists all fields including status from the clone.Also note: line 118's comment says "Use dispute_info.id to ensure we're updating the correct record" but
dispute_info.idis the order ID (not the dispute UUID), which is correct since that's the primary key inadmin_disputes, but the comment could be clearer.♻️ Consider removing redundant update
If you want to keep the defensive double-write for robustness, add a clarifying comment. Otherwise:
if let Err(e) = AdminDispute::new(pool, dispute_info_clone, dispute_id.to_string()).await { log::error!("Failed to save dispute to database: {}", e); return Err(anyhow::anyhow!("Failed to save dispute to database: {}", e)); } - // Also explicitly update status to ensure it's set (in case of update path) - // Use dispute_info.id to ensure we're updating the correct record - if let Err(e) = - AdminDispute::set_status_in_progress(pool, &dispute_info.id.to_string()).await - { - log::error!("Failed to update dispute status to InProgress: {}", e); - return Err(anyhow::anyhow!( - "Failed to update dispute status to InProgress: {}", - e - )); - }src/ui/key_handler/mod.rs (1)
284-340: Duplicated chat message filtering logic.The
visible_countcalculation with thefilterclosure (lines 297-313) is repeated identically in PageUp/PageDown (lines 356-372) and End (lines 432-448) handlers. This makes the code harder to maintain.♻️ Extract filtering into a helper
Consider extracting a helper function:
fn get_visible_message_count( messages: &[super::DisputeChatMessage], active_chat_party: super::ChatParty, ) -> usize { messages .iter() .filter(|msg| match msg.sender { super::ChatSender::Admin => msg.target_party == Some(active_chat_party), super::ChatSender::Buyer => active_chat_party == super::ChatParty::Buyer, super::ChatSender::Seller => active_chat_party == super::ChatParty::Seller, }) .count() }Then reuse it in all three navigation handlers.
src/models.rs (1)
299-307: Silent failure on JSON serialization.Using
.ok()to convert serialization errors toNonesilently drops errors. IfUserInfofails to serialize (unlikely but possible with custom types), the data is lost without any indication.♻️ Log serialization failures
// Serialize UserInfo to JSON let initiator_info_json = dispute_info .initiator_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize initiator_info: {}", e)) + .ok() + }); let counterpart_info_json = dispute_info .counterpart_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize counterpart_info: {}", e)) + .ok() + });src/ui/key_handler/navigation.rs (1)
143-161: Redundant bounds clamping in dispute navigation.The up-key handling for
DisputesPendinghas multiple redundant checks:
- Line 152: checks
> 0before decrementing- Line 154-156: else sets to 0 (but if already 0, this is a no-op)
- Lines 158-160: clamps with
.min()againThe logic is correct but overly defensive. The same pattern appears in the down-key handler (lines 254-262).
♻️ Simplified version
} else if let Tab::Admin(AdminTab::DisputesPending) = app.active_tab { // Only count disputes with "initiated" status let disputes_lock = disputes.lock().unwrap(); let initiated_count = disputes_lock .iter() .filter(|d| d.status == "initiated") .count(); - if initiated_count > 0 { - // Ensure index doesn't go below 0 - if app.selected_dispute_idx > 0 { - app.selected_dispute_idx -= 1; - } else { - app.selected_dispute_idx = 0; - } - // Clamp to valid range - app.selected_dispute_idx = app - .selected_dispute_idx - .min(initiated_count.saturating_sub(1)); - } + if initiated_count > 0 && app.selected_dispute_idx > 0 { + app.selected_dispute_idx = (app.selected_dispute_idx - 1) + .min(initiated_count.saturating_sub(1)); + } }src/ui/disputes_in_progress_tab.rs (1)
466-473: Auto-scroll may interfere with manual navigation.The condition on line 469 (
current_selection.unwrap_or(0) >= total_items.saturating_sub(1)) resets to the bottom whenever the user is at or near the last message. This means if a user manually scrolls up and new messages arrive, or if they're viewing the second-to-last message, they'll be snapped to the bottom.Consider only auto-scrolling when selection is
None(initial state or after party switch) to preserve user's manual scroll position.♻️ Preserve manual scroll position
if total_items > 0 { let current_selection = app.admin_chat_list_state.selected(); - // Reset to bottom if: no selection, selection is out of bounds, or selection is at the end - if current_selection.is_none() - || current_selection.unwrap_or(0) >= total_items - || current_selection.unwrap_or(0) >= total_items.saturating_sub(1) - { + // Reset to bottom only if: no selection or selection is out of bounds + if current_selection.is_none() || current_selection.unwrap_or(0) >= total_items { app.admin_chat_list_state .select(Some(total_items.saturating_sub(1))); }src/ui/helpers.rs (1)
170-188: Minor: Unnecessary string cloning.
msg.content.clone()on lines 176 and 185 clones the message content for each displayed message. SinceListItemcan work with borrowed content and the messages slice outlives the returned items, you could potentially avoid these clones.However, given the chat message volumes are typically small and this is UI code that runs once per frame, this is a very minor optimization concern.
| let buyer_pubkey = dispute.buyer_pubkey.as_deref().unwrap_or("Unknown"); | ||
| let seller_pubkey = dispute.seller_pubkey.as_deref().unwrap_or("Unknown"); | ||
|
|
||
| let is_initiator_buyer = dispute.initiator_pubkey == buyer_pubkey; | ||
| let buyer_pubkey_display = truncate_pubkey(buyer_pubkey); | ||
| let seller_pubkey_display = truncate_pubkey(seller_pubkey); |
There was a problem hiding this comment.
Edge case: is_initiator_buyer may be incorrect when buyer_pubkey is unknown.
When buyer_pubkey is "Unknown", the comparison dispute.initiator_pubkey == buyer_pubkey (line 130) will always be false. This means:
- If the buyer is the initiator but their pubkey is missing, they'll incorrectly be shown as non-initiator
- Privacy and rating info will be misattributed
Consider a tri-state approach or explicitly checking if pubkeys are available before determining initiator role.
🔧 Suggested approach
let is_initiator_buyer = dispute.buyer_pubkey.as_ref()
.map(|bp| dispute.initiator_pubkey == *bp)
.unwrap_or(false); // or use Option<bool> for unknown state
let is_initiator_seller = dispute.seller_pubkey.as_ref()
.map(|sp| dispute.initiator_pubkey == *sp)
.unwrap_or(false);
// When neither matches, initiator role is unknown
let initiator_role_known = is_initiator_buyer || is_initiator_seller;🤖 Prompt for AI Agents
In `@src/ui/dispute_finalization_popup.rs` around lines 127 - 132, The current
comparison sets is_initiator_buyer using buyer_pubkey string which becomes
"Unknown" when the Option is None, causing false negatives; update the logic to
compare dispute.initiator_pubkey against the original Option values
(dispute.buyer_pubkey and dispute.seller_pubkey) before defaulting to "Unknown"
— e.g., compute is_initiator_buyer and is_initiator_seller by mapping the Option
(dispute.buyer_pubkey.as_ref().map(|bp| dispute.initiator_pubkey ==
*bp).unwrap_or(false)) or use Option<bool> to represent an unknown initiator
state, and only use truncate_pubkey(buyer_pubkey) /
truncate_pubkey(seller_pubkey) for display after determining initiator role.
- Split enter_handlers.rs (1060→617 lines) into user_handlers, admin_handlers, message_handlers - Created chat_helpers.rs to eliminate duplicate filtering logic - Reduced mod.rs from 507 to 403 lines - Replaced all string-based dispute status checks with DisputeStatus enum - Updated DB queries to use kebab-case status strings (in-progress, settled, seller-refunded) - Added finalized disputes view with Shift+C toggle - Moved execute_finalize_dispute to order_utils module - Changed filter toggle from F to Shift+C, YES button always green
Hacker! This things should be fixed, I will see rabbit rants tomorrow, when you have some time take a look! |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/ui/mod.rs (1)
580-590: Duplicate assignment ofselected_dispute_idx.Line 584 assigns
selected_dispute_idx = 0, then line 586 assigns it again. The second assignment is likely a copy-paste artifact.🐛 Proposed fix
pub fn switch_role(&mut self, new_role: UserRole) { self.user_role = new_role; self.active_tab = Tab::first(new_role); self.mode = UiMode::Normal; self.selected_dispute_idx = 0; self.selected_settings_option = 0; - self.selected_dispute_idx = 0; self.selected_in_progress_idx = 0; self.active_chat_party = ChatParty::Buyer; self.admin_chat_input.clear(); }
🤖 Fix all issues with AI agents
In `@src/ui/dispute_finalization_popup.rs`:
- Around line 155-168: The buyer_privacy/seller_privacy computation relies on
is_initiator_buyer which can be incorrect when buyer_pubkey is "Unknown"; change
the logic to determine privacy directly from dispute initiator vs buyer/seller
pubkeys (e.g., compare dispute.initiator_pubkey to dispute.buyer_pubkey and
dispute.seller_pubkey, handling the "Unknown" case explicitly) and then set
buyer_privacy and seller_privacy from dispute.initiator_full_privacy and
dispute.counterpart_full_privacy based on that concrete comparison, ensuring the
roles cannot be swapped due to a faulty is_initiator_buyer value.
In `@src/ui/key_handler/message_handlers.rs`:
- Around line 54-55: In message_handlers.rs, the handlers currently set app.mode
to UiMode::UserMode(UserMode::WaitingAddInvoice) unconditionally; change those
assignments (the one at the location that sets UserMode::WaitingAddInvoice and
the other at the later handler around the second occurrence) to inspect
app.user_role and set app.mode via a match so admins get AdminMode::Normal and
users get UserMode::WaitingAddInvoice (use the pattern matching shown in the
review), referencing UiMode, UserMode::WaitingAddInvoice, AdminMode::Normal, and
app.user_role to determine the default_mode before assigning app.mode.
In `@src/util/order_utils/execute_finalize_dispute.rs`:
- Around line 55-61: The code in execute_finalize_dispute is passing the
dispute_id to AdminDispute::set_status_settled and
AdminDispute::set_status_seller_refunded which perform WHERE id = ? (order
primary key) — instead pass the order id instead: obtain the order_id
(AdminDispute.id, e.g., from the AdminTakeDispute/dispute_info.id or by first
loading the AdminDispute record by dispute_id) and then call
AdminDispute::set_status_settled(pool, &order_id) or
AdminDispute::set_status_seller_refunded(pool, &order_id); alternatively fetch
AdminDispute by dispute_id and use its id field for the update so the WHERE id =
? matches the correct row.
♻️ Duplicate comments (4)
src/main.rs (1)
186-208: String-based message detection remains fragile.This is a duplicate of the previous review comment. The detection logic still relies on matching substrings
"Dispute"and"taken successfully"or"settled"/"canceled"in the info message. The developer has acknowledged this will be improved.src/ui/dispute_finalization_popup.rs (1)
136-141: Initiator role detection is unreliable when pubkeys are unknown.When
buyer_pubkeyis"Unknown"(line 136), the comparisondispute.initiator_pubkey == buyer_pubkey(line 139) will always be false. This can misattribute the initiator badge, privacy settings, and rating information to the wrong party.Consider comparing against the original
Optionvalues before defaulting to "Unknown":🔧 Suggested fix
- let buyer_pubkey = dispute.buyer_pubkey.as_deref().unwrap_or("Unknown"); - let seller_pubkey = dispute.seller_pubkey.as_deref().unwrap_or("Unknown"); - - let is_initiator_buyer = dispute.initiator_pubkey == buyer_pubkey; + let is_initiator_buyer = dispute + .buyer_pubkey + .as_ref() + .map(|bp| dispute.initiator_pubkey == *bp) + .unwrap_or(false); + let is_initiator_seller = dispute + .seller_pubkey + .as_ref() + .map(|sp| dispute.initiator_pubkey == *sp) + .unwrap_or(false); + let initiator_role_unknown = !is_initiator_buyer && !is_initiator_seller; + + let buyer_pubkey = dispute.buyer_pubkey.as_deref().unwrap_or("Unknown"); + let seller_pubkey = dispute.seller_pubkey.as_deref().unwrap_or("Unknown");Then update the buyer/seller role display (lines 258-283) to show "Role Unknown" when
initiator_role_unknownis true.src/ui/disputes_in_progress_tab.rs (1)
128-157: Long single-word tokens can cause input height miscalculation.The word-wrapping logic increments
linesby 1 when a word doesn't fit, but doesn't account for words wider thanavailable_width(e.g., a pubkey pasted without spaces). This can cause the input box to be too short and clip text.🔧 Proposed fix
if current_width + space_width + word_width > available_width { // Word doesn't fit, wrap to next line lines += 1; - current_width = word_width; + // If the word itself exceeds the width, account for extra lines + if word_width > available_width { + let full_lines = word_width / available_width; + lines += full_lines; + current_width = word_width % available_width; + if current_width == 0 { + current_width = available_width; + } + } else { + current_width = word_width; + } } else {src/ui/key_handler/enter_handlers.rs (1)
276-280: Don't re-enable chat input when input is empty.Pressing Enter with an empty input still forces
admin_chat_input_enabled = true, which defeats the purpose of the Shift+I toggle. If the admin explicitly disabled input, it should remain disabled.🐛 Suggested fix
} else { - // If input is empty, ensure input stays enabled and mode is correct + // If input is empty or disabled, keep the current enabled state app.mode = UiMode::AdminMode(AdminMode::ManagingDispute); - app.admin_chat_input_enabled = true; }
🧹 Nitpick comments (16)
src/models.rs (3)
299-307: Silent failure on JSON serialization.If
serde_json::to_stringfails, the error is silently swallowed via.ok(), andNoneis stored. Consider logging a warning when serialization fails to aid debugging.Proposed improvement
let initiator_info_json = dispute_info .initiator_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize initiator_info: {}", e)) + .ok() + }); let counterpart_info_json = dispute_info .counterpart_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize counterpart_info: {}", e)) + .ok() + });
496-502: Silent failure on JSON deserialization.Similar to serialization, deserialization errors are silently ignored. For debugging purposes, consider logging when JSON parsing fails.
Proposed improvement
fn deserialize_user_info(&mut self) { if let Some(ref json_str) = self.initiator_info { - self.initiator_info_data = serde_json::from_str(json_str).ok(); + self.initiator_info_data = serde_json::from_str(json_str) + .map_err(|e| log::warn!("Failed to deserialize initiator_info: {}", e)) + .ok(); } if let Some(ref json_str) = self.counterpart_info { - self.counterpart_info_data = serde_json::from_str(json_str).ok(); + self.counterpart_info_data = serde_json::from_str(json_str) + .map_err(|e| log::warn!("Failed to deserialize counterpart_info: {}", e)) + .ok(); } }
521-527: Consider using constants or the DisputeStatus enum for status strings.The status values
'in-progress','settled', and'seller-refunded'are hardcoded strings. UsingDisputeStatus::InProgress.to_string()(as done inexecute_take_dispute.rsline 106) would ensure consistency and catch typos at compile time.Proposed improvement
pub async fn set_status_in_progress(pool: &SqlitePool, dispute_id: &str) -> Result<()> { - sqlx::query(r#"UPDATE admin_disputes SET status = 'in-progress' WHERE id = ?"#) + sqlx::query(r#"UPDATE admin_disputes SET status = ? WHERE id = ?"#) + .bind(DisputeStatus::InProgress.to_string()) .bind(dispute_id) .execute(pool) .await?; Ok(()) }Apply similar changes to
set_status_settledandset_status_seller_refunded.Also applies to: 533-539, 545-551
src/util/order_utils/execute_take_dispute.rs (2)
104-127: Redundant status update to InProgress.The status is set to
InProgressin the cloneddispute_info_clone(line 106), thenAdminDispute::newis called which persists this status. The subsequent call toset_status_in_progress(lines 119-127) is redundant since the record was just created/updated withInProgressstatus.The comment mentions "in case of update path", but
AdminDispute::newalready handles the upsert and includes the status in the update.Proposed simplification
// Clone and override status to InProgress before saving - this admin is now resolving it let mut dispute_info_clone = dispute_info.clone(); dispute_info_clone.status = DisputeStatus::InProgress.to_string(); // Save dispute info to database with InProgress status // Pass the dispute_id (from the function parameter) to distinguish it from order_id if let Err(e) = AdminDispute::new(pool, dispute_info_clone, dispute_id.to_string()).await { log::error!("Failed to save dispute to database: {}", e); return Err(anyhow::anyhow!("Failed to save dispute to database: {}", e)); } - // Also explicitly update status to ensure it's set (in case of update path) - // Use dispute_info.id to ensure we're updating the correct record - if let Err(e) = - AdminDispute::set_status_in_progress(pool, &dispute_info.id.to_string()).await - { - log::error!("Failed to update dispute status to InProgress: {}", e); - return Err(anyhow::anyhow!( - "Failed to update dispute status to InProgress: {}", - e - )); - } log::info!(
94-102: Mismatch in error message references.On line 100, the error message uses
dispute_info.idbut the comparison on line 96 uses*id(from the payload). While both should be the same value, usingidin the error message would be more consistent with what was actually compared.Proposed fix
// Verify the dispute ID matches if *id != *dispute_id { return Err(anyhow::anyhow!( "Dispute ID mismatch: expected {}, got {}", dispute_id, - dispute_info.id + id )); }src/ui/disputes_tab.rs (2)
22-24: Redundant imports.
mostro_core::prelude::*is already imported at line 4, andstd::str::FromStris likely also available through the prelude or could be imported at the top of the file.Proposed fix
+use std::str::FromStr; use std::sync::{Arc, Mutex}; use chrono::DateTime; use mostro_core::prelude::*; // ... rest of imports pub fn render_disputes_tab( // ... ) { let disputes_lock = disputes.lock().unwrap(); // Filter to only show disputes with "initiated" status - use mostro_core::prelude::*; - use std::str::FromStr; let initiated_disputes: Vec<(usize, &Dispute)> = disputes_lock
25-33: Unused_original_idxin the collected tuple.The original index is stored but never used. If it's not needed, you can simplify the collection to just filter the disputes directly.
Proposed simplification
- let initiated_disputes: Vec<(usize, &Dispute)> = disputes_lock + let initiated_disputes: Vec<&Dispute> = disputes_lock .iter() - .enumerate() - .filter(|(_, dispute)| { + .filter(|dispute| { DisputeStatus::from_str(dispute.status.as_str()) .map(|s| s == DisputeStatus::Initiated) .unwrap_or(false) }) .collect(); // ... and in the rendering: - let rows: Vec<Row> = initiated_disputes - .iter() - .enumerate() - .map(|(display_idx, (_original_idx, dispute))| { + let rows: Vec<Row> = initiated_disputes + .iter() + .enumerate() + .map(|(display_idx, dispute)| {Also applies to: 62-65
src/ui/dispute_finalization_confirm.rs (1)
64-67: Fixed popup dimensions may not fit small terminals.The popup uses fixed dimensions (70x15) which may overflow on terminals smaller than these dimensions. Consider making the popup responsive like the error popup case (lines 25-26) which uses relative sizing.
Proposed improvement
let area = f.area(); - let popup_width = 70; - let popup_height = 15; + let popup_width = 70.min(area.width.saturating_sub(4)); + let popup_height = 15.min(area.height.saturating_sub(2));src/ui/key_handler/message_handlers.rs (1)
135-135: Empty handler for PayInvoice action.The
PayInvoicematch arm is empty. If this is intentional (e.g., PayInvoice doesn't require user action), consider adding a comment explaining why. If it's a TODO, mark it explicitly.Proposed improvement
- Action::PayInvoice => {} + Action::PayInvoice => { + // PayInvoice is display-only; no user action required + }src/ui/key_handler/mod.rs (1)
261-287: Consider extracting finalized status check to a shared helper.The finalized status check logic (lines 267-283) is duplicated from
dispute_finalization_popup.rs(lines 107-117). Consider extracting this to a shared utility function in the models or helpers module to reduce duplication.src/ui/key_handler/chat_helpers.rs (1)
90-96: Minor: Redundant.min()call in PageUp handling.The
.min(visible_count.saturating_sub(1))on line 95 is redundant sincecurrent.saturating_sub(10)already produces a value ≤current, which is already ≤visible_count - 1. This doesn't cause issues but adds unnecessary computation.♻️ Suggested simplification
crossterm::event::KeyCode::PageUp => { // Scroll up (show older messages) - move selection up by ~10 items - current - .saturating_sub(10) - .min(visible_count.saturating_sub(1)) + current.saturating_sub(10) }src/ui/key_handler/navigation.rs (1)
168-171: Duplicate navigation logic for DisputesInProgress tab.The up-navigation for
DisputesInProgressis duplicated betweenAdminMode::Normal(lines 168-171) andAdminMode::ManagingDispute(lines 199-206). Consider consolidating by matching both modes together:♻️ Suggested consolidation
UiMode::Normal | UiMode::UserMode(UserMode::Normal) - | UiMode::AdminMode(AdminMode::Normal) => { + | UiMode::AdminMode(AdminMode::Normal) + | UiMode::AdminMode(AdminMode::ManagingDispute) => { // ... existing code ... } - UiMode::AdminMode(AdminMode::ManagingDispute) => { - // Navigate within disputes in progress list - if let Tab::Admin(AdminTab::DisputesInProgress) = app.active_tab { - if !app.admin_disputes_in_progress.is_empty() && app.selected_in_progress_idx > 0 { - app.selected_in_progress_idx -= 1; - } - } - }Also applies to: 199-206
src/ui/disputes_in_progress_tab.rs (2)
263-275: Consider clamping rating before star calculation.The star display uses
(info.rating / 2.0).round() as usizethen.repeat(). While.take(5)caps the output, an unexpectedly large rating value would cause unnecessary allocation (e.g., rating of 1000 →.repeat(500)before truncation).♻️ Suggested improvement
- let stars = "⭐" - .repeat((info.rating / 2.0).round() as usize) - .chars() - .take(5) - .collect::<String>(); + let star_count = ((info.rating / 2.0).round() as usize).min(5); + let stars = "⭐".repeat(star_count);
561-574: Simplify redundant selection bounds check.The condition at lines 565-566 is redundant:
>= total_itemsis always true when>= total_items.saturating_sub(1)is true (fortotal_items > 0). The intent seems to be "reset to bottom if selection is invalid or at end."♻️ Simplified condition
- if current_selection.is_none() - || current_selection.unwrap_or(0) >= total_items - || current_selection.unwrap_or(0) >= total_items.saturating_sub(1) - { + // Reset to bottom if: no selection or selection is at/past the end + if current_selection.map_or(true, |sel| sel >= total_items.saturating_sub(1)) {src/ui/key_handler/user_handlers.rs (1)
78-82: Silent parse failure could mask invalid amount input.Using
.parse::<i64>().ok()silently converts parse failures toNone. Since the validation at lines 60-70 already checks for empty input and validation errors, a parse failure here indicates the validation logic didn't catch an invalid format. Consider logging or handling this case explicitly.♻️ More explicit handling
let amount = if take_state_clone.is_range_order { - take_state_clone.amount_input.trim().parse::<i64>().ok() + match take_state_clone.amount_input.trim().parse::<i64>() { + Ok(amt) => Some(amt), + Err(e) => { + log::warn!("Amount parse failed after validation: {}", e); + None + } + } } else { None };src/ui/key_handler/enter_handlers.rs (1)
202-211: Mock responses are placeholder - ensure tracking for replacement.The mock response array is clearly temporary per the TODO at lines 198-199. Consider adding a tracking issue or more visible marker to ensure this gets replaced with real Nostr DM integration.
Would you like me to open an issue to track replacing the mock chat responses with real Nostr DM integration?
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@src/ui/dispute_finalization_popup.rs`:
- Around line 143-157: The buyer/seller privacy flags can be swapped when
is_initiator_buyer is unreliable (e.g., buyer_pubkey == "Unknown"); instead
derive a reliable initiator boolean by comparing dispute.initiator_pubkey to
buyer_pubkey and only fall back to is_initiator_buyer if buyer_pubkey ==
"Unknown", then use that derived boolean when computing buyer_privacy and
seller_privacy (replace direct uses of is_initiator_buyer in the
buyer_privacy/seller_privacy expressions so they use the new derived
initiator_is_buyer).
- Around line 302-308: The displayed fiat amount is missing its currency label;
update the Span that currently uses Span::raw(format!("{}",
dispute.fiat_amount)) inside the Line::from block so it shows the currency code
alongside the amount (e.g. format!("{} {}", dispute.fiat_amount,
dispute.fiat_currency) or format!("{} {}", dispute.fiat_amount,
dispute.fiat_currency_code) depending on the field name on the dispute struct),
keeping the existing Span::styled(...) for "Fiat Amount: " and preserving
formatting via Span::raw for the combined string.
In `@src/ui/helpers.rs`:
- Around line 189-196: Replace the non-existent Span::into_right_aligned_line()
calls used when is_right_aligned is true: instead of calling
into_right_aligned_line() on header_span and message_span, convert each Span
into a Line via Line::from(span) and set the alignment to Alignment::Right
(e.g., Line::from(header_span).alignment(Alignment::Right)) before pushing into
message_lines; update both the header_span and message_span usages so
compilation succeeds with ratatui v0.30.0.
In `@src/ui/key_handler/mod.rs`:
- Around line 202-210: When entering the finalization popup set and use the
dispute UUID consistently: instead of parsing selected_dispute.id (which is the
order id), parse and pass the dispute's UUID field (or otherwise ensure the
value you parse is the dispute identifier) into
UiMode::AdminMode(AdminMode::ReviewingDisputeForFinalization(dispute_id, ...));
then update any later lookup/comparison logic that checks d.dispute_id (e.g.,
the "finalized" check and related conditionals that iterate
admin_disputes_in_progress) to compare against this same dispute_id value rather
than the order id string so the finalized check and actions target the correct
dispute.
In `@src/ui/key_handler/navigation.rs`:
- Around line 144-168: The Up/Down handlers for
Tab::Admin(AdminTab::DisputesPending) compute initiated_count but do not reset
app.selected_dispute_idx when initiated_count == 0, leaving a stale index;
update both handlers (the blocks that use disputes.lock(), initiated_count, and
mutate app.selected_dispute_idx) to explicitly set app.selected_dispute_idx = 0
when initiated_count == 0, otherwise perform the existing decrement/increment
and then clamp with .min(initiated_count.saturating_sub(1)) to ensure the index
is always valid relative to the filtered list of disputes (use
DisputeStatus::from_str(d.status.as_str()) and the existing disputes lock
logic).
♻️ Duplicate comments (3)
src/main.rs (1)
187-210: String-based message detection remains fragile.This detection logic still relies on substring matching (
msg.contains("Dispute"),msg.contains("taken successfully"), etc.). This was flagged in a previous review, and you acknowledged you'd improve it with a structured approach (e.g., a dedicated enum variant likeOrderResult::DisputeTaken).src/ui/dispute_finalization_popup.rs (1)
125-130: Initiator detection still unreliable when buyer_pubkey is unknown.When
buyer_pubkeyis"Unknown", the comparisondispute.initiator_pubkey == buyer_pubkeyon line 128 will always be false, incorrectly marking the seller as the initiator. This was noted in past reviews.Consider comparing against the original
Optionvalues:🔧 Suggested fix
let buyer_pubkey = dispute.buyer_pubkey.as_deref().unwrap_or("Unknown"); let seller_pubkey = dispute.seller_pubkey.as_deref().unwrap_or("Unknown"); - let is_initiator_buyer = dispute.initiator_pubkey == buyer_pubkey; + // Compare against original Option to avoid "Unknown" false matches + let is_initiator_buyer = dispute + .buyer_pubkey + .as_ref() + .map(|bp| dispute.initiator_pubkey == *bp) + .unwrap_or(false); + + let is_initiator_seller = dispute + .seller_pubkey + .as_ref() + .map(|sp| dispute.initiator_pubkey == *sp) + .unwrap_or(false); + + // When neither matches, initiator role is unknown + let initiator_role_known = is_initiator_buyer || is_initiator_seller; let buyer_pubkey_display = truncate_pubkey(buyer_pubkey); let seller_pubkey_display = truncate_pubkey(seller_pubkey);src/ui/disputes_in_progress_tab.rs (1)
125-156: Handle long tokens in input height calculation.Long words (e.g., pubkeys) wider than
available_widthstill incrementlinesby 1, so the input box can be too short and text clips.🛠️ Suggested fix
- if current_width + space_width + word_width > available_width { - // Word doesn't fit, wrap to next line - lines += 1; - current_width = word_width; + if current_width + space_width + word_width > available_width { + // Word doesn't fit, wrap to next line + lines += 1; + // If the word itself exceeds the width, account for extra lines + if word_width > available_width { + let full_lines = word_width / available_width; + lines += full_lines; + current_width = word_width % available_width; + if current_width == 0 { + current_width = available_width; + } + } else { + current_width = word_width; + } } else {
🧹 Nitpick comments (5)
src/models.rs (2)
299-307: Silent serialization failures could lead to data loss.If
serde_json::to_stringfails, the error is silently swallowed with.ok(), resulting inNonebeing stored. This could cause user info to be lost without any indication.Consider logging when serialization fails:
🔧 Suggested fix
let initiator_info_json = dispute_info .initiator_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize initiator_info: {}", e)) + .ok() + }); let counterpart_info_json = dispute_info .counterpart_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize counterpart_info: {}", e)) + .ok() + });
519-529: Status update methods don't verify affected rows.The
set_status_in_progress,set_status_settled, andset_status_seller_refundedmethods don't check if the UPDATE actually modified any rows. If the dispute ID doesn't exist, the operation silently succeeds.Consider checking
rows_affected()and returning an error or logging when no rows were updated:🔧 Suggested fix for set_status_in_progress
pub async fn set_status_in_progress(pool: &SqlitePool, dispute_id: &str) -> Result<()> { - sqlx::query(r#"UPDATE admin_disputes SET status = ? WHERE id = ?"#) + let result = sqlx::query(r#"UPDATE admin_disputes SET status = ? WHERE id = ?"#) .bind(DisputeStatus::InProgress.to_string()) .bind(dispute_id) .execute(pool) .await?; + if result.rows_affected() == 0 { + log::warn!("No dispute found with id {} to update", dispute_id); + } Ok(()) }Also applies to: 531-555
src/util/order_utils/execute_take_dispute.rs (1)
86-90: Only first response message is processed.If multiple DM events arrive, only the first is processed. While this is likely fine for the typical case, you may want to log if additional messages are received for debugging purposes.
src/ui/dispute_finalization_popup.rs (1)
436-444: Duplicatecenter_recthelper function.This
center_rectfunction duplicates the logic insrc/ui/helpers.rs::create_centered_popup. Consider reusing the existing helper to reduce code duplication.src/ui/mod.rs (1)
580-589: Consider resetting admin chat flags on role switch.
switch_roleclears chat input and resets selection, but leavesadmin_chat_input_enabled,admin_chat_list_state, anddispute_filteruntouched. That can restore a stale/disabled admin UI when switching back.♻️ Suggested adjustment
self.selected_in_progress_idx = 0; self.active_chat_party = ChatParty::Buyer; self.admin_chat_input.clear(); + self.admin_chat_input_enabled = true; + self.admin_chat_list_state = ratatui::widgets::ListState::default(); + self.dispute_filter = DisputeFilter::InProgress;
- Validate buyer_pubkey and seller_pubkey in AdminDispute::new() - Show error popup in finalization view when pubkeys are missing - Remove "Unknown" fallback to prevent invalid database entries
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/ui/disputes_in_progress_tab.rs`:
- Around line 260-322: The buyer-vs-seller rating display is inconsistent: when
is_initiator_buyer == Some(true) you format the initiator (buyer) as "/5" and
build stars from info.rating.round(), while all other branches use "/10" and
stars from (rating / 2.0); make them consistent (use the same scale and star
logic everywhere). Update the Some(true) branch for buyer_rating to mirror the
other branches: compute stars via ("⭐".repeat((info.rating / 2.0).round() as
usize).chars().take(5).collect::<String>()) and change the format string to "{}
{:.1}/10 ({} trades completed, {} days)"; ensure you also normalize any other
occurrences where buyer or seller ratings are formatted (references:
selected_dispute.initiator_info_data, selected_dispute.counterpart_info_data,
variables buyer_rating and seller_rating, and the is_initiator_buyer match) so
all branches show the same "/10" scale and star calculation.
- Around line 561-564: The code uses Option::is_none_or which requires Rust
1.82.0; either add rust-version = "1.82.0" to Cargo.toml [package] or change the
conditional around current_selection/total_items to avoid is_none_or—e.g., check
current_selection.is_none() || current_selection.unwrap_or(0) >=
total_items.saturating_sub(1) (or use match) before calling
app.admin_chat_list_state.select(Some(total_items.saturating_sub(1))) so the
logic in the current_selection / total_items branch (where
app.admin_chat_list_state.select is invoked) works on older Rust versions.
♻️ Duplicate comments (1)
src/ui/disputes_in_progress_tab.rs (1)
125-157: Input height calculation doesn't handle single long words spanning multiple lines.The word-wrapping calculation at lines 141-148 treats any word that doesn't fit on the current line as wrapping to the next line, but doesn't account for words longer than
available_widththat would span multiple lines themselves. This was noted in a past review comment.♻️ Suggested fix for long word handling
if current_width + space_width + word_width > available_width { // Word doesn't fit, wrap to next line lines += 1; - current_width = word_width; + // Handle words longer than available width + if word_width > available_width { + let extra_lines = word_width / available_width; + lines += extra_lines; + current_width = word_width % available_width; + if current_width == 0 { + current_width = available_width; + } + } else { + current_width = word_width; + } } else {
🧹 Nitpick comments (5)
src/ui/dispute_finalization_popup.rs (1)
176-184: Use of.expect()after validation is acceptable but fragile.The code validates
buyer_pubkeyandseller_pubkeyat lines 70-118 before reaching this point, so the.expect()calls should never panic. However, if future refactoring changes the control flow, these could become runtime panics.Consider using a more defensive approach that returns early with an error display rather than panicking:
♻️ Optional defensive approach
- let buyer_pubkey = dispute - .buyer_pubkey - .as_deref() - .expect("buyer_pubkey should be validated before rendering"); - let seller_pubkey = dispute - .seller_pubkey - .as_deref() - .expect("seller_pubkey should be validated before rendering"); + let (buyer_pubkey, seller_pubkey) = match ( + dispute.buyer_pubkey.as_deref(), + dispute.seller_pubkey.as_deref(), + ) { + (Some(bp), Some(sp)) => (bp, sp), + _ => return, // Should not happen due to earlier validation + };src/ui/key_handler/navigation.rs (1)
161-170: Minor redundancy in up navigation bounds checking.The
elsebranch at line 163-164 is redundant since ifapp.selected_dispute_idx > 0is false, it's already 0. The subsequent clamp at lines 167-169 also makes the earlier conditional partially redundant.♻️ Optional simplification
if initiated_count == 0 { app.selected_dispute_idx = 0; } else { - // Ensure index doesn't go below 0 - if app.selected_dispute_idx > 0 { - app.selected_dispute_idx -= 1; - } else { - app.selected_dispute_idx = 0; - } - // Clamp to valid range - app.selected_dispute_idx = app - .selected_dispute_idx - .min(initiated_count.saturating_sub(1)); + app.selected_dispute_idx = app + .selected_dispute_idx + .saturating_sub(1) + .min(initiated_count.saturating_sub(1)); }src/models.rs (2)
317-329: Hardcoded "USD" fallback may cause incorrect fiat currency display.When the related order is not found, the code defaults
fiat_codeto "USD". This could lead to incorrect currency display in the admin UI if the actual dispute involves a different fiat currency.Consider logging at
warnlevel instead ofdebugsince this represents a data integrity concern, or storing the fiat_code in the dispute info received from Mostro if available.♻️ Suggested improvement
Err(_) => { - // Order not found, use default - log::debug!( + // Order not found - this may cause incorrect currency display + log::warn!( "Order not found for dispute {}, using default fiat_code", dispute_info.id ); "USD".to_string() }
527-537: Confusing parameter naming inset_status_in_progress.The parameter is named
dispute_idbut is used inWHERE id = ?, whereidis the order ID (primary key). The other status methods (set_status_settled,set_status_seller_refunded) correctly name their parameterorder_id.♻️ Suggested fix for consistency
- pub async fn set_status_in_progress(pool: &SqlitePool, dispute_id: &str) -> Result<()> { + pub async fn set_status_in_progress(pool: &SqlitePool, order_id: &str) -> Result<()> { sqlx::query(r#"UPDATE admin_disputes SET status = ? WHERE id = ?"#) .bind(DisputeStatus::InProgress.to_string()) - .bind(dispute_id) + .bind(order_id) .execute(pool) .await?; Ok(()) }src/util/order_utils/execute_finalize_dispute.rs (1)
58-63: Consider adding error context for the order_id lookup.The raw SQL query will return an error if no dispute with the given
dispute_idexists, but the error message won't indicate what went wrong. Adding context would improve debugging.♻️ Suggested improvement
let order_id: String = sqlx::query_scalar::<_, String>( r#"SELECT id FROM admin_disputes WHERE dispute_id = ? LIMIT 1"#, ) .bind(&dispute_id_str) .fetch_one(pool) - .await?; + .await + .map_err(|e| anyhow::anyhow!("Failed to find dispute {}: {}", dispute_id, e))?;
Previously, pressing Enter in the disputes chat would force admin_chat_input_enabled = true even when the input was empty, overriding the admin's explicit choice to disable input via Shift+I.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/DATABASE.md (2)
220-244: Missing fiat_code column in schema definition.The CREATE TABLE statement for
admin_disputesis missing thefiat_codecolumn, which is mentioned in the Database Migrations section (lines 48-81) as one of the columns added via migration:"migrations add new columns initiator_info (TEXT), counterpart_info (TEXT), fiat_code (TEXT with default 'USD'), and dispute_id (TEXT NOT NULL DEFAULT '')"
This is directly related to reviewer issue
#1: the missing fiat currency label in the UI. If the schema doesn't includefiat_code, admins cannot see which fiat currency is being traded.🔧 Update schema to include fiat_code
The CREATE TABLE statement should include the fiat_code column. Based on the migration description, add after line 237:
fiat_amount INTEGER NOT NULL, +fiat_code TEXT NOT NULL DEFAULT 'USD', fee INTEGER NOT NULL,
251-275: Field descriptions missing fiat_code and dispute_id columns.The admin_disputes field descriptions table is missing documentation for
fiat_codeanddispute_idcolumns, which are mentioned in the Database Migrations section (lines 48-81) as columns added via migration.This omission is critical because:
- fiat_code relates to reviewer issue
#1(missing fiat currency label)- dispute_id relates to reviewer issue
#4(ID confusion between Dispute ID and Order ID)📝 Add missing field descriptions
Add these rows to the field descriptions table after line 269 (fiat_amount):
| `fiat_code` | `TEXT` | Fiat currency code (e.g., "USD", "EUR"). Defaults to "USD" if not specified. |And after line 275 (created_at):
| `dispute_id` | `TEXT` | Unique dispute identifier. Populated from id field for existing records. |
🤖 Fix all issues with AI agents
In `@docs/ADMIN_DISPUTES.md`:
- Around line 354-359: The docs are missing behavior for post-finalization
actions: update both code and documentation to prevent AdminSettle and
AdminCancel from remaining selectable after a dispute is finalized by adding
explicit state checks (e.g., is_finalized or status field on AdminDispute) and
gating logic in the action handlers and UI chooser. Specifically, add methods
like AdminDispute::is_finalized or AdminDispute::can_settle/can_cancel and use
them in the AdminSettle and AdminCancel handlers (and in the finalization
popup/UI action list) so those actions are disabled/removed once
AdminDispute::new sets the finalized state; then update the Data Validation
section to note that post-finalization actions are blocked by these checks and
return cant-do responses if attempted.
In `@docs/FINALIZE_DISPUTES.md`:
- Around line 54-70: The documentation in FINALIZE_DISPUTES.md contradicts the
reviewer feedback: update the three described items to match reality or document
fixes—specifically, clarify the "Dispute ID (full UUID)" entry to state whether
the UI shows Dispute ID or Order ID (or include both) and mark as TODO if still
wrong; change the "Privacy status" description from ambiguous emojis to explicit
labels (e.g., "Privacy: Yes/No (info available/private)") and note any pending
fix; and modify "Fiat amount and currency" to indicate whether the fiat currency
code is currently displayed or omitted in the UI (and add a FIXME/TODO if
missing). Include brief notes referencing the exact doc lines/phrases ("Dispute
ID (full UUID)", "Privacy status (🟢/🔴)", "Fiat amount and currency") so
readers can locate and reconcile doc vs UI, and state whether these issues were
resolved in this PR or remain outstanding.
- Line 20: Update the FINALIZE_DISPUTES.md documentation to remove the obsolete
"Press Enter when input is empty" method and replace both occurrences with the
explicit Shift+F shortcut: change the step titled "5. **Open Finalization**" so
it reads "5. **Open Finalization**: Press Shift+F to open finalization popup"
(replacing the existing "Press Enter when input is empty..." text) and update
the keymap/listing entry so it reads "- Shift+F: Open finalization popup"
instead of the old Enter behavior.
In `@docs/TUI_INTERFACE.md`:
- Line 73: The docs entry for "Disputes Pending" currently states it lists
disputes with state `Initiated` but omits the known bug where non-`Initiated`
items appear (issue `#5`); update the "Disputes Pending" line to either (A) add a
short note acknowledging the existing bug and that the filter should only
include `Initiated` disputes, or (B) if the bug is fixed, explicitly state the
behavior as "Only displays disputes with `Initiated` status"; reference the
exact phrase "Disputes Pending" and the `Initiated` status in the change so
readers know the expected filter behavior and the current bug status.
♻️ Duplicate comments (3)
docs/FINALIZE_DISPUTES.md (1)
224-224: Privacy indicator ambiguity not addressed.This is a duplicate of the concern at lines 60-61. The reviewer reported that the green/red privacy icons are unclear and suggested using explicit labels (e.g., "yes/no" or "available/private") for clarity.
docs/ADMIN_DISPUTES.md (2)
24-32: Pending disputes filter bug not documented.The reviewer reported (issue
#5) that non-Initiated disputes are appearing under "Pending Disputes", and attempting to take them returns a cant-do response. The documentation states disputes should be "state:Initiated" (line 27) but doesn't acknowledge this known filtering bug.Either the bug needs to be fixed, or the documentation should note this known issue and recommend checking dispute status before taking.
51-70: Header section doesn't address fiat currency code issue.Line 56 mentions "Amount in sats and fiat" but the reviewer (issue
#1) reported that the UI shows fiat amounts WITHOUT the fiat currency code, making it unclear which fiat currency is being traded.The documentation should either:
- Confirm that line 56 means "Amount in sats, fiat amount AND fiat currency code" (if fixed), or
- Acknowledge this as a known issue if not yet fixed
This is consistent with the missing
fiat_codefield in the DATABASE.md schema.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/ADMIN_DISPUTES.md (1)
326-327: Documentation doesn't acknowledge missing fiat currency label in UI.According to PR objectives, reviewer arkanoider reported (issue
#1, with screenshot) that the UI shows fiat amounts but not which fiat currency is used, preventing admins from knowing the traded fiat.The documentation describes
fiat_amountas "Amount in fiat currency" but doesn't acknowledge that the UI currently lacks the currency label.Consider adding a note to flag this known limitation:
- **`fiat_amount`**: Amount in fiat currency **Note**: Current UI displays the fiat amount but not the currency code (e.g., USD, EUR); this is being addressed.This helps admins understand current limitations when reviewing dispute information.
🤖 Fix all issues with AI agents
In `@docs/ADMIN_DISPUTES.md`:
- Line 52: Update the documentation header that currently reads "Dispute ID,
Type, Status" to include a clarifying note about the known labeling issue: add a
short sentence (e.g., "Note: Current UI may display Order ID in the Dispute ID
field; this labeling inconsistency is being addressed.") immediately below the
"Dispute ID, Type, Status" line so readers know the field may show the Order ID
and that it's being fixed; modify the docs/ADMIN_DISPUTES.md content where the
"Dispute ID, Type, Status" header appears to include this note.
In `@src/models.rs`:
- Around line 527-537: Rename the parameter dispute_id to order_id in the
function set_status_in_progress and update its doc comment to match sibling
functions (clarify it updates by order id); update all uses inside the function
(the .bind(dispute_id) call) to .bind(order_id) and ensure the SQL remains
"WHERE id = ?" so the parameter name correctly reflects the primary key being
updated.
♻️ Duplicate comments (4)
src/ui/disputes_in_progress_tab.rs (2)
517-527:is_none_orrequires Rust 1.82.0 - verify MSRV.The
is_none_ormethod onOptionwas stabilized in Rust 1.82.0 (October 2024). The project'sCargo.tomldoesn't specify arust-version, which may cause compilation failures on older Rust versions.Either add
rust-version = "1.82.0"toCargo.tomlor refactor to use an alternative:♻️ Compatible alternative
- if current_selection.is_none_or(|sel| sel >= total_items.saturating_sub(1)) { + if current_selection.map_or(true, |sel| sel >= total_items.saturating_sub(1)) {
141-157: Long single-word tokens may cause input height underestimation.The word-wrapping calculation counts each word that exceeds
available_widthas only one line. If a user pastes a long string without spaces (e.g., a pubkey or invoice), the input box height will be underestimated, causing visual clipping.♻️ Suggested improvement
if current_width + space_width + word_width > available_width { // Word doesn't fit, wrap to next line lines += 1; - current_width = word_width; + // Handle words wider than available_width + if word_width > available_width { + let extra_lines = word_width / available_width; + lines += extra_lines; + current_width = word_width % available_width; + if current_width == 0 { + current_width = available_width; + } + } else { + current_width = word_width; + } } else {docs/TUI_INTERFACE.md (1)
73-73: Documentation doesn't reflect known filtering bug reported by reviewer.According to PR objectives, reviewer arkanoider reported (issue
#5) that items not in "Initiated" status are appearing under "Pending Disputes", and taking them returns a cant-do response. The documentation states the ideal behavior but doesn't acknowledge this known defect.Since the reviewer marked this as a defect requiring fixes, consider either:
- Adding a note acknowledging the current bug:
"Only displays disputes with Initiated status (filtering implemented in disputes_tab.rs). **Note**: Currently, non-Initiated disputes may appear; this is a known issue being addressed."- Or, if the bug is fixed, explicitly confirm the filter works correctly
Note: This is similar to a past review comment on this line. The issue remains unresolved in the documentation.
docs/ADMIN_DISPUTES.md (1)
26-26: Documentation doesn't reflect filtering bug reported by reviewer.According to PR objectives, reviewer arkanoider reported (issue
#5) that non-Initiated disputes appear in the "Pending Disputes" tab, and attempting to take them returns a cant-do response. The documentation describes the ideal behavior ("state:Initiated") but doesn't acknowledge this known defect.Consider adding a note to acknowledge the current issue or confirm if it's been resolved.
Note: This is the same issue flagged in TUI_INTERFACE.md line 73.
🧹 Nitpick comments (1)
src/models.rs (1)
307-315: Silent error handling in JSON serialization may mask issues.The
.and_then(|info| serde_json::to_string(info).ok())pattern silently discards serialization errors. IfUserInfofails to serialize (e.g., due to invalid data), the field becomesNoneand the admin loses potentially important user rating information without any indication.Consider logging a warning when serialization fails:
♻️ Suggested improvement
let initiator_info_json = dispute_info .initiator_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize initiator_info: {}", e)) + .ok() + }); let counterpart_info_json = dispute_info .counterpart_info .as_ref() - .and_then(|info| serde_json::to_string(info).ok()); + .and_then(|info| { + serde_json::to_string(info) + .map_err(|e| log::warn!("Failed to serialize counterpart_info: {}", e)) + .ok() + });





🎯 Overview
This PR introduces a comprehensive admin dispute management system with an interactive chat interface, dispute finalization capabilities, and enhanced UI/UX improvements. Admins can now take disputes, communicate with parties, review detailed information, and finalize disputes through an intuitive TUI interface.
✨ Key Features
1. Admin Dispute Management UI
2. Interactive Chat System
3. Enhanced Dispute Information Display
4. Dispute Finalization
Action::AdminSettleorAction::AdminCancelvia encrypted DMs5. Exit Confirmation
6. Settings Enhancements
settings.toml🏗️ Technical Changes
Code Organization
key_handler.rs(1014 lines) into focused modules:key_handler/mod.rs- Main dispatcherkey_handler/navigation.rs- Tab and list navigationkey_handler/enter_handlers.rs- Enter key actionskey_handler/esc_handlers.rs- Escape key handlingkey_handler/form_input.rs- Form input handlingkey_handler/input_helpers.rs- Input utilitieskey_handler/settings.rs- Settings-specific handlerskey_handler/validation.rs- Input validationkey_handler/confirmation.rs- Confirmation popupsNew UI Components
src/ui/disputes_in_progress_tab.rs- Main dispute management UI with chatsrc/ui/dispute_finalization_popup.rs- Finalization popup renderingsrc/ui/exit_confirm.rs- Exit confirmation popupsrc/ui/admin_key_confirm.rs- Admin key confirmation popupsrc/ui/helpers.rs- UI helper functions (scrollbar, chat list building)New Order Utilities
src/util/order_utils/execute_admin_settle.rs- AdminSettle actionsrc/util/order_utils/execute_admin_cancel.rs- AdminCancel actionsrc/util/order_utils/execute_admin_add_solver.rs- Add solver functionalitysrc/util/order_utils/execute_take_dispute.rs- Take dispute actionDatabase Changes
initiator_infoandcounterpart_infotoadmin_disputestableUserInfostructs as JSON stringsUserInfofrom databaseState Management
AdminMode::ManagingDispute- Active dispute managementAdminMode::ReviewingDisputeForFinalization- Finalization popupAdminMode::WaitingDisputeFinalization- Waiting for responseConfirmExit- Exit confirmationChatSenderenum (Admin, Buyer, Seller)DisputeChatMessagestruct (sender, content, timestamp)admin_dispute_chatsHashMap for per-dispute storageadmin_chat_list_statefor chat message navigation📚 Documentation
New Documentation Files
docs/ADMIN_DISPUTES.md- Comprehensive admin dispute management guide (987 lines)docs/FINALIZE_DISPUTES.md- Dispute finalization workflow guide (233 lines)docs/CODING_STANDARDS.md- Code quality guidelines (208 lines)docs/DATABASE.md- Database schema and persistence (376 lines)docs/KEY_MANAGEMENT.md- Key derivation and management (103 lines)docs/MESSAGE_FLOW_AND_PROTOCOL.md- Protocol interactions (485 lines)docs/RANGE_ORDERS.md- Range order implementation (100 lines)docs/SETTINGS_ANALYSIS.md- Settings feature analysis (266 lines)docs/STARTUP_AND_CONFIG.md- Startup sequence and configuration (237 lines)docs/TUI_INTERFACE.md- TUI architecture and navigation (260 lines)Updated Documentation
README.md- Updated with new features and documentation linksTest Coverage
🔧 Configuration Changes
settings.tomlUpdatesuser_modefield (persisted at runtime)ws://andwss://)🐛 Bug Fixes
ws://andwss://protocols🚀 Migration Guide
For Existing Users
Database Migration: The database will automatically migrate to add new columns (
initiator_info,counterpart_info) to theadmin_disputestable.Settings Update: If you're using admin mode, ensure your
settings.tomlhas:New Keyboard Shortcuts:
🔮 Future Enhancements
📝 Related Issues
This PR implements the admin dispute management system as outlined in the project roadmap, providing a complete solution for dispute resolution within the Mostrix TUI client.
👥 Review Notes
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Chores
✏️ Tip: You can customize this high-level summary in your review settings.