Skip to content

Step 2 - Admin Dispute Management & Chat Interface#26

Merged
grunch merged 24 commits into
mainfrom
admin-take-dispute
Jan 26, 2026
Merged

Step 2 - Admin Dispute Management & Chat Interface#26
grunch merged 24 commits into
mainfrom
admin-take-dispute

Conversation

@arkanoider

@arkanoider arkanoider commented Jan 22, 2026

Copy link
Copy Markdown
Collaborator

🎯 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

  • Disputes Pending Tab: Browse and take disputes from the Mostro network
  • Disputes in Progress Tab: Manage active disputes with detailed information display
  • Sidebar Navigation: Quick dispute selection with Up/Down arrow keys
  • Real-time Updates: Dispute information updates dynamically when selecting different disputes

2. Interactive Chat System

  • Dual-Party Chat: Switch between Buyer and Seller chat views with Tab key
  • Color-Coded Messages:
    • Cyan (▶) for Admin messages
    • Green (◀) for Buyer messages
    • Red (◀) for Seller messages
  • Dynamic Input Box: Automatically grows from 1 to 10 lines based on message length
  • Text Wrapping: Intelligent word-boundary wrapping with trim behavior
  • Input Toggle: Press Shift+I to enable/disable chat input (prevents accidental typing)
  • Chat Scrolling:
    • PageUp/PageDown to navigate through message history
    • End key to jump to bottom (latest messages)
    • Visual scrollbar on the right shows position (↑/↓/│/█ symbols)
  • Per-Dispute Storage: Each dispute maintains its own complete message history
  • Party Filtering: Only shows messages from the active chat party

3. Enhanced Dispute Information Display

  • Comprehensive Header (8 lines):
    • Dispute ID, Type, Status
    • Initiator role (Buyer/Seller) with truncated pubkey
    • Creation date
    • Amount in satoshis and fiat currency
    • Privacy indicators (🟢 info available / 🔴 private)
    • User ratings with operating days for both parties
  • Party Information: Full details for buyer and seller including:
    • Truncated pubkeys
    • Privacy status
    • Average rating, total reviews, operating days
  • Financial Details: Amount, fiat amount, premium, fees

4. Dispute Finalization

  • Finalization Popup: Complete dispute review before taking action
  • Three Actions:
    • Pay Buyer (AdminSettle): Settles dispute in favor of buyer
    • Refund Seller (AdminCancel): Cancels order and refunds seller
    • Exit: Return to dispute management without action
  • Keyboard Navigation: Left/Right arrows to select action, Enter to execute
  • Protocol Integration: Sends Action::AdminSettle or Action::AdminCancel via encrypted DMs

5. Exit Confirmation

  • Safety Feature: Prevents accidental application exit
  • Confirmation Popup: Shows when pressing Q or selecting Exit tab
  • Visual Feedback: Green "✓ YES" and red "✗ NO" buttons
  • Navigation: Left/Right to select, Enter to confirm, Esc to cancel

6. Settings Enhancements

  • Admin Key Confirmation: Popup for admin key changes with persistence
  • Dynamic Currency Filters: Add/remove currency filters for order filtering
  • Relay Management: Add/remove Nostr relays with validation
  • Mode Persistence: User/Admin mode selection persisted to settings.toml

🏗️ Technical Changes

Code Organization

  • Modular Key Handler: Split key_handler.rs (1014 lines) into focused modules:
    • key_handler/mod.rs - Main dispatcher
    • key_handler/navigation.rs - Tab and list navigation
    • key_handler/enter_handlers.rs - Enter key actions
    • key_handler/esc_handlers.rs - Escape key handling
    • key_handler/form_input.rs - Form input handling
    • key_handler/input_helpers.rs - Input utilities
    • key_handler/settings.rs - Settings-specific handlers
    • key_handler/validation.rs - Input validation
    • key_handler/confirmation.rs - Confirmation popups

New UI Components

  • src/ui/disputes_in_progress_tab.rs - Main dispute management UI with chat
  • src/ui/dispute_finalization_popup.rs - Finalization popup rendering
  • src/ui/exit_confirm.rs - Exit confirmation popup
  • src/ui/admin_key_confirm.rs - Admin key confirmation popup
  • src/ui/helpers.rs - UI helper functions (scrollbar, chat list building)

