fix: correct the Repo tab detail view's stale-fetch and refresh handling

Two bugs in the Repo tab detail view. First, RepoUpdate::Detail carried no identity for which issue or PR it was fetched for, so a slow reply filled whatever detail happened to be open: open issue #12, press Esc, open PR #7, and #12's body could render under the #7 title. Detail fetches now carry a RepoTarget (section plus number) and a reply is applied only when it matches the open view, otherwise dropped. Second, the footer advertised "r refresh" but pressing r in the detail view called refresh_repo, which reloads the lists and clears repo_detail, so the view the user was reading vanished. r now re-fetches the item on screen.

paydii committed Jul 11, 2026 at 15:10 UTC cecfb9379225cda7d1f1b379ef50674f6bd946ab
1 file changed +45 -9
src/chat.rs
+45 -9
@@ -686,8 +686,20 @@ mod tui {
686 }
687 }
688
689 + /// Identifies which list row a detail fetch belongs to, so a slow reply
690 + /// can be matched against the currently-open detail and dropped if the
691 + /// user has since navigated elsewhere.
692 + #[derive(Clone, Copy, PartialEq, Eq)]
693 + struct RepoTarget {
694 + section: RepoSection,
695 + number: u64,
696 + }
697 +
698 /// The full-tab detail view opened with Enter on a list row.
699 struct RepoDetail {
700 + /// Which issue/PR this view is showing; matched against arriving
701 + /// `RepoUpdate::Detail` replies so a stale fetch can't fill it.
702 + target: RepoTarget,
703 title: String,
704 /// `None` while the `get_issue` / `get_pull_request` fetch runs.
705 text: Option<String>,
@@ -699,7 +711,7 @@ mod tui {
711 enum RepoUpdate {
712 Issues(Result<Vec<RepoItem>, String>),
713 PullRequests(Result<Vec<RepoItem>, String>),
702 - Detail(String),
714 + Detail { target: RepoTarget, text: String },
715 }
716
717 // ── App state ─────────────────────────────────────────────────────────────
@@ -1514,7 +1526,12 @@ mod tui {
1526 };
1527
1528 let number = item.number;
1529 + let target = RepoTarget {
1530 + section: app.repo_section,
1531 + number,
1532 + };
1533 app.repo_detail = Some(RepoDetail {
1534 + target,
1535 title: format!("{label} #{number} — {}", item.title),
1536 text: None,
1537 scroll: 0,
@@ -1527,7 +1544,10 @@ mod tui {
1544 let args = serde_json::json!({ "repo": repo, "number": number }).to_string();
1545 tokio::spawn(async move {
1546 let text = crate::mcp::call_tool(&tool, &args).await;
1530 - let _ = tx.send(RepoUpdate::Detail(super::format_repo_detail(&text)));
1547 + let _ = tx.send(RepoUpdate::Detail {
1548 + target,
1549 + text: super::format_repo_detail(&text),
1550 + });
1551 });
1552 }
1553
@@ -1599,11 +1619,22 @@ mod tui {
1619 Tab::Repo => {
1620 // Detail view open: Up/Down scroll it; Esc (handled in the
1621 // event loop) closes it back to the list.
1602 - if let Some(detail) = app.repo_detail.as_mut() {
1622 + if app.repo_detail.is_some() {
1623 match key.code {
1604 - KeyCode::Up => detail.scroll = detail.scroll.saturating_sub(1),
1605 - KeyCode::Down => detail.scroll = detail.scroll.saturating_add(1),
1606 - KeyCode::Char('r') => refresh_repo(app),
1624 + KeyCode::Up => {
1625 + if let Some(detail) = app.repo_detail.as_mut() {
1626 + detail.scroll = detail.scroll.saturating_sub(1);
1627 + }
1628 + }
1629 + KeyCode::Down => {
1630 + if let Some(detail) = app.repo_detail.as_mut() {
1631 + detail.scroll = detail.scroll.saturating_add(1);
1632 + }
1633 + }
1634 + // "r refresh" (as the footer advertises) re-fetches the
1635 + // item on screen, rather than closing the view back to
1636 + // the list as refresh_repo would.
1637 + KeyCode::Char('r') => open_repo_detail(app),
1638 _ => {}
1639 }
1640 return;
@@ -3505,9 +3536,14 @@ mod tui {
3536 .repo_pr_index
3537 .min(app.repo_prs.len().saturating_sub(1));
3538 }
3508 - Some(RepoUpdate::Detail(text)) => {
3509 - // Ignored if the user already closed the view.
3510 - if let Some(detail) = app.repo_detail.as_mut() {
3539 + Some(RepoUpdate::Detail { target, text }) => {
3540 + // Apply only to the matching open view: a reply for
3541 + // a detail the user has since closed or navigated
3542 + // away from is dropped, never shown under the wrong
3543 + // title.
3544 + if let Some(detail) = app.repo_detail.as_mut()
3545 + && detail.target == target
3546 + {
3547 detail.text = Some(text);
3548 }
3549 }