Skip to main content

teamctl_ui/
mailbox.rs

1//! Mailbox-pane data source and tab definitions.
2//!
3//! The Triptych mailbox presents **two** tabs, organised by direction
4//! rather than channel type (#462):
5//!
6//! - `Inbox` — everything inbound to the focused agent, time-merged:
7//!   DMs (`recipient = '<project>:<agent>'`) plus inbound channel and
8//!   wire traffic (`sender != '<project>:<agent>'`, so a broadcast the
9//!   agent itself sent shows under Sent, not here).
10//! - `Sent` — every row whose `sender = '<project>:<agent>'`,
11//!   irrespective of recipient class. Sender-side, so it already
12//!   catches outbound DMs, channel posts, and broadcasts.
13//!
14//! Four data shapes still back these tabs — the SQL fetch is unchanged
15//! (`inbox`, `sent`, `channel_feed`, `wire`); only the tab *surface*
16//! folds them by direction. The `Channel` and `Wire` `MailboxTab`
17//! variants survive as **internal** buffer keys (they're dropped from
18//! [`MailboxTab::ALL`], so they're never user-navigable tabs) because
19//! the channel buffer still feeds the MailboxFirst layout's channel
20//! feed and the Inbox merge:
21//!
22//! - `Channel` — channel traffic for channels the focused agent is
23//!   a member of (recipient is `'channel:<channel_id>'`, filtered
24//!   through `channel_members`).
25//! - `Wire` — project-wide broadcast traffic on the `all` channel
26//!   (`recipient = 'channel:<project>:all'`).
27//!
28//! INVARIANT: every `messages.recipient` value falls into exactly
29//! one of three prefix classes — `<project>:<agent>` (DM, no scheme
30//! prefix; the channel-or-user split below depends on this absence),
31//! `channel:<channel_id>`, or `user:<handle>`. `data::mailbox_counts`
32//! relies on the same contract when it filters out channel/user rows
33//! for the per-agent unread-mail counter; if a fourth prefix class
34//! ever lands, the comment there and the queries here both need to
35//! learn it. Sent is the one tab whose filter is sender-side and
36//! recipient-class-agnostic — it returns rows from all three
37//! recipient prefix classes.
38
39use std::borrow::Cow;
40use std::path::PathBuf;
41
42use anyhow::Result;
43use rusqlite::{params, Connection};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum MailboxTab {
47    Inbox,
48    Sent,
49    Channel,
50    Wire,
51}
52
53impl MailboxTab {
54    /// The user-navigable tabs, in tab-bar order. Channel and Wire are
55    /// deliberately absent (#462): their traffic folds into Inbox by
56    /// direction, but the variants survive as internal buffer keys.
57    pub const ALL: [MailboxTab; 2] = [MailboxTab::Inbox, MailboxTab::Sent];
58
59    pub fn label(self) -> &'static str {
60        match self {
61            MailboxTab::Inbox => "Inbox",
62            MailboxTab::Sent => "Sent",
63            MailboxTab::Channel => "Channel",
64            MailboxTab::Wire => "Wire",
65        }
66    }
67
68    pub fn empty_hint(self) -> &'static str {
69        match self {
70            MailboxTab::Inbox => "(no DMs)",
71            MailboxTab::Sent => "(no sent messages)",
72            MailboxTab::Channel => "(no channel traffic)",
73            MailboxTab::Wire => "(quiet)",
74        }
75    }
76
77    pub fn next(self) -> Self {
78        match self {
79            MailboxTab::Inbox => MailboxTab::Sent,
80            MailboxTab::Sent => MailboxTab::Inbox,
81            // Channel/Wire are internal-only (folded into Inbox, never
82            // the active tab); map them to Inbox defensively so cycling
83            // can never strand the cursor on a non-tab.
84            MailboxTab::Channel | MailboxTab::Wire => MailboxTab::Inbox,
85        }
86    }
87
88    pub fn prev(self) -> Self {
89        match self {
90            MailboxTab::Inbox => MailboxTab::Sent,
91            MailboxTab::Sent => MailboxTab::Inbox,
92            MailboxTab::Channel | MailboxTab::Wire => MailboxTab::Inbox,
93        }
94    }
95}
96
97#[derive(Debug, Clone)]
98pub struct MessageRow {
99    pub id: i64,
100    pub sender: String,
101    pub recipient: String,
102    pub text: String,
103    pub sent_at: f64,
104}
105
106/// Format a single row for the mailbox pane. Kept terse: prefix in
107/// brackets + one-line body. Multi-line bodies are flattened with a
108/// space so a single message stays one row in the pane.
109///
110/// Prefix is tab-aware (T-231, #462):
111///
112/// - **Inbox** → `[<senderName>]` for a DM, or `[#channel] [<sender>]`
113///   for a folded-in channel/wire row (recipient `channel:…`). The
114///   channel segment keeps the disambiguator-first labelling (T-249)
115///   so a broadcast stays distinct from a direct message now that both
116///   share the Inbox.
117/// - **Sent** → `[→<recipientName>]`. Sender on a Sent row is
118///   always the focused agent (that's the filter), so showing it is
119///   redundant. Operators want to see WHO the agent talked to;
120///   recipient resolution goes through
121///   [`crate::data::recipient_label`] which handles agent,
122///   `channel:`, and `user:` recipient shapes.
123/// - **Channel** (internal — the MailboxFirst feed) → `[#channel]
124///   [<sender>]`, the two-segment T-249 shape.
125pub fn render_row(row: &MessageRow, team: &crate::data::TeamSnapshot, tab: MailboxTab) -> String {
126    let one_line: String = row
127        .text
128        .replace('\n', " ")
129        .replace('\r', "")
130        .chars()
131        .take(180)
132        .collect();
133    match tab {
134        MailboxTab::Sent => {
135            let recipient = crate::data::recipient_label(team, &row.recipient);
136            format!("[→{recipient}] {one_line}")
137        }
138        MailboxTab::Inbox => {
139            // #462: the Inbox folds inbound DMs + channel + wire. A
140            // channel/wire row (recipient `channel:…`) keeps the T-249
141            // disambiguator-first labelling — `[#channel] [sender]` — so
142            // it stays distinguishable from a direct DM (`[sender]`).
143            // `recipient_label` maps `channel:<p>:<n>` to `#<n>`.
144            if row.recipient.starts_with("channel:") {
145                let channel = crate::data::recipient_label(team, &row.recipient);
146                let sender = crate::data::agent_label(team, &row.sender);
147                format!("[{channel}] [{sender}] {one_line}")
148            } else {
149                let sender = crate::data::agent_label(team, &row.sender);
150                format!("[{sender}] {one_line}")
151            }
152        }
153        MailboxTab::Channel => {
154            // T-249, still used by the MailboxFirst channel feed: two
155            // bracketed segments — channel, then sender — so operators
156            // can tell `#all` from `#dev` from `#docs` in a rolled-up
157            // feed. `recipient_label` maps `channel:<p>:<n>` to `#<n>`.
158            let channel = crate::data::recipient_label(team, &row.recipient);
159            let sender = crate::data::agent_label(team, &row.sender);
160            format!("[{channel}] [{sender}] {one_line}")
161        }
162        MailboxTab::Wire => {
163            // Internal variant, not a user tab; kept for exhaustiveness.
164            // Same sender-only shape it always had.
165            let sender = crate::data::agent_label(team, &row.sender);
166            format!("[{sender}] {one_line}")
167        }
168    }
169}
170
171/// T-131 PR-4: short absolute-datetime stamp for the right-side
172/// mailbox-row indicator. Computed every render from `now_secs`
173/// (clock reading at render time) and the row's `sent_at` (epoch
174/// seconds). Format is **today-folded** to save column budget on
175/// the common case:
176///
177/// - same calendar day in the operator's local timezone → `HH:MM`
178///   (24-hour, 5 chars; e.g. `15:42`).
179/// - any earlier day → `%b %d %H:%M` (12 chars; e.g. `May 22 15:42`).
180///
181/// Variants ratified by owner (tg 3388):
182/// - (1) today-vs-not folding: YES.
183/// - (2) 24-hour clock: YES.
184///
185/// Silent defaults preserved: no seconds; local-to-operator TZ
186/// (the detail modal already shows UTC for the precise reference);
187/// past-day format `%b %d %H:%M`.
188///
189/// Production callers use [`row_timestamp`] (wraps `Local`); tests
190/// drive [`row_timestamp_in`] with `chrono::Utc` for determinism.
191pub fn row_timestamp(now_secs: f64, sent_at: f64) -> String {
192    row_timestamp_in(&chrono::Local, now_secs, sent_at)
193}
194
195/// TZ-injected variant of [`row_timestamp`] — keeps the production
196/// path on `Local` while tests pin behaviour with `Utc`.
197pub fn row_timestamp_in<Tz>(tz: &Tz, now_secs: f64, sent_at: f64) -> String
198where
199    Tz: chrono::TimeZone,
200    Tz::Offset: std::fmt::Display,
201{
202    let Some(now) = tz.timestamp_opt(now_secs as i64, 0).single() else {
203        return "—".to_string();
204    };
205    let Some(sent) = tz.timestamp_opt(sent_at as i64, 0).single() else {
206        return "—".to_string();
207    };
208    if now.date_naive() == sent.date_naive() {
209        sent.format("%H:%M").to_string()
210    } else {
211        sent.format("%b %d %H:%M").to_string()
212    }
213}
214
215/// T-131 PR-3: human-readable kind label for the detail modal.
216/// Derived from the recipient shape — the same prefix classes the
217/// module-doc INVARIANT pins (`<project>:<agent>` DM,
218/// `channel:<project>:all` wire, other `channel:` channel,
219/// `user:` DM-from-or-to-a-user).
220pub fn kind_label(row: &MessageRow) -> &'static str {
221    if let Some(rest) = row.recipient.strip_prefix("channel:") {
222        // `channel:<project>:all` is the project-wide wire; anything
223        // else under `channel:` is a named channel.
224        if rest.ends_with(":all") {
225            "wire broadcast"
226        } else {
227            "channel broadcast"
228        }
229    } else {
230        // Agent id (`<project>:<agent>`) or `user:<handle>` —
231        // either way, a directed message.
232        "DM"
233    }
234}
235
236/// T-131 PR-3: best-effort transport / origin label for the detail
237/// modal. Heuristic from the sender prefix (variant (b) locked):
238///
239/// - `user:telegram` → "via telegram" — by far the most common
240///   non-agent origin, worth its own label.
241/// - any other `user:<handle>` → "via user" — DMs from a different
242///   human-facing adapter, future-proof against new `user:*` shapes.
243/// - agent id (`<project>:<agent>`) → "via mcp" — every agent emits
244///   through the MCP broker.
245/// - else → "—" (unparseable / future schema).
246pub fn transport_label(row: &MessageRow) -> &'static str {
247    if row.sender.starts_with("user:telegram") {
248        "via telegram"
249    } else if row.sender.starts_with("user:") {
250        "via user"
251    } else if row.sender.contains(':') {
252        "via mcp"
253    } else {
254        "—"
255    }
256}
257
258/// Lookup contract: each method returns rows newer than `after_id`
259/// for the given filter, in ascending id order. Callers fold the
260/// returned rows into a per-tab buffer and bump `after_id` to the
261/// last returned id.
262pub trait MailboxSource: Send + Sync {
263    fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
264    fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
265    fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
266    fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>>;
267}
268
269/// Production impl reading the broker SQLite at `<root>/state/mailbox.db`.
270/// Each call opens a fresh connection — `mailbox.db` is local and
271/// short-lived connections cost effectively zero.
272#[derive(Debug, Clone)]
273pub struct BrokerMailboxSource {
274    pub db_path: PathBuf,
275}
276
277impl BrokerMailboxSource {
278    pub fn new(db_path: PathBuf) -> Self {
279        Self { db_path }
280    }
281
282    fn open(&self) -> Result<Option<Connection>> {
283        if !self.db_path.is_file() {
284            return Ok(None);
285        }
286        let conn = Connection::open(&self.db_path)?;
287        Ok(Some(conn))
288    }
289}
290
291impl MailboxSource for BrokerMailboxSource {
292    fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
293        let Some(conn) = self.open()? else {
294            return Ok(Vec::new());
295        };
296        let mut stmt = conn.prepare(
297            "SELECT id, sender, recipient, text, sent_at FROM messages
298             WHERE id > ?1 AND recipient = ?2
299             ORDER BY id ASC",
300        )?;
301        let rows = stmt
302            .query_map(params![after_id, agent_id], |r| {
303                Ok(MessageRow {
304                    id: r.get(0)?,
305                    sender: r.get(1)?,
306                    recipient: r.get(2)?,
307                    text: r.get(3)?,
308                    sent_at: r.get(4)?,
309                })
310            })?
311            .flatten()
312            .collect();
313        Ok(rows)
314    }
315
316    fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
317        let Some(conn) = self.open()? else {
318            return Ok(Vec::new());
319        };
320        // Sender-side filter — every row the focused agent emitted,
321        // irrespective of recipient class. Returns DMs, telegram
322        // replies, channel posts, and wire broadcasts in a single
323        // stream.
324        let mut stmt = conn.prepare(
325            "SELECT id, sender, recipient, text, sent_at FROM messages
326             WHERE id > ?1 AND sender = ?2
327             ORDER BY id ASC",
328        )?;
329        let rows = stmt
330            .query_map(params![after_id, agent_id], |r| {
331                Ok(MessageRow {
332                    id: r.get(0)?,
333                    sender: r.get(1)?,
334                    recipient: r.get(2)?,
335                    text: r.get(3)?,
336                    sent_at: r.get(4)?,
337                })
338            })?
339            .flatten()
340            .collect();
341        Ok(rows)
342    }
343
344    fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
345        let Some(conn) = self.open()? else {
346            return Ok(Vec::new());
347        };
348        // Same shape as `teamctl tail <agent>`'s channel arm: rows
349        // whose recipient is a `channel:` URL the agent is a member
350        // of. Membership lives in `channel_members.agent_id =
351        // <project>:<agent>`.
352        let mut stmt = conn.prepare(
353            "SELECT id, sender, recipient, text, sent_at FROM messages
354             WHERE id > ?1
355               AND recipient IN (
356                   SELECT 'channel:' || cm.channel_id FROM channel_members cm
357                   WHERE cm.agent_id = ?2
358               )
359             ORDER BY id ASC",
360        )?;
361        let rows = stmt
362            .query_map(params![after_id, agent_id], |r| {
363                Ok(MessageRow {
364                    id: r.get(0)?,
365                    sender: r.get(1)?,
366                    recipient: r.get(2)?,
367                    text: r.get(3)?,
368                    sent_at: r.get(4)?,
369                })
370            })?
371            .flatten()
372            .collect();
373        Ok(rows)
374    }
375
376    fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
377        let Some(conn) = self.open()? else {
378            return Ok(Vec::new());
379        };
380        // The project-wide `all` channel is the broadcast wire.
381        // Channel ids are `<project>:<name>`; messages address them
382        // via `channel:<channel_id>`.
383        let target = format!("channel:{project_id}:all");
384        let mut stmt = conn.prepare(
385            "SELECT id, sender, recipient, text, sent_at FROM messages
386             WHERE id > ?1 AND recipient = ?2
387             ORDER BY id ASC",
388        )?;
389        let rows = stmt
390            .query_map(params![after_id, target], |r| {
391                Ok(MessageRow {
392                    id: r.get(0)?,
393                    sender: r.get(1)?,
394                    recipient: r.get(2)?,
395                    text: r.get(3)?,
396                    sent_at: r.get(4)?,
397                })
398            })?
399            .flatten()
400            .collect();
401        Ok(rows)
402    }
403}
404
405/// Per-agent buffer state — four tabs, four `after_id` cursors.
406/// Lives on `App` so swapping the focused agent resets the cursors
407/// without trying to back-fill: the operator sees only forward
408/// motion in the tab they're watching.
409#[derive(Debug, Default, Clone)]
410pub struct MailboxBuffers {
411    /// The focused agent's id (`<project>:<agent>`), set by
412    /// `refresh_mailbox`. The `sender != me` source for the Inbox
413    /// merge's inbound filter (#462) — kept here so `rows()` and the
414    /// cursor methods that read it don't have to thread `me` through
415    /// every signature. Empty until the first refresh; an empty id
416    /// means the filter is a no-op (one transient frame at most).
417    pub agent_id: String,
418    pub inbox: Vec<MessageRow>,
419    pub sent: Vec<MessageRow>,
420    pub channel: Vec<MessageRow>,
421    pub wire: Vec<MessageRow>,
422    pub inbox_after: i64,
423    pub sent_after: i64,
424    pub channel_after: i64,
425    pub wire_after: i64,
426    // T-131 PR-1: UI cursor state per tab. `selected_idx` is an index
427    // INTO `visible_indices(tab)`, not directly into `rows(tab)` — the
428    // two coincide when no filter/search is set; PR-2 made them
429    // diverge without changing this invariant or any call site (the
430    // composability payoff of returning `Vec<usize>` indices, not a
431    // slice).
432    pub inbox_cursor: CursorState,
433    pub sent_cursor: CursorState,
434    pub channel_cursor: CursorState,
435    pub wire_cursor: CursorState,
436    // T-131 PR-2: per-tab filter (sender substring) + search (body
437    // substring) text. Both compose: a row is visible iff it passes
438    // BOTH (empty = no-op on that axis). Mirrors the existing per-tab
439    // Vec + cursor pattern. Case-insensitive substring match.
440    pub inbox_filter: String,
441    pub sent_filter: String,
442    pub channel_filter: String,
443    pub wire_filter: String,
444    pub inbox_search: String,
445    pub sent_search: String,
446    pub channel_search: String,
447    pub wire_search: String,
448}
449
450/// Which mailbox input the operator is editing. Singleton at the App
451/// level (only one input can be open at a time across all tabs);
452/// distinct from the per-tab `filter_text` / `search_text` it targets,
453/// which live on [`MailboxBuffers`]. Defined here so the data-side
454/// methods (`input_push_char`, `input_pop_char`, etc.) can take it
455/// without crossing the App boundary.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum MailboxInputKind {
458    Filter,
459    Search,
460}
461
462/// UI cursor state for one mailbox tab. PR-1 stores only the selected
463/// row index; the rendered scroll-window is derived at render time
464/// from `selected_idx` + the actual pane height, so a terminal resize
465/// just changes the next-paint window without touching persisted
466/// state. `selected_idx` is an index into
467/// [`MailboxBuffers::visible_indices`].
468#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
469pub struct CursorState {
470    pub selected_idx: usize,
471}
472
473const MAX_TAB_ROWS: usize = 500;
474
475/// PageUp/PageDown jump size — a screen-ish chunk of rows. Fixed for
476/// PR-1 to keep scope surgical; a follow-up can wire this to the
477/// actual rendered mailbox-pane height once that's plumbed onto App.
478pub const PAGE_JUMP: usize = 10;
479
480impl MailboxBuffers {
481    /// Rows for `tab`, in ascending broker-id (receipt) order.
482    ///
483    /// Most tabs hand back a borrow of their backing buffer. `Inbox`
484    /// is the exception (#462): it returns an owned, time-merged view
485    /// of inbound DMs + channel + wire, so it allocates. `Cow` lets the
486    /// common borrowed case stay zero-copy while the merged case owns
487    /// its Vec; callers use the result as a slice either way (`Cow`
488    /// derefs to `[MessageRow]`).
489    pub fn rows(&self, tab: MailboxTab) -> Cow<'_, [MessageRow]> {
490        match tab {
491            MailboxTab::Inbox => Cow::Owned(self.merged_inbox()),
492            MailboxTab::Sent => Cow::Borrowed(&self.sent),
493            MailboxTab::Channel => Cow::Borrowed(&self.channel),
494            MailboxTab::Wire => Cow::Borrowed(&self.wire),
495        }
496    }
497
498    /// The Inbox feed (#462): inbound DMs + inbound channel + inbound
499    /// wire, merged and sorted ascending by broker `id` (receipt
500    /// order, the same order each buffer already holds). Inbound means
501    /// `sender != self.agent_id`, so a channel post or broadcast the
502    /// focused agent itself sent stays under Sent and never
503    /// double-shows here.
504    ///
505    /// **Dedup by id is load-bearing.** The project-wide `all` channel
506    /// is a real channel every agent joins (its `members: "*"`
507    /// registers all of them in `channel_members`), so a broadcast to
508    /// `channel:<project>:all` is returned by *both* the membership
509    /// query behind the `channel` buffer *and* the `wire` buffer — the
510    /// same message id via two paths. Without the dedup every wire
511    /// broadcast would render twice in the folded Inbox. Equal ids are
512    /// adjacent after the id-sort, so `dedup_by_key` collapses them.
513    fn merged_inbox(&self) -> Vec<MessageRow> {
514        let me = self.agent_id.as_str();
515        let mut rows: Vec<MessageRow> = self
516            .inbox
517            .iter()
518            .chain(self.channel.iter().filter(|r| r.sender != me))
519            .chain(self.wire.iter().filter(|r| r.sender != me))
520            .cloned()
521            .collect();
522        rows.sort_by_key(|r| r.id);
523        rows.dedup_by_key(|r| r.id);
524        rows
525    }
526
527    /// Indices into `rows(tab)` for the rows currently presented to
528    /// the operator — filter ∩ search. PR-2 swapped this body in;
529    /// every cursor method and the render call site stayed unchanged
530    /// from PR-1 because they go through this abstraction. A row at
531    /// `rows(tab)[i]` is visible iff:
532    ///
533    /// 1. `filter_text(tab)` is empty OR `row.sender` (lower-cased)
534    ///    contains the filter (lower-cased) as a substring.
535    /// 2. `search_text(tab)` is empty OR `row.text` (lower-cased)
536    ///    contains the search (lower-cased) as a substring.
537    ///
538    /// When both axes are empty, the result is identity
539    /// `(0..rows.len())` — PR-1's default behavior recovers exactly.
540    /// Case-insensitive substring is the documented contract; the
541    /// per-keystroke recompute on small (~500-row) buffers is well
542    /// within budget.
543    pub fn visible_indices(&self, tab: MailboxTab) -> Vec<usize> {
544        self.visible_indices_in(&self.rows(tab), tab)
545    }
546
547    /// Like [`visible_indices`] but against an already-materialised row
548    /// slice. The render path already holds `rows(tab)`, so it computes
549    /// visibility through this to avoid a second `rows()` call — for the
550    /// Inbox that call is non-trivial (it clones, sorts, and dedups the
551    /// merged feed, #462), and the per-frame render would otherwise pay
552    /// for the merge twice.
553    pub fn visible_indices_in(&self, rows: &[MessageRow], tab: MailboxTab) -> Vec<usize> {
554        let filter = self.filter_text(tab).to_lowercase();
555        let search = self.search_text(tab).to_lowercase();
556        if filter.is_empty() && search.is_empty() {
557            return (0..rows.len()).collect();
558        }
559        (0..rows.len())
560            .filter(|&i| {
561                let row = &rows[i];
562                (filter.is_empty() || row.sender.to_lowercase().contains(&filter))
563                    && (search.is_empty() || row.text.to_lowercase().contains(&search))
564            })
565            .collect()
566    }
567
568    /// Current sender-substring filter on `tab`; empty = no filter.
569    pub fn filter_text(&self, tab: MailboxTab) -> &str {
570        match tab {
571            MailboxTab::Inbox => &self.inbox_filter,
572            MailboxTab::Sent => &self.sent_filter,
573            MailboxTab::Channel => &self.channel_filter,
574            MailboxTab::Wire => &self.wire_filter,
575        }
576    }
577
578    /// Current body-substring search on `tab`; empty = no search.
579    pub fn search_text(&self, tab: MailboxTab) -> &str {
580        match tab {
581            MailboxTab::Inbox => &self.inbox_search,
582            MailboxTab::Sent => &self.sent_search,
583            MailboxTab::Channel => &self.channel_search,
584            MailboxTab::Wire => &self.wire_search,
585        }
586    }
587
588    fn filter_text_mut(&mut self, tab: MailboxTab) -> &mut String {
589        match tab {
590            MailboxTab::Inbox => &mut self.inbox_filter,
591            MailboxTab::Sent => &mut self.sent_filter,
592            MailboxTab::Channel => &mut self.channel_filter,
593            MailboxTab::Wire => &mut self.wire_filter,
594        }
595    }
596
597    fn search_text_mut(&mut self, tab: MailboxTab) -> &mut String {
598        match tab {
599            MailboxTab::Inbox => &mut self.inbox_search,
600            MailboxTab::Sent => &mut self.sent_search,
601            MailboxTab::Channel => &mut self.channel_search,
602            MailboxTab::Wire => &mut self.wire_search,
603        }
604    }
605
606    /// Push `c` onto the active input buffer for `tab`, then clamp
607    /// the cursor against the (possibly shorter) new visible_indices.
608    /// Called per-keystroke by the App input-mode handler.
609    pub fn input_push_char(&mut self, tab: MailboxTab, kind: MailboxInputKind, c: char) {
610        match kind {
611            MailboxInputKind::Filter => self.filter_text_mut(tab).push(c),
612            MailboxInputKind::Search => self.search_text_mut(tab).push(c),
613        }
614        self.clamp_cursor(tab);
615    }
616
617    /// Pop one character (Backspace) from the active input buffer for
618    /// `tab`, then re-clamp the cursor.
619    pub fn input_pop_char(&mut self, tab: MailboxTab, kind: MailboxInputKind) {
620        match kind {
621            MailboxInputKind::Filter => {
622                self.filter_text_mut(tab).pop();
623            }
624            MailboxInputKind::Search => {
625                self.search_text_mut(tab).pop();
626            }
627        }
628        self.clamp_cursor(tab);
629    }
630
631    /// Replace the active input buffer for `tab` wholesale — used by
632    /// the Esc-cancel-revert path to restore the pre-open snapshot.
633    pub fn set_input(&mut self, tab: MailboxTab, kind: MailboxInputKind, value: String) {
634        match kind {
635            MailboxInputKind::Filter => *self.filter_text_mut(tab) = value,
636            MailboxInputKind::Search => *self.search_text_mut(tab) = value,
637        }
638        self.clamp_cursor(tab);
639    }
640
641    /// Clamp the per-tab cursor to the current visible_indices range.
642    /// Called from every input mutation and from extend()'s drain
643    /// path so a stale `selected_idx` can never index past the
644    /// visible set.
645    fn clamp_cursor(&mut self, tab: MailboxTab) {
646        let len = self.visible_indices(tab).len();
647        let cur = self.cursor_mut(tab);
648        if len == 0 {
649            cur.selected_idx = 0;
650        } else if cur.selected_idx >= len {
651            cur.selected_idx = len - 1;
652        }
653    }
654
655    pub fn cursor(&self, tab: MailboxTab) -> &CursorState {
656        match tab {
657            MailboxTab::Inbox => &self.inbox_cursor,
658            MailboxTab::Sent => &self.sent_cursor,
659            MailboxTab::Channel => &self.channel_cursor,
660            MailboxTab::Wire => &self.wire_cursor,
661        }
662    }
663
664    fn cursor_mut(&mut self, tab: MailboxTab) -> &mut CursorState {
665        match tab {
666            MailboxTab::Inbox => &mut self.inbox_cursor,
667            MailboxTab::Sent => &mut self.sent_cursor,
668            MailboxTab::Channel => &mut self.channel_cursor,
669            MailboxTab::Wire => &mut self.wire_cursor,
670        }
671    }
672
673    /// Move the cursor one row toward the tail; clamps at the last
674    /// visible row (vim-like — no wrap).
675    pub fn move_cursor_down(&mut self, tab: MailboxTab) {
676        let max = self.visible_indices(tab).len().saturating_sub(1);
677        let c = self.cursor_mut(tab);
678        c.selected_idx = (c.selected_idx + 1).min(max);
679    }
680
681    /// Move the cursor one row toward the head; clamps at 0.
682    pub fn move_cursor_up(&mut self, tab: MailboxTab) {
683        let c = self.cursor_mut(tab);
684        c.selected_idx = c.selected_idx.saturating_sub(1);
685    }
686
687    /// Jump a screen toward the tail.
688    pub fn page_cursor_down(&mut self, tab: MailboxTab) {
689        let max = self.visible_indices(tab).len().saturating_sub(1);
690        let c = self.cursor_mut(tab);
691        c.selected_idx = (c.selected_idx + PAGE_JUMP).min(max);
692    }
693
694    /// Jump a screen toward the head.
695    pub fn page_cursor_up(&mut self, tab: MailboxTab) {
696        let c = self.cursor_mut(tab);
697        c.selected_idx = c.selected_idx.saturating_sub(PAGE_JUMP);
698    }
699
700    /// Jump to the first visible row.
701    pub fn cursor_home(&mut self, tab: MailboxTab) {
702        self.cursor_mut(tab).selected_idx = 0;
703    }
704
705    /// Jump to the last visible row.
706    pub fn cursor_end(&mut self, tab: MailboxTab) {
707        let max = self.visible_indices(tab).len().saturating_sub(1);
708        self.cursor_mut(tab).selected_idx = max;
709    }
710
711    /// Whether the Inbox cursor is at (or past) the tail of the merged
712    /// view — i.e. the operator is following new arrivals. Captured
713    /// before a refresh's extends so [`follow_inbox_tail`] can re-anchor
714    /// the cursor afterwards: per-tab `extend` only follows the tab it
715    /// touches, but a channel/wire arrival grows the *Inbox* view too,
716    /// so without this the highlight would stop tracking the newest row
717    /// on the merged tab (#462).
718    pub fn inbox_at_tail(&self) -> bool {
719        let len = self.visible_indices(MailboxTab::Inbox).len();
720        len == 0 || self.inbox_cursor.selected_idx + 1 >= len
721    }
722
723    /// Snap the Inbox cursor to the tail of the post-merge view. Called
724    /// after a refresh's extends when [`inbox_at_tail`] held before
725    /// them, so following the Inbox keeps tracking the newest row even
726    /// when the new arrival came via the channel or wire buffer (#462).
727    pub fn follow_inbox_tail(&mut self) {
728        let len = self.visible_indices(MailboxTab::Inbox).len();
729        if len > 0 {
730            self.inbox_cursor.selected_idx = len - 1;
731        }
732    }
733
734    /// Fold a freshly-fetched batch into the appropriate tab,
735    /// trimming to the last `MAX_TAB_ROWS`. Bumps the broker
736    /// pagination cursor to the last returned id when the batch is
737    /// non-empty. T-131 PR-1: when the UI cursor was already at the
738    /// tail (or the tab was empty), follow new arrivals — matching
739    /// the pre-T-131 "tail to whatever fits" UX. Always re-clamps the
740    /// UI cursor against the (possibly drained) post-extend visible
741    /// length so a stale index can never reference a missing row.
742    pub fn extend(&mut self, tab: MailboxTab, batch: Vec<MessageRow>) {
743        let prev_visible_len = self.visible_indices(tab).len();
744        let was_at_tail =
745            prev_visible_len == 0 || self.cursor(tab).selected_idx + 1 >= prev_visible_len;
746        let last_id = batch.last().map(|r| r.id);
747        let (buf, after) = match tab {
748            MailboxTab::Inbox => (&mut self.inbox, &mut self.inbox_after),
749            MailboxTab::Sent => (&mut self.sent, &mut self.sent_after),
750            MailboxTab::Channel => (&mut self.channel, &mut self.channel_after),
751            MailboxTab::Wire => (&mut self.wire, &mut self.wire_after),
752        };
753        buf.extend(batch);
754        if buf.len() > MAX_TAB_ROWS {
755            let drop = buf.len() - MAX_TAB_ROWS;
756            buf.drain(..drop);
757        }
758        if let Some(id) = last_id {
759            *after = id;
760        }
761        let new_visible_len = self.visible_indices(tab).len();
762        let cur = self.cursor_mut(tab);
763        if was_at_tail && new_visible_len > 0 {
764            cur.selected_idx = new_visible_len - 1;
765        } else if new_visible_len > 0 {
766            let max = new_visible_len - 1;
767            if cur.selected_idx > max {
768                cur.selected_idx = max;
769            }
770        } else {
771            cur.selected_idx = 0;
772        }
773    }
774
775    /// Reset every tab's contents and cursor. Called when the
776    /// focused agent changes — the new agent's `inbox` filter would
777    /// otherwise skip historical rows that landed before our last
778    /// `inbox_after`, and the UI cursor would point into the wrong
779    /// agent's buffer.
780    pub fn reset(&mut self) {
781        *self = Self::default();
782    }
783}
784
785pub mod test_support {
786    //! Shared mock — public so unit tests, integration tests, and
787    //! downstream coverage can wire in a recorder without rolling
788    //! their own. Matches the shape used by `compose::test_support`
789    //! and `approvals::test_support`.
790
791    use super::*;
792    use std::sync::Mutex;
793
794    /// Test stub — returns canned rows on each call, records every
795    /// arg pair. Mailbox is the most-asserted test surface in
796    /// PR-UI-3 so the recorder lets snapshot + interaction tests
797    /// verify "is the right filter being asked the right thing."
798    #[derive(Default)]
799    pub struct MockMailboxSource {
800        pub inbox_rows: Vec<MessageRow>,
801        pub sent_rows: Vec<MessageRow>,
802        pub channel_rows: Vec<MessageRow>,
803        pub wire_rows: Vec<MessageRow>,
804        pub inbox_calls: Mutex<Vec<(String, i64)>>,
805        pub sent_calls: Mutex<Vec<(String, i64)>>,
806        pub channel_calls: Mutex<Vec<(String, i64)>>,
807        pub wire_calls: Mutex<Vec<(String, i64)>>,
808    }
809
810    impl MailboxSource for MockMailboxSource {
811        fn inbox(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
812            self.inbox_calls
813                .lock()
814                .unwrap()
815                .push((agent_id.into(), after_id));
816            Ok(self.inbox_rows.clone())
817        }
818
819        fn sent(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
820            self.sent_calls
821                .lock()
822                .unwrap()
823                .push((agent_id.into(), after_id));
824            Ok(self.sent_rows.clone())
825        }
826
827        fn channel_feed(&self, agent_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
828            self.channel_calls
829                .lock()
830                .unwrap()
831                .push((agent_id.into(), after_id));
832            Ok(self.channel_rows.clone())
833        }
834
835        fn wire(&self, project_id: &str, after_id: i64) -> Result<Vec<MessageRow>> {
836            self.wire_calls
837                .lock()
838                .unwrap()
839                .push((project_id.into(), after_id));
840            Ok(self.wire_rows.clone())
841        }
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::test_support::*;
848    use super::*;
849
850    fn row(id: i64, sender: &str, recipient: &str, text: &str) -> MessageRow {
851        MessageRow {
852            id,
853            sender: sender.into(),
854            recipient: recipient.into(),
855            text: text.into(),
856            sent_at: 0.0,
857        }
858    }
859
860    #[test]
861    fn all_is_inbox_and_sent_only() {
862        // #462: Channel/Wire are no longer user tabs.
863        assert_eq!(MailboxTab::ALL, [MailboxTab::Inbox, MailboxTab::Sent]);
864    }
865
866    #[test]
867    fn next_toggles_between_inbox_and_sent() {
868        // #462: two user tabs → next is a straight toggle.
869        let mut t = MailboxTab::Inbox;
870        t = t.next();
871        assert_eq!(t, MailboxTab::Sent);
872        t = t.next();
873        assert_eq!(t, MailboxTab::Inbox);
874    }
875
876    #[test]
877    fn prev_toggles_between_inbox_and_sent() {
878        let mut t = MailboxTab::Inbox;
879        t = t.prev();
880        assert_eq!(t, MailboxTab::Sent);
881        t = t.prev();
882        assert_eq!(t, MailboxTab::Inbox);
883    }
884
885    #[test]
886    fn internal_channel_wire_variants_fold_to_inbox_on_cycle() {
887        // The folded-in internal variants are never the active tab, but
888        // if cycling ever lands on one it must escape to a real tab, not
889        // strand the cursor on a non-tab.
890        assert_eq!(MailboxTab::Channel.next(), MailboxTab::Inbox);
891        assert_eq!(MailboxTab::Wire.next(), MailboxTab::Inbox);
892        assert_eq!(MailboxTab::Channel.prev(), MailboxTab::Inbox);
893        assert_eq!(MailboxTab::Wire.prev(), MailboxTab::Inbox);
894    }
895
896    #[test]
897    fn extend_appends_and_bumps_cursor() {
898        let mut buf = MailboxBuffers::default();
899        buf.extend(
900            MailboxTab::Inbox,
901            vec![row(7, "p:m", "p:dev", "hi"), row(8, "p:m", "p:dev", "yo")],
902        );
903        assert_eq!(buf.inbox.len(), 2);
904        assert_eq!(buf.inbox_after, 8);
905        // Empty batch must not move the cursor backward.
906        buf.extend(MailboxTab::Inbox, vec![]);
907        assert_eq!(buf.inbox_after, 8);
908    }
909
910    #[test]
911    fn extend_trims_to_cap() {
912        let mut buf = MailboxBuffers::default();
913        let batch: Vec<MessageRow> = (1..=600).map(|i| row(i, "p:m", "p:dev", "x")).collect();
914        buf.extend(MailboxTab::Wire, batch);
915        assert_eq!(buf.wire.len(), MAX_TAB_ROWS);
916        // Cap keeps the *latest* rows — the cursor reflects the
917        // batch's actual high-water id, not the trimmed buffer's
918        // first row.
919        assert_eq!(buf.wire_after, 600);
920        assert_eq!(buf.wire.last().unwrap().id, 600);
921    }
922
923    #[test]
924    fn reset_clears_buffers_and_cursors() {
925        let mut buf = MailboxBuffers::default();
926        buf.extend(MailboxTab::Inbox, vec![row(3, "a", "b", "x")]);
927        buf.extend(MailboxTab::Channel, vec![row(4, "a", "channel:p:all", "y")]);
928        buf.reset();
929        assert!(buf.inbox.is_empty());
930        assert!(buf.channel.is_empty());
931        assert_eq!(buf.inbox_after, 0);
932        assert_eq!(buf.channel_after, 0);
933    }
934
935    #[test]
936    fn inbox_merges_inbound_channel_and_wire_sorted_excluding_self() {
937        // #462: the Inbox tab folds inbound DMs + channel + wire,
938        // time-ordered by id, with self-sent broadcasts dropped (they
939        // live under Sent).
940        let buf = MailboxBuffers {
941            agent_id: "p:m".to_string(),
942            inbox: vec![row(2, "p:dev", "p:m", "dm")],
943            channel: vec![
944                row(1, "p:dev", "channel:p:eng", "chan inbound"),
945                row(4, "p:m", "channel:p:eng", "chan from me"), // self → dropped
946            ],
947            wire: vec![
948                row(3, "p:ops", "channel:p:all", "wire inbound"),
949                row(5, "p:m", "channel:p:all", "wire from me"), // self → dropped
950            ],
951            ..Default::default()
952        };
953        let inbox = buf.rows(MailboxTab::Inbox);
954        let ids: Vec<i64> = inbox.iter().map(|r| r.id).collect();
955        assert_eq!(ids, vec![1, 2, 3], "inbound DM+channel+wire, id-sorted");
956        // The raw Channel/Wire buffers are untouched — they still back
957        // the MailboxFirst feed and hold the self rows.
958        assert_eq!(buf.rows(MailboxTab::Channel).len(), 2);
959        assert_eq!(buf.rows(MailboxTab::Wire).len(), 2);
960    }
961
962    #[test]
963    fn inbox_dedups_all_channel_wire_overlap() {
964        // The `all` channel is membership-joined by every agent, so an
965        // `all` broadcast lands in BOTH the channel buffer (via the
966        // membership query) and the wire buffer — the same id via two
967        // paths. The merged Inbox must show it once, not twice.
968        let dup = row(7, "p:dev", "channel:p:all", "release cut");
969        let buf = MailboxBuffers {
970            agent_id: "p:m".to_string(),
971            channel: vec![dup.clone()],
972            wire: vec![dup],
973            ..Default::default()
974        };
975        let ids: Vec<i64> = buf.rows(MailboxTab::Inbox).iter().map(|r| r.id).collect();
976        assert_eq!(ids, vec![7], "the all-channel broadcast appears once");
977    }
978
979    #[test]
980    fn inbox_merge_is_noop_filter_before_agent_id_set() {
981        // Empty `agent_id` only co-occurs with empty buffers in
982        // production (`reset` clears both, and `refresh_mailbox` sets
983        // the id before any extend), so the no-op `sender != ""` filter
984        // is unobservable there. Pinned defensively: even hand-fed a
985        // self row with no agent_id, the merge drops nothing rather than
986        // panicking or losing a row.
987        let buf = MailboxBuffers {
988            channel: vec![row(1, "p:m", "channel:p:eng", "self")],
989            ..Default::default()
990        };
991        assert_eq!(buf.rows(MailboxTab::Inbox).len(), 1);
992    }
993
994    #[test]
995    fn inbox_cursor_follows_channel_wire_arrivals_only_when_at_tail() {
996        // The merged Inbox grows when channel/wire rows arrive, not just
997        // DMs. The refresh re-anchor (inbox_at_tail + follow_inbox_tail)
998        // keeps a tail-following cursor on the newest row — but must not
999        // yank a scrolled-up cursor (#462).
1000        let mut buf = MailboxBuffers {
1001            agent_id: "p:m".to_string(),
1002            inbox: vec![row(1, "p:dev", "p:m", "dm one")],
1003            ..Default::default()
1004        };
1005        assert!(buf.inbox_at_tail(), "single row → at tail");
1006        // A channel arrival while following the tail.
1007        let was_at_tail = buf.inbox_at_tail();
1008        buf.extend(
1009            MailboxTab::Channel,
1010            vec![row(2, "p:dev", "channel:p:eng", "later")],
1011        );
1012        if was_at_tail {
1013            buf.follow_inbox_tail();
1014        }
1015        assert_eq!(
1016            buf.inbox_cursor.selected_idx, 1,
1017            "cursor tracked the channel arrival"
1018        );
1019        // Now scrolled up: a wire arrival must NOT pull the cursor down.
1020        buf.inbox_cursor.selected_idx = 0;
1021        let was_at_tail = buf.inbox_at_tail();
1022        buf.extend(
1023            MailboxTab::Wire,
1024            vec![row(3, "p:ops", "channel:p:all", "broadcast")],
1025        );
1026        if was_at_tail {
1027            buf.follow_inbox_tail();
1028        }
1029        assert_eq!(
1030            buf.inbox_cursor.selected_idx, 0,
1031            "scrolled-up cursor stays put"
1032        );
1033    }
1034
1035    fn empty_team() -> crate::data::TeamSnapshot {
1036        crate::data::TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
1037    }
1038
1039    #[test]
1040    fn render_row_flattens_newlines_and_truncates() {
1041        let team = empty_team();
1042        let r = row(1, "p:m", "p:dev", "first\nsecond\nthird");
1043        assert_eq!(
1044            render_row(&r, &team, MailboxTab::Inbox),
1045            "[p:m] first second third"
1046        );
1047
1048        let long: String = "x".repeat(300);
1049        let r = row(1, "s", "r", &long);
1050        let rendered = render_row(&r, &team, MailboxTab::Inbox);
1051        // 5 chars ("[s] ") + at most 180 chars of body = 185.
1052        assert!(rendered.chars().count() <= 185);
1053    }
1054
1055    #[test]
1056    fn render_row_inbox_disambiguates_channel_from_dm() {
1057        // #462: a folded-in channel/wire row keeps the `[#chan]
1058        // [sender]` T-249 shape; a DM stays `[sender]` so the two are
1059        // distinguishable now that they share the Inbox.
1060        let team = empty_team();
1061        let dm = row(1, "p:dev", "p:m", "direct");
1062        assert_eq!(render_row(&dm, &team, MailboxTab::Inbox), "[p:dev] direct");
1063        let chan = row(2, "p:dev", "channel:p:eng", "in channel");
1064        assert_eq!(
1065            render_row(&chan, &team, MailboxTab::Inbox),
1066            "[#eng] [p:dev] in channel"
1067        );
1068        let wire = row(3, "p:ops", "channel:p:all", "broadcast");
1069        assert_eq!(
1070            render_row(&wire, &team, MailboxTab::Inbox),
1071            "[#all] [p:ops] broadcast"
1072        );
1073    }
1074
1075    #[test]
1076    fn render_row_uses_display_name_when_set() {
1077        // T-160: when the sender id has a `display_name` in the team
1078        // snapshot, the mailbox row renders the label, not the id.
1079        // Unknown senders fall through to the raw id (covered above).
1080        use crate::data::{AgentInfo, TeamSnapshot};
1081        use team_core::supervisor::AgentState;
1082        let agent = AgentInfo {
1083            id: "p:sage".into(),
1084            agent: "sage".into(),
1085            project: "p".into(),
1086            tmux_session: "a-p-sage".into(),
1087            state: AgentState::Unknown,
1088            unread_mail: 0,
1089            pending_approvals: 0,
1090            is_manager: true,
1091            display_name: Some("Sage (Visionary)".into()),
1092            rate_limit_resets_at: None,
1093            last_activity_at: None,
1094            reports_to: None,
1095        };
1096        let team = TeamSnapshot {
1097            root: std::path::PathBuf::from("/tmp"),
1098            team_name: "t".into(),
1099            agents: vec![agent],
1100            channels: vec![],
1101        };
1102        let r = row(1, "p:sage", "p:hugo", "ping");
1103        assert_eq!(
1104            render_row(&r, &team, MailboxTab::Inbox),
1105            "[Sage (Visionary)] ping"
1106        );
1107    }
1108
1109    // T-231: tab-aware prefix — Sent shows recipient, others show
1110    // sender. These pin the contract the operator-visible UX rests on.
1111
1112    #[test]
1113    fn render_row_sent_tab_shows_recipient_with_arrow() {
1114        // Sent rows have the focused agent as sender (constant);
1115        // recipient is the disambiguating column. Verify the arrow
1116        // glyph + recipient appear in place of the sender.
1117        let team = empty_team();
1118        let r = row(1, "p:me", "p:dev", "ack");
1119        assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→p:dev] ack");
1120    }
1121
1122    #[test]
1123    fn render_row_sent_tab_resolves_recipient_display_name() {
1124        // Same display-name resolution as the Inbox path — the
1125        // recipient's label, not the raw id, when the team snapshot
1126        // has a display_name for them.
1127        use crate::data::{AgentInfo, TeamSnapshot};
1128        use team_core::supervisor::AgentState;
1129        let agent = AgentInfo {
1130            id: "p:hugo".into(),
1131            agent: "hugo".into(),
1132            project: "p".into(),
1133            tmux_session: "a-p-hugo".into(),
1134            state: AgentState::Running,
1135            unread_mail: 0,
1136            pending_approvals: 0,
1137            is_manager: true,
1138            display_name: Some("Hugo (PM)".into()),
1139            rate_limit_resets_at: None,
1140            last_activity_at: None,
1141            reports_to: None,
1142        };
1143        let team = TeamSnapshot {
1144            root: std::path::PathBuf::from("/tmp"),
1145            team_name: "t".into(),
1146            agents: vec![agent],
1147            channels: vec![],
1148        };
1149        let r = row(1, "p:sage", "p:hugo", "ping");
1150        assert_eq!(render_row(&r, &team, MailboxTab::Sent), "[→Hugo (PM)] ping");
1151    }
1152
1153    #[test]
1154    fn render_row_sent_tab_renders_channel_recipient_with_hash() {
1155        // Broadcast-to-channel rows have `recipient = channel:<id>`.
1156        // The Sent prefix should render as `→#<short>` — operators
1157        // recognize `#dev`, not `channel:teamctl:dev`.
1158        let team = empty_team();
1159        let r = row(1, "p:me", "channel:teamctl:dev", "rolling 0.8.3");
1160        assert_eq!(
1161            render_row(&r, &team, MailboxTab::Sent),
1162            "[→#dev] rolling 0.8.3"
1163        );
1164    }
1165
1166    #[test]
1167    fn render_row_sent_tab_renders_user_recipient_verbatim() {
1168        // Telegram-bound `reply_to_user` rows have `recipient = user:telegram`.
1169        // No special prefix-stripping — operators already recognize
1170        // the `user:*` shape and dropping the prefix would lose the
1171        // "this went to the operator" signal.
1172        let team = empty_team();
1173        let r = row(1, "p:mgr", "user:telegram", "PR url");
1174        assert_eq!(
1175            render_row(&r, &team, MailboxTab::Sent),
1176            "[→user:telegram] PR url"
1177        );
1178    }
1179
1180    #[test]
1181    fn render_row_non_sent_tabs_still_show_sender() {
1182        // Inbox / Wire prefix is the sender. Channel has its own
1183        // two-segment shape pinned in the T-249 tests below.
1184        let team = empty_team();
1185        let r = row(1, "p:from", "p:me", "yo");
1186        assert_eq!(render_row(&r, &team, MailboxTab::Inbox), "[p:from] yo");
1187        assert_eq!(render_row(&r, &team, MailboxTab::Wire), "[p:from] yo");
1188    }
1189
1190    // T-249: Channel tab — two bracketed segments, channel then sender.
1191    // The disambiguator the operator needs is "which channel was this
1192    // posted in", because the tab folds every subscribed channel into
1193    // a single feed.
1194
1195    #[test]
1196    fn render_row_channel_tab_prefixes_channel_name_and_sender() {
1197        let team = empty_team();
1198        let r = row(1, "p:from", "channel:teamctl:dev", "yo");
1199        assert_eq!(
1200            render_row(&r, &team, MailboxTab::Channel),
1201            "[#dev] [p:from] yo"
1202        );
1203    }
1204
1205    #[test]
1206    fn render_row_channel_tab_resolves_sender_display_name() {
1207        // Sender resolution mirrors the Inbox path — display_name
1208        // when set on the team snapshot, raw id otherwise. Channel
1209        // name resolution is independent.
1210        use crate::data::{AgentInfo, TeamSnapshot};
1211        use team_core::supervisor::AgentState;
1212        let agent = AgentInfo {
1213            id: "p:wren".into(),
1214            agent: "wren".into(),
1215            project: "p".into(),
1216            tmux_session: "a-p-wren".into(),
1217            state: AgentState::Running,
1218            unread_mail: 0,
1219            pending_approvals: 0,
1220            is_manager: false,
1221            display_name: Some("Wren (Engineer)".into()),
1222            rate_limit_resets_at: None,
1223            last_activity_at: None,
1224            reports_to: None,
1225        };
1226        let team = TeamSnapshot {
1227            root: std::path::PathBuf::from("/tmp"),
1228            team_name: "t".into(),
1229            agents: vec![agent],
1230            channels: vec![],
1231        };
1232        let r = row(1, "p:wren", "channel:p:all", "hello");
1233        assert_eq!(
1234            render_row(&r, &team, MailboxTab::Channel),
1235            "[#all] [Wren (Engineer)] hello"
1236        );
1237    }
1238
1239    #[test]
1240    fn render_row_channel_tab_handles_malformed_channel_recipient() {
1241        // Defensive — channel_feed SQL only returns rows shaped
1242        // `channel:<channel_id>`, but if a malformed value ever
1243        // lands (manual write, future schema shift), the row still
1244        // renders without panic. Pins recipient_label's malformed
1245        // fallback (matches T-231's parallel sent-tab test).
1246        let team = empty_team();
1247        let r = row(1, "p:from", "channel:malformed", "yo");
1248        assert_eq!(
1249            render_row(&r, &team, MailboxTab::Channel),
1250            "[#malformed] [p:from] yo"
1251        );
1252    }
1253
1254    #[test]
1255    fn mock_records_calls() {
1256        let mock = MockMailboxSource {
1257            inbox_rows: vec![row(1, "p:m", "p:a", "hi")],
1258            ..Default::default()
1259        };
1260        let _ = mock.inbox("p:a", 0).unwrap();
1261        let _ = mock.sent("p:a", 2).unwrap();
1262        let _ = mock.channel_feed("p:a", 5).unwrap();
1263        let _ = mock.wire("p", 9).unwrap();
1264        assert_eq!(*mock.inbox_calls.lock().unwrap(), vec![("p:a".into(), 0)]);
1265        assert_eq!(*mock.sent_calls.lock().unwrap(), vec![("p:a".into(), 2)]);
1266        assert_eq!(*mock.channel_calls.lock().unwrap(), vec![("p:a".into(), 5)]);
1267        assert_eq!(*mock.wire_calls.lock().unwrap(), vec![("p".into(), 9)]);
1268    }
1269
1270    // T-131 PR-1: cursor + visible_indices invariants.
1271
1272    fn rows_n(n: i64) -> Vec<MessageRow> {
1273        (1..=n).map(|i| row(i, "p:m", "p:dev", "x")).collect()
1274    }
1275
1276    #[test]
1277    fn visible_indices_is_identity_in_pr1() {
1278        // PR-1 invariant: visible_indices(tab) == (0..rows(tab).len()).
1279        // PR-2 swaps the body — this test guards the PR-1 baseline so
1280        // a PR-2 regression that breaks PR-1's identity assumption
1281        // surfaces here, not in a render call site downstream.
1282        let mut buf = MailboxBuffers::default();
1283        buf.extend(MailboxTab::Inbox, rows_n(5));
1284        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 1, 2, 3, 4]);
1285        assert!(buf.visible_indices(MailboxTab::Sent).is_empty());
1286    }
1287
1288    #[test]
1289    fn extend_into_empty_seats_cursor_at_tail() {
1290        // Pre-T-131 UX was "tail to whatever fits"; the cursor seat
1291        // preserves it — a freshly-populated tab shows the latest row
1292        // selected, matching the existing snapshot expectations for
1293        // unfocused mailbox panes.
1294        let mut buf = MailboxBuffers::default();
1295        buf.extend(MailboxTab::Inbox, rows_n(7));
1296        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 6);
1297    }
1298
1299    #[test]
1300    fn extend_when_cursor_at_tail_follows_new_arrivals() {
1301        // Standard chat-app "follow tail" UX: as long as the operator
1302        // hasn't scrolled away, new messages keep the cursor at the
1303        // newest row.
1304        let mut buf = MailboxBuffers::default();
1305        buf.extend(MailboxTab::Inbox, rows_n(3));
1306        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2);
1307        buf.extend(
1308            MailboxTab::Inbox,
1309            vec![row(4, "p:m", "p:dev", "x"), row(5, "p:m", "p:dev", "x")],
1310        );
1311        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1312    }
1313
1314    #[test]
1315    fn extend_when_cursor_scrolled_up_does_not_follow() {
1316        // Operator inspecting older history shouldn't be yanked back
1317        // to the tail by a new arrival — the cursor is sticky once it
1318        // leaves the tail.
1319        let mut buf = MailboxBuffers::default();
1320        buf.extend(MailboxTab::Inbox, rows_n(5));
1321        buf.cursor_home(MailboxTab::Inbox); // selected_idx = 0
1322        buf.extend(MailboxTab::Inbox, vec![row(6, "p:m", "p:dev", "x")]);
1323        assert_eq!(
1324            buf.cursor(MailboxTab::Inbox).selected_idx,
1325            0,
1326            "scrolled-up cursor must not jump on new arrival"
1327        );
1328    }
1329
1330    #[test]
1331    fn extend_reclamps_cursor_after_drain() {
1332        // The MAX_TAB_ROWS drain shifts indices — a cursor that was
1333        // valid pre-drain must be re-clamped against the new visible
1334        // length so render never indexes past the buffer.
1335        let mut buf = MailboxBuffers::default();
1336        buf.extend(MailboxTab::Inbox, rows_n(MAX_TAB_ROWS as i64));
1337        buf.cursor_home(MailboxTab::Inbox);
1338        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1339        // Push another batch large enough to drain off the front.
1340        let next: Vec<MessageRow> = (501..=510).map(|i| row(i, "p:m", "p:dev", "x")).collect();
1341        buf.extend(MailboxTab::Inbox, next);
1342        let visible = buf.visible_indices(MailboxTab::Inbox);
1343        assert_eq!(visible.len(), MAX_TAB_ROWS);
1344        assert!(
1345            buf.cursor(MailboxTab::Inbox).selected_idx < visible.len(),
1346            "post-drain cursor must stay in range; got {}, visible.len {}",
1347            buf.cursor(MailboxTab::Inbox).selected_idx,
1348            visible.len()
1349        );
1350    }
1351
1352    #[test]
1353    fn move_cursor_down_and_up_clamp_at_ends() {
1354        let mut buf = MailboxBuffers::default();
1355        buf.extend(MailboxTab::Inbox, rows_n(3)); // cursor seated at 2
1356        buf.move_cursor_down(MailboxTab::Inbox);
1357        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2, "tail clamps");
1358        buf.move_cursor_up(MailboxTab::Inbox);
1359        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1360        buf.move_cursor_up(MailboxTab::Inbox);
1361        buf.move_cursor_up(MailboxTab::Inbox);
1362        buf.move_cursor_up(MailboxTab::Inbox); // extra up at 0 is no-op
1363        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0, "head clamps");
1364    }
1365
1366    #[test]
1367    fn page_cursor_jumps_a_screen() {
1368        let mut buf = MailboxBuffers::default();
1369        buf.extend(MailboxTab::Inbox, rows_n(50));
1370        buf.cursor_home(MailboxTab::Inbox);
1371        buf.page_cursor_down(MailboxTab::Inbox);
1372        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1373        buf.page_cursor_down(MailboxTab::Inbox);
1374        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 2 * PAGE_JUMP);
1375        buf.page_cursor_up(MailboxTab::Inbox);
1376        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, PAGE_JUMP);
1377        // PageDown past the tail clamps.
1378        for _ in 0..20 {
1379            buf.page_cursor_down(MailboxTab::Inbox);
1380        }
1381        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 49);
1382        // PageUp past the head clamps.
1383        for _ in 0..20 {
1384            buf.page_cursor_up(MailboxTab::Inbox);
1385        }
1386        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1387    }
1388
1389    #[test]
1390    fn cursor_home_and_end_jump_to_ends() {
1391        let mut buf = MailboxBuffers::default();
1392        buf.extend(MailboxTab::Inbox, rows_n(20));
1393        buf.cursor_home(MailboxTab::Inbox);
1394        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1395        buf.cursor_end(MailboxTab::Inbox);
1396        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 19);
1397    }
1398
1399    #[test]
1400    fn cursors_are_per_tab_and_independent() {
1401        // Issue AC: "Scrolling is per-tab — Inbox/Sent/Channel/Wire
1402        // each remember their own position."
1403        let mut buf = MailboxBuffers::default();
1404        buf.extend(MailboxTab::Inbox, rows_n(10));
1405        buf.extend(MailboxTab::Sent, rows_n(10));
1406        buf.cursor_home(MailboxTab::Inbox); // Inbox cursor at 0
1407                                            // Sent cursor stays at its post-extend tail (idx 9).
1408        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1409        assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 9);
1410        // And channel/wire are still at 0 with empty buffers.
1411        assert_eq!(buf.cursor(MailboxTab::Channel).selected_idx, 0);
1412        assert_eq!(buf.cursor(MailboxTab::Wire).selected_idx, 0);
1413    }
1414
1415    #[test]
1416    fn reset_clears_cursors_too() {
1417        // Reset is called when the focused agent changes; the new
1418        // agent's mailbox starts from a clean slate, cursor at 0.
1419        let mut buf = MailboxBuffers::default();
1420        buf.extend(MailboxTab::Inbox, rows_n(5));
1421        buf.cursor_home(MailboxTab::Inbox);
1422        buf.move_cursor_down(MailboxTab::Inbox);
1423        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 1);
1424        buf.reset();
1425        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1426        assert_eq!(buf.cursor(MailboxTab::Sent).selected_idx, 0);
1427    }
1428
1429    #[test]
1430    fn cursor_methods_are_safe_on_empty_buffer() {
1431        // No rows yet — every cursor method must be a no-op on
1432        // selected_idx = 0 rather than panic.
1433        let mut buf = MailboxBuffers::default();
1434        buf.move_cursor_down(MailboxTab::Inbox);
1435        buf.move_cursor_up(MailboxTab::Inbox);
1436        buf.page_cursor_down(MailboxTab::Inbox);
1437        buf.page_cursor_up(MailboxTab::Inbox);
1438        buf.cursor_home(MailboxTab::Inbox);
1439        buf.cursor_end(MailboxTab::Inbox);
1440        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1441    }
1442
1443    // T-131 PR-2: filter + search semantics.
1444
1445    fn mixed_rows() -> Vec<MessageRow> {
1446        vec![
1447            row(1, "p:ada", "p:dev", "ready for review"),
1448            row(2, "p:kian", "p:dev", "release pipeline notes"),
1449            row(3, "p:ada", "p:dev", "shipping the patch"),
1450            row(4, "user:telegram", "p:dev", "any blockers?"),
1451            row(5, "p:kian", "p:dev", "Release smoke green"),
1452        ]
1453    }
1454
1455    #[test]
1456    fn visible_indices_identity_when_no_filter_no_search() {
1457        let mut buf = MailboxBuffers::default();
1458        buf.extend(MailboxTab::Inbox, mixed_rows());
1459        assert_eq!(
1460            buf.visible_indices(MailboxTab::Inbox),
1461            vec![0, 1, 2, 3, 4],
1462            "no filter + no search must recover PR-1 identity exactly"
1463        );
1464    }
1465
1466    #[test]
1467    fn filter_restricts_to_sender_substring_case_insensitive() {
1468        let mut buf = MailboxBuffers::default();
1469        buf.extend(MailboxTab::Inbox, mixed_rows());
1470        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ADA".into());
1471        assert_eq!(
1472            buf.visible_indices(MailboxTab::Inbox),
1473            vec![0, 2],
1474            "filter `ADA` (case-insensitive) must match `p:ada` rows only"
1475        );
1476    }
1477
1478    #[test]
1479    fn search_restricts_to_body_substring_case_insensitive() {
1480        let mut buf = MailboxBuffers::default();
1481        buf.extend(MailboxTab::Inbox, mixed_rows());
1482        buf.set_input(
1483            MailboxTab::Inbox,
1484            MailboxInputKind::Search,
1485            "release".into(),
1486        );
1487        assert_eq!(
1488            buf.visible_indices(MailboxTab::Inbox),
1489            vec![1, 4],
1490            "search `release` must match both `release pipeline notes` and \
1491             `Release smoke green` case-insensitively"
1492        );
1493    }
1494
1495    #[test]
1496    fn filter_and_search_compose_via_intersection() {
1497        let mut buf = MailboxBuffers::default();
1498        buf.extend(MailboxTab::Inbox, mixed_rows());
1499        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1500        buf.set_input(
1501            MailboxTab::Inbox,
1502            MailboxInputKind::Search,
1503            "release".into(),
1504        );
1505        assert_eq!(
1506            buf.visible_indices(MailboxTab::Inbox),
1507            vec![1, 4],
1508            "filter `kian` ∩ search `release` must keep only kian's release rows"
1509        );
1510        // Pin "compose" semantics: each axis on its own would be a
1511        // superset; intersection is strictly smaller-or-equal.
1512        let only_filter = {
1513            let mut b = MailboxBuffers::default();
1514            b.extend(MailboxTab::Inbox, mixed_rows());
1515            b.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "kian".into());
1516            b.visible_indices(MailboxTab::Inbox)
1517        };
1518        assert_eq!(only_filter, vec![1, 4]); // here filter alone happens to coincide
1519    }
1520
1521    #[test]
1522    fn empty_axis_is_noop() {
1523        // The empty-input contract: empty = clear that axis. Issue AC.
1524        let mut buf = MailboxBuffers::default();
1525        buf.extend(MailboxTab::Inbox, mixed_rows());
1526        // Set then clear filter — visible_indices returns to identity.
1527        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1528        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1529        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, String::new());
1530        assert_eq!(
1531            buf.visible_indices(MailboxTab::Inbox),
1532            vec![0, 1, 2, 3, 4],
1533            "clearing the filter must restore identity"
1534        );
1535    }
1536
1537    #[test]
1538    fn input_push_pop_updates_visible_and_clamps_cursor() {
1539        let mut buf = MailboxBuffers::default();
1540        buf.extend(MailboxTab::Inbox, mixed_rows()); // cursor lands at 4 (tail)
1541        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 4);
1542        // Type `a`-`d`-`a` → filter shrinks visible to {0, 2}, len 2.
1543        // The cursor was at 4 (out of range for the shorter list), so
1544        // clamp_cursor must bring it to len-1 = 1.
1545        buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1546        buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'd');
1547        buf.input_push_char(MailboxTab::Inbox, MailboxInputKind::Filter, 'a');
1548        assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1549        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1550        assert_eq!(
1551            buf.cursor(MailboxTab::Inbox).selected_idx,
1552            1,
1553            "cursor must clamp to the shorter visible_indices len-1"
1554        );
1555        // Backspace twice → filter becomes `a`, visible widens but
1556        // cursor stays where it landed (in range).
1557        buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1558        buf.input_pop_char(MailboxTab::Inbox, MailboxInputKind::Filter);
1559        assert_eq!(buf.filter_text(MailboxTab::Inbox), "a");
1560    }
1561
1562    #[test]
1563    fn filter_and_search_are_per_tab() {
1564        // Issue AC: "Filter state is per-tab." So is search.
1565        let mut buf = MailboxBuffers::default();
1566        buf.extend(MailboxTab::Inbox, mixed_rows());
1567        buf.extend(MailboxTab::Sent, mixed_rows());
1568        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1569        buf.set_input(MailboxTab::Sent, MailboxInputKind::Search, "release".into());
1570        assert_eq!(buf.filter_text(MailboxTab::Inbox), "ada");
1571        assert_eq!(buf.filter_text(MailboxTab::Sent), "");
1572        assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1573        assert_eq!(buf.search_text(MailboxTab::Sent), "release");
1574        assert_eq!(buf.visible_indices(MailboxTab::Inbox), vec![0, 2]);
1575        assert_eq!(buf.visible_indices(MailboxTab::Sent), vec![1, 4]);
1576    }
1577
1578    #[test]
1579    fn reset_clears_filter_and_search() {
1580        let mut buf = MailboxBuffers::default();
1581        buf.extend(MailboxTab::Inbox, mixed_rows());
1582        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
1583        buf.set_input(MailboxTab::Inbox, MailboxInputKind::Search, "ship".into());
1584        buf.reset();
1585        assert_eq!(buf.filter_text(MailboxTab::Inbox), "");
1586        assert_eq!(buf.search_text(MailboxTab::Inbox), "");
1587        assert!(buf.rows(MailboxTab::Inbox).is_empty());
1588    }
1589
1590    #[test]
1591    fn empty_visible_keeps_cursor_at_zero_not_panic() {
1592        // Filter that matches no rows yields empty visible_indices.
1593        // clamp_cursor must leave cursor at 0 rather than underflow.
1594        let mut buf = MailboxBuffers::default();
1595        buf.extend(MailboxTab::Inbox, mixed_rows());
1596        buf.set_input(
1597            MailboxTab::Inbox,
1598            MailboxInputKind::Filter,
1599            "no-such-sender".into(),
1600        );
1601        assert!(buf.visible_indices(MailboxTab::Inbox).is_empty());
1602        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1603        // Cursor methods on an empty visible set must not panic.
1604        buf.move_cursor_down(MailboxTab::Inbox);
1605        buf.move_cursor_up(MailboxTab::Inbox);
1606        buf.cursor_end(MailboxTab::Inbox);
1607        assert_eq!(buf.cursor(MailboxTab::Inbox).selected_idx, 0);
1608    }
1609
1610    // T-131 PR-3: kind_label + transport_label derivation.
1611
1612    #[test]
1613    fn kind_label_distinguishes_dm_channel_wire() {
1614        let r = row(1, "p:a", "p:dev", "x"); // agent-to-agent DM
1615        assert_eq!(kind_label(&r), "DM");
1616        let r = row(1, "p:a", "user:telegram", "x"); // agent-to-user DM
1617        assert_eq!(kind_label(&r), "DM");
1618        let r = row(1, "p:a", "channel:p:dev", "x"); // named channel
1619        assert_eq!(kind_label(&r), "channel broadcast");
1620        let r = row(1, "p:a", "channel:p:all", "x"); // project-wide wire
1621        assert_eq!(kind_label(&r), "wire broadcast");
1622    }
1623
1624    #[test]
1625    fn transport_label_heuristic_covers_documented_cases() {
1626        // Issue's "if discernible" — heuristic from sender prefix.
1627        let r = row(1, "user:telegram", "p:a", "x");
1628        assert_eq!(transport_label(&r), "via telegram");
1629        let r = row(1, "user:discord", "p:a", "x");
1630        assert_eq!(transport_label(&r), "via user");
1631        let r = row(1, "p:agent", "p:other", "x");
1632        assert_eq!(transport_label(&r), "via mcp");
1633        let r = row(1, "p:agent", "channel:p:dev", "x");
1634        assert_eq!(transport_label(&r), "via mcp"); // agent emit, recipient class doesn't matter
1635        let r = row(1, "weird-no-colon", "p:a", "x");
1636        assert_eq!(transport_label(&r), "—"); // graceful degrade
1637    }
1638
1639    // T-131 PR-4: row_timestamp today-fold tests. Owner ratified
1640    // (tg 3388) (1) today-fold YES + (2) 24h YES; silent defaults
1641    // intact (no seconds, local-TZ, past-day `%b %d %H:%M`). Tests
1642    // drive `row_timestamp_in(&Utc, …)` so the assertions are
1643    // timezone-stable regardless of the dev machine's `Local`.
1644
1645    fn ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, sec: u32) -> f64 {
1646        use chrono::TimeZone;
1647        chrono::Utc
1648            .with_ymd_and_hms(year, month, day, hour, minute, sec)
1649            .unwrap()
1650            .timestamp() as f64
1651    }
1652
1653    #[test]
1654    fn row_timestamp_same_day_renders_24h_hhmm() {
1655        let now = ts(2026, 5, 22, 15, 42, 30);
1656        // Sent earlier today at 10:15:00 UTC: `10:15`.
1657        let sent = ts(2026, 5, 22, 10, 15, 0);
1658        assert_eq!(row_timestamp_in(&chrono::Utc, now, sent), "10:15");
1659        // Sent exactly now (truncates the :30 seconds): `15:42`.
1660        assert_eq!(row_timestamp_in(&chrono::Utc, now, now), "15:42");
1661        // Sent at exact midnight same day: `00:00`.
1662        let sent_midnight = ts(2026, 5, 22, 0, 0, 0);
1663        assert_eq!(row_timestamp_in(&chrono::Utc, now, sent_midnight), "00:00");
1664    }
1665
1666    #[test]
1667    fn row_timestamp_prior_day_renders_b_d_hhmm() {
1668        let now = ts(2026, 5, 22, 15, 42, 30);
1669        // Yesterday: full `%b %d %H:%M` past-day format.
1670        let sent_yesterday = ts(2026, 5, 21, 23, 59, 0);
1671        assert_eq!(
1672            row_timestamp_in(&chrono::Utc, now, sent_yesterday),
1673            "May 21 23:59"
1674        );
1675        // A month earlier: same shape, different date.
1676        let sent_earlier_month = ts(2026, 4, 22, 12, 0, 0);
1677        assert_eq!(
1678            row_timestamp_in(&chrono::Utc, now, sent_earlier_month),
1679            "Apr 22 12:00"
1680        );
1681    }
1682
1683    #[test]
1684    fn row_timestamp_future_send_uses_sent_timestamp() {
1685        // Clock skew or test fixture with `sent_at > now`. The
1686        // helper folds purely by date equality, so a future-send on
1687        // the same day still renders `HH:MM`; a future-send on a
1688        // later day renders that day's `%b %d %H:%M`. No special
1689        // negative handling — matches the simplicity-first model.
1690        let now = ts(2026, 5, 22, 15, 42, 30);
1691        let sent_future_same_day = ts(2026, 5, 22, 16, 42, 30);
1692        assert_eq!(
1693            row_timestamp_in(&chrono::Utc, now, sent_future_same_day),
1694            "16:42"
1695        );
1696        let sent_future_next_day = ts(2026, 5, 23, 15, 42, 30);
1697        assert_eq!(
1698            row_timestamp_in(&chrono::Utc, now, sent_future_next_day),
1699            "May 23 15:42"
1700        );
1701    }
1702
1703    #[test]
1704    fn row_timestamp_zero_epoch_is_same_day_as_itself() {
1705        // Snapshot tests use `App::new` (now_secs=0.0) + fixture
1706        // rows (sent_at=0.0) — both map to the Unix epoch, same
1707        // day, format `HH:MM` deterministically across machines
1708        // (snapshots.rs sets TZ=UTC so `Local` resolves to UTC).
1709        assert_eq!(row_timestamp_in(&chrono::Utc, 0.0, 0.0), "00:00");
1710    }
1711}