New Order Utilities

  • src/util/order_utils/execute_admin_settle.rs - AdminSettle action
  • src/util/order_utils/execute_admin_cancel.rs - AdminCancel action
  • src/util/order_utils/execute_admin_add_solver.rs - Add solver functionality
  • src/util/order_utils/execute_take_dispute.rs - Take dispute action

Database Changes

  • New Columns: Added initiator_info and counterpart_info to admin_disputes table
  • Migration Support: Automatic migration for existing databases
  • JSON Serialization: Stores UserInfo structs as JSON strings
  • Deserialization: Helper functions to restore UserInfo from database

State Management

  • New UI Modes:
    • AdminMode::ManagingDispute - Active dispute management
    • AdminMode::ReviewingDisputeForFinalization - Finalization popup
    • AdminMode::WaitingDisputeFinalization - Waiting for response
    • ConfirmExit - Exit confirmation
  • Chat Data Structures:
    • ChatSender enum (Admin, Buyer, Seller)
    • DisputeChatMessage struct (sender, content, timestamp)
    • admin_dispute_chats HashMap for per-dispute storage
  • List State: admin_chat_list_state for 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 links
  • All markdown files formatted to pass linting (blank lines, code blocks, trailing whitespace)

Test Coverage

  • Database migrations and schema changes
  • Input validation (pubkeys, relays, currencies)
  • Helper functions and utilities
  • Parsing logic for various data structures

🔧 Configuration Changes

settings.toml Updates

  • Added support for user_mode field (persisted at runtime)
  • Admin key validation and confirmation flow
  • Enhanced relay validation (accepts both ws:// and wss://)

🐛 Bug Fixes

  • Fixed Up/Down arrow navigation in Disputes in Progress tab
  • Fixed tab switching when in ManagingDispute mode
  • Fixed relay validation to accept both ws:// and wss:// protocols
  • Fixed navigation limits using constants instead of hardcoded values
  • Fixed admin key change validation in settings
  • Fixed user role switching logic

🚀 Migration Guide

For Existing Users

  1. Database Migration: The database will automatically migrate to add new columns (initiator_info, counterpart_info) to the admin_disputes table.

  2. Settings Update: If you're using admin mode, ensure your settings.toml has:

    user_mode = "admin"  # or "user"
    admin_privkey = "your_admin_key_here"
  3. New Keyboard Shortcuts:

    • Shift+I: Toggle chat input enabled/disabled (in Disputes in Progress tab)
    • End: Jump to bottom of chat (latest messages)
    • Q: Shows exit confirmation (no longer exits immediately)

🔮 Future Enhancements

  • Real Nostr DM integration (currently uses mockup responses for testing)
  • Message persistence across application restarts
  • Export dispute chat history

📝 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

  • The chat system currently uses mockup responses for testing UI/UX. Real Nostr DM integration is marked with TODO comments and ready for implementation.
  • All keyboard shortcuts are documented in the relevant documentation files.
  • The codebase has been significantly refactored for maintainability, splitting large files into focused modules.

Summary by CodeRabbit

  • New Features

    • Admin dispute workflows: Disputes Pending & Disputes In‑Progress tabs, per‑dispute workspace, finalization popup (Pay Buyer / Refund Seller), exit confirmation, and Exit tab with ASCII logo.
    • Admin actions: take, settle, and cancel dispute flows with daemon messaging and async handling.
    • Real‑time per‑dispute chat (Admin/Buyer/Seller) with history, party filtering, persistence, scrollbar, dynamic multi‑line input, and keyboard navigation.
    • Faster UI refresh for snappier updates.
  • Bug Fixes

    • Safe, idempotent DB migrations on startup.
    • Settings initialization reports recoverable errors instead of panicking.
    • Dispute finalization safeguards to prevent actions on finalized disputes.
  • Documentation

    • New/expanded docs for admin disputes, finalization, UI flows, startup/migrations, and key management.
  • Chores

    • Dependency bumps and relay validation test updates.

✏️ Tip: You can customize this high-level summary in your review settings.

- 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
@coderabbitai

coderabbitai Bot commented Jan 22, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s) Summary
Deps
Cargo.toml
Bumped Rust version; mostro-core0.7.0; upgraded toml and lightning-invoice.
DB & Migrations
src/db.rs, docs/DATABASE.md, docs/STARTUP_AND_CONFIG.md
Add migrate_db run on startup; idempotent ALTERs adding initiator_info, counterpart_info, fiat_code, dispute_id; populate existing dispute_id.
Models
src/models.rs
Add public AdminDispute struct, constructor/upsert/get helpers, status setters, JSON (de)serialize helpers; switch to prelude and use NOSTR_ORDER_EVENT_KIND.
Admin order utils
src/util/order_utils/*.rs, src/util/order_utils/mod.rs
New async admin actions & re-exports: execute_take_dispute, execute_admin_settle, execute_admin_cancel, execute_finalize_dispute (Nostr DM flows, response handling, DB updates).
UI core & state
src/ui/mod.rs, src/ui/helpers.rs, src/ui/tab_content.rs
New UI modules (finalization confirm/popup, disputes_in_progress_tab, exit_confirm); add chat types; extend AppState with chat/dispute fields; chat persistence & render helpers; Exit tab rendering.
Dispute UI
src/ui/disputes_tab.rs, src/ui/disputes_in_progress_tab.rs, src/ui/dispute_finalization_popup.rs, src/ui/dispute_finalization_confirm.rs, src/ui/exit_confirm.rs
Rename Disputes → Disputes Pending; add Disputes In Progress two‑pane layout, header, party tabs, chat history, dynamic multiline input, finalization popup/confirm, exit confirm.
UI helpers & chat
src/ui/helpers.rs, src/ui/key_handler/chat_helpers.rs
Chat persistence to ~/.mostrix/<dispute_id>.txt; build styled chat items with color/alignment; scrollbar rendering; visible-count and navigation helpers.
Key handling & navigation
src/ui/key_handler/*.rs, src/ui/key_handler/mod.rs
New modules: admin_handlers, user_handlers, message_handlers, chat_helpers; prioritized admin chat input; extended ESC/ENTER/arrow/page handling; handle_key_event signature adjusted to accept PublicKey.
Main & init
src/main.rs
Preload admin in‑progress disputes at startup for Admin users; refresh disputes on dispute events; UI refresh interval → 150ms; ui_draw now takes &mut AppState.
Filters & DM utils
src/util/filters.rs, src/util/dm_utils/mod.rs
Simplify create_seven_days_filter(kind, pubkey); replace SETTINGS.get().unwrap() with guarded retrieval returning an error when missing.
Finalize docs & TUI docs
docs/FINALIZE_DISPUTES.md, docs/ADMIN_DISPUTES.md, docs/TUI_INTERFACE.md, docs/KEY_MANAGEMENT.md
New/expanded docs covering finalization workflow, admin chat UX, migrations, key derivation note, and TUI changes.
Tests
tests/validation_tests.rs
Update relay validation tests to accept ws:// (trimmed) and reject http://.

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)
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • grunch
  • Catrya
  • AndreaDiazCorreia

Poem

🐇 I nibbled through migrations, chats, and tabs,
Signed DMs hopped like carrots in my grabs.
Popups bloom, scrollbars hum, disputes settle soon,
Admins and rabbits chat beneath the moon.
A tiny patch — calm fields by afternoon.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Step 2 - Admin Dispute Management & Chat Interface' clearly and specifically summarizes the main changes: admin dispute management features and a TUI chat interface. It directly relates to the substantial code additions for disputes, chat, and admin workflows.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arkanoider arkanoider changed the title Admin Dispute Management & Chat Interface Step 2 - Admin Dispute Management & Chat Interface Jan 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 constraint

The 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: Redundant default_mode calculation.

The default_mode is already computed at the top of handle_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 ManagingDispute mode should only be active when on the DisputesInProgress tab (as set in handle_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 Tab and BackTab toggle 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 Block widget with title "Exit" and identical styling is constructed twice: once at lines 41-45 to compute inner_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 InvoiceInputState and KeyInputState into scope, which is used in the function signatures (lines 57, 63). However, the trait implementations at lines 9 and 18 still use the fully qualified crate::ui::InvoiceInputState and crate::ui::KeyInputState paths. 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-172 for Shift+I toggle and mod.rs:168-172 for handle_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_rect function duplicates helpers::create_centered_popup but 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 the Action variant 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;

Comment thread docs/ADMIN_DISPUTES.md Outdated
Comment thread docs/ADMIN_DISPUTES.md
Comment thread src/db.rs
Comment thread src/main.rs Outdated
Comment thread src/ui/dispute_finalization_popup.rs
Comment thread src/ui/dispute_finalization_popup.rs Outdated
Comment thread src/ui/disputes_in_progress_tab.rs Outdated
Comment thread src/ui/key_handler/enter_handlers.rs Outdated
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Consider Wrap { trim: true } for the Paragraph.

♻️ 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

Comment on lines +93 to +134
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"
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is None, is_initiator_buyer still 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: Existing unwrap() on checked_sub_signed could panic.

While unlikely with reasonable since_time values, checked_sub_signed returns None on overflow. Consider using unwrap_or_else with 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: Redundant default_mode computation.

default_mode is 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;
     }

Comment thread src/db.rs
Comment on lines +113 to +136
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())
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 -A5

Repository: 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.rs

Repository: MostroP2P/mostrix

Length of output: 74


🏁 Script executed:

cat -n src/db.rs

Repository: 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 -20

Repository: 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 -10

Repository: 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 -20

Repository: 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.

Suggested change
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.

Comment thread src/ui/dispute_finalization_popup.rs
Comment thread src/ui/disputes_in_progress_tab.rs Outdated
Comment on lines +76 to +90
// 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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment thread src/ui/disputes_in_progress_tab.rs Outdated
Comment on lines +122 to +134
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment thread src/ui/key_handler/confirmation.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.rs now, and the Q shortcut 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_enabled and admin_chat_list_state persist across role switches, and selected_dispute_idx is 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 of solver_pubkey.

solver_pubkey is already an owned String passed 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 other handle_enter_* functions in this file.

Comment thread docs/FINALIZE_DISPUTES.md Outdated
Comment thread src/ui/helpers.rs

@Catrya Catrya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @arkanoider great job!
These are the details I’ve found:

  1. The fiat currency of the order is missing. The admin has no way to know which currency is being traded.
Image
  1. 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.
Image
  1. 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).

  2. 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.

Image
  1. 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-do response and doesn’t make sense.
Image
  1. 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-do response and don’t make sense.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ui/helpers.rs Outdated
…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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_role clears some fields but leaves the new admin chat/dispute state intact and duplicates selected_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_exists helper only handles "no such column" errors. For databases that existed before the admin_disputes table 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 increments lines by 1 and sets current_width = word_width. However, if word_width > available_width, this doesn't account for the word wrapping across multiple lines in ratatui's Paragraph widget with Wrap { 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 in src/ui/helpers.rs to 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 InProgress disputes 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 existing create_centered_popup helper.

This center_rect function duplicates helpers::create_centered_popup (shown in relevant snippets from src/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 with super::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 cloned dispute_info_clone (line 106) before calling AdminDispute::new(), which should persist with that status. Then it immediately calls set_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), the update_db method 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.id is the order ID (not the dispute UUID), which is correct since that's the primary key in admin_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_count calculation with the filter closure (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 to None silently drops errors. If UserInfo fails 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 DisputesPending has multiple redundant checks:

  1. Line 152: checks > 0 before decrementing
  2. Line 154-156: else sets to 0 (but if already 0, this is a no-op)
  3. Lines 158-160: clamps with .min() again

The 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. Since ListItem can 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.

Comment thread src/ui/dispute_finalization_popup.rs Outdated
Comment on lines +127 to +132
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment thread src/ui/key_handler/enter_handlers.rs
Comment thread src/ui/key_handler/enter_handlers.rs
- 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
@arkanoider

arkanoider commented Jan 25, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @arkanoider great job! These are the details I’ve found:

  1. The fiat currency of the order is missing. The admin has no way to know which currency is being traded.
Image 2. 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. Image 3. 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). 4. 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. Image 5. 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-do` response and doesn’t make sense. Image 6. 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-do` response and don’t make sense.

Hacker! This things should be fixed, I will see rabbit rants tomorrow, when you have some time take a look!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of selected_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_pubkey is "Unknown" (line 136), the comparison dispute.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 Option values 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_unknown is 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 lines by 1 when a word doesn't fit, but doesn't account for words wider than available_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_string fails, the error is silently swallowed via .ok(), and None is 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. Using DisputeStatus::InProgress.to_string() (as done in execute_take_dispute.rs line 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_settled and set_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 InProgress in the cloned dispute_info_clone (line 106), then AdminDispute::new is called which persists this status. The subsequent call to set_status_in_progress (lines 119-127) is redundant since the record was just created/updated with InProgress status.

The comment mentions "in case of update path", but AdminDispute::new already 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.id but the comparison on line 96 uses *id (from the payload). While both should be the same value, using id in 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, and std::str::FromStr is 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_idx in 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 PayInvoice match 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 since current.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 DisputesInProgress is duplicated between AdminMode::Normal (lines 168-171) and AdminMode::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 usize then .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_items is always true when >= total_items.saturating_sub(1) is true (for total_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 to None. 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?

Comment thread src/ui/dispute_finalization_popup.rs
Comment thread src/ui/key_handler/message_handlers.rs Outdated
Comment thread src/util/order_utils/execute_finalize_dispute.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 like OrderResult::DisputeTaken).

src/ui/dispute_finalization_popup.rs (1)

125-130: Initiator detection still unreliable when buyer_pubkey is unknown.

When buyer_pubkey is "Unknown", the comparison dispute.initiator_pubkey == buyer_pubkey on line 128 will always be false, incorrectly marking the seller as the initiator. This was noted in past reviews.

Consider comparing against the original Option values:

🔧 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_width still increment lines by 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_string fails, the error is silently swallowed with .ok(), resulting in None being 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, and set_status_seller_refunded methods 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: Duplicate center_rect helper function.

This center_rect function duplicates the logic in src/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_role clears chat input and resets selection, but leaves admin_chat_input_enabled, admin_chat_list_state, and dispute_filter untouched. 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;

Comment thread src/ui/dispute_finalization_popup.rs
Comment thread src/ui/dispute_finalization_popup.rs
Comment thread src/ui/helpers.rs
Comment thread src/ui/key_handler/mod.rs
Comment thread src/ui/key_handler/navigation.rs
- 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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_width that 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_pubkey and seller_pubkey at 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 else branch at line 163-164 is redundant since if app.selected_dispute_idx > 0 is 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_code to "USD". This could lead to incorrect currency display in the admin UI if the actual dispute involves a different fiat currency.

Consider logging at warn level instead of debug since 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 in set_status_in_progress.

The parameter is named dispute_id but is used in WHERE id = ?, where id is the order ID (primary key). The other status methods (set_status_settled, set_status_seller_refunded) correctly name their parameter order_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_id exists, 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))?;

Comment thread src/ui/disputes_in_progress_tab.rs
Comment thread src/ui/disputes_in_progress_tab.rs
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_disputes is missing the fiat_code column, 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 include fiat_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_code and dispute_id columns, which are mentioned in the Database Migrations section (lines 48-81) as columns added via migration.

This omission is critical because:

  1. fiat_code relates to reviewer issue #1 (missing fiat currency label)
  2. 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_code field in the DATABASE.md schema.

Comment thread docs/ADMIN_DISPUTES.md Outdated
Comment thread docs/FINALIZE_DISPUTES.md Outdated
Comment thread docs/FINALIZE_DISPUTES.md Outdated
Comment thread docs/TUI_INTERFACE.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_amount as "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_or requires Rust 1.82.0 - verify MSRV.

The is_none_or method on Option was stabilized in Rust 1.82.0 (October 2024). The project's Cargo.toml doesn't specify a rust-version, which may cause compilation failures on older Rust versions.

Either add rust-version = "1.82.0" to Cargo.toml or 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_width as 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. If UserInfo fails to serialize (e.g., due to invalid data), the field becomes None and 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()
+            });

Comment thread docs/ADMIN_DISPUTES.md
Comment thread src/models.rs Outdated

@grunch grunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

dispute status text color is yellow with green as background is impossible to read

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants