fix(panel): stop a conversation row offering a jump the pane cannot make (#759)

A turn's row is a link back into the scrollback, and `scroll_to_agent_turn`
refuses two cases: a turn with no anchor, and a pane sitting on the alternate
screen with no scrollback behind it. The panel only checked the first. So a
conversation recorded under the classic renderer kept its anchors, the user
switched the agent into a full-screen renderer — Claude Code's `/tui
fullscreen` — and every row went on drawing a pointer and a hover fill while
swallowing the click in silence.

Both conditions now live in one predicate the panel and the view agree on, and
a row that goes nowhere says why on hover: grey text reads as "less important"
long before it reads as "not a link".

Claude-Session: https://claude.ai/code/session_01A8Hiu4o14SkF5bpoPiV7Ko
This commit is contained in:
l0ng-ai
2026-08-28 19:56:50 +08:00
committed by GitHub
parent 82f235df93
commit a2b5ae56d9
7 changed files with 80 additions and 7 deletions
+13
View File
@@ -153,6 +153,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **A conversation row no longer offers a jump the pane cannot make.** Clicking
one under a full-screen agent did nothing at all: no scroll, no message,
nothing. The row had an anchor, so the panel drew it as a link — pointer,
hover fill and all — while the view refused the jump, because a pane on the
alternate screen has no scrollback to land in. Switching Claude Code into
`/tui fullscreen` mid-session is enough to produce it: the turns recorded
under the classic renderer keep their anchors, and every one of them goes
quiet at once. The two conditions now live in one predicate both sides read,
and a row that goes nowhere says why on hover rather than leaving grey text
to carry a meaning grey does not have. Jumping is still off while an agent
renders full-screen — there is genuinely nothing behind it — but the panel no
longer pretends otherwise.
- **In-app updates work again on macOS** (#708). Every "Update and Relaunch"
failed with `codesign did not report a designated requirement`, on every
build and both channels, with nothing a user could do but download the app by
+1 -1
View File
@@ -3794,7 +3794,7 @@ impl TerminalView {
!self.prompt_editor || self.shell_vi_prompt() || self.handoff_active()
}
fn on_alt_screen(&self) -> bool {
pub(crate) fn on_alt_screen(&self) -> bool {
self.terminal
.term
.lock()
+6
View File
@@ -1033,6 +1033,12 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::PanelNoChangesHint => "The working tree is clean.",
L10nKey::PanelSessionSubtitle => "Session",
L10nKey::PanelConversationSubtitle => "Conversation",
L10nKey::PanelTurnAltScreenNow => {
"Nowhere to jump while a full-screen program owns this pane."
}
L10nKey::PanelTurnNoScrollback => {
"This turn was drawn on the alternate screen, so the scrollback never kept it."
}
L10nKey::PanelProcessesSubtitle => "Processes",
L10nKey::PanelPortsSubtitle => "Ports",
L10nKey::PanelCwd => "cwd",
+6
View File
@@ -1097,6 +1097,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelNoChangesHint => "ワーキングツリーはクリーンです",
L10nKey::PanelSessionSubtitle => "セッション",
L10nKey::PanelConversationSubtitle => "会話",
L10nKey::PanelTurnAltScreenNow => {
"全画面プログラムがこのペインを占有している間は、戻る先がありません"
}
L10nKey::PanelTurnNoScrollback => {
"このターンは代替画面に描かれたため、スクロールバックに残っていません"
}
L10nKey::PanelProcessesSubtitle => "プロセス",
L10nKey::PanelPortsSubtitle => "ポート",
L10nKey::PanelCwd => "作業ディレクトリ",
+2
View File
@@ -768,6 +768,8 @@ l10n_keys! {
PanelMoreChangedFiles,
PanelSessionSubtitle,
PanelConversationSubtitle,
PanelTurnAltScreenNow,
PanelTurnNoScrollback,
PanelProcessesSubtitle,
PanelPortsSubtitle,
PanelCwd,
+2
View File
@@ -990,6 +990,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelNoChangesHint => "worktree 是干净的。",
L10nKey::PanelSessionSubtitle => "会话",
L10nKey::PanelConversationSubtitle => "对话",
L10nKey::PanelTurnAltScreenNow => "全屏程序占着此窗格,没有 scrollback 可跳回。",
L10nKey::PanelTurnNoScrollback => "这一轮画在 alt screen 上,scrollback 里没有留下它。",
L10nKey::PanelProcessesSubtitle => "进程",
L10nKey::PanelPortsSubtitle => "端口",
L10nKey::PanelCwd => "工作目录",
+50 -6
View File
@@ -1021,7 +1021,7 @@ impl Tty7App {
}
/// The agent's conversation, one row per turn, each a way back to where
/// that turn started in the scrollback.
/// that turn started in the scrollback — when there is one to go back to.
///
/// It sits under the session facts rather than in a tab of its own: this is
/// something *this pane* is, like its shell and its cwd, and the tab strip
@@ -1042,15 +1042,18 @@ impl Tty7App {
if turns.is_empty() {
return None;
}
// A full-screen program owns the whole drawing surface, so there is no
// scrollback under it to land in — while one is up every jump is a
// no-op, whatever anchor the turn is carrying. An agent that renders
// that way (Claude Code's `/tui fullscreen`, Codex) puts every row in
// this section here.
let alt_now = leaf.read(cx).on_alt_screen();
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let count = turns.len().to_string();
let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(1.)).gap(px(1.));
for turn in turns {
let id = turn.id;
// Only a turn that was drawn into the scrollback has somewhere to
// go: one that began on the alt screen is history the pane never
// kept, so its row reads as a label and not as a link.
let jumpable = turn.row.is_some();
let jumpable = turn_is_jumpable(turn.row, alt_now);
let dot = {
let d = div().flex_none().size(px(7.)).rounded_full();
if turn.done {
@@ -1079,6 +1082,18 @@ impl Tty7App {
});
}))
})
// A row that goes nowhere says why on hover. Muted text is
// the whole of what it says otherwise, and grey reads as
// "less important" long before it reads as "not a link".
.when(!jumpable, |this| {
let tip = t(match alt_now {
true => L10nKey::PanelTurnAltScreenNow,
false => L10nKey::PanelTurnNoScrollback,
});
this.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(tip).build(window, cx)
})
})
.child(dot)
.child(
div()
@@ -1488,9 +1503,19 @@ fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> Strin
crate::ui::path_display::abbreviate_home(&path.to_string_lossy(), home).into_owned()
}
/// Whether a turn's row is a link back into the scrollback, or only a label.
///
/// Both halves have to hold, and they are the same two conditions
/// [`TerminalView::scroll_to_agent_turn`](crate::terminal::view::TerminalView)
/// refuses on — deliberately, because a row that draws as a link and then does
/// nothing is worse than one that never offered. Keep the two in step.
fn turn_is_jumpable(row: Option<i64>, alt_now: bool) -> bool {
row.is_some() && !alt_now
}
#[cfg(test)]
mod tests {
use super::{InfoRow, InfoValue, split_path_leaf};
use super::{InfoRow, InfoValue, split_path_leaf, turn_is_jumpable};
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
InfoRow {
@@ -1534,6 +1559,25 @@ mod tests {
);
}
#[test]
fn a_turn_offers_the_jump_only_where_the_jump_would_land() {
assert!(
turn_is_jumpable(Some(42), false),
"a turn anchored in the scrollback of a pane on the normal screen"
);
assert!(
!turn_is_jumpable(None, false),
"a turn that began on the alt screen was never written down"
);
// The one this pair exists for: the anchor survives the switch into a
// full-screen renderer, and the row it points at does not. Before, the
// row kept its pointer and its hover fill and swallowed every click.
assert!(
!turn_is_jumpable(Some(42), true),
"and an anchor is no use while a full-screen program owns the pane"
);
}
#[test]
fn counts_are_a_button_only_when_there_is_a_diff_to_open() {
assert!(