mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): hide chat input action buttons in same frame as empty draft (#4178)
The floating `[ X ]`/`[ COPY ]` buttons on the chat input border only toggled on the deferred `TextArea.Changed` event. History navigation and clear set the draft text programmatically and suppress that event, so when a draft emptied (notably tabbing through input history), the buttons lingered for an extra frame. This syncs button visibility synchronously when text is set programmatically so they hide/show in lockstep with the draft. Made by [Open SWE](https://openswe.vercel.app/agents/89f7c717-fc07-c517-89f1-df142c799d2f) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -15,7 +15,7 @@ from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.content import Content
|
||||
from textual.css.query import NoMatches
|
||||
from textual.geometry import Offset
|
||||
from textual.geometry import Offset, Size
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
@@ -766,6 +766,66 @@ class ChatTextArea(TextArea):
|
||||
# refresh so it stays in view.
|
||||
self.call_after_refresh(self.scroll_cursor_visible)
|
||||
|
||||
def _refresh_scrollbars(self) -> None:
|
||||
"""Refresh scrollbars without flashing a transient vertical bar.
|
||||
|
||||
`TextArea` grows its `virtual_size` height the moment a row is inserted,
|
||||
a frame before this `height: auto` widget's container reflows to match.
|
||||
The base `_refresh_scrollbars` decides vertical visibility by comparing
|
||||
`virtual_size.height` against the stale `self._container_size.height`,
|
||||
so for that one frame the freshly inserted row looks like overflow and
|
||||
the scrollbar flashes on, then off once the container catches up.
|
||||
|
||||
The widget only ever truly overflows once its content exceeds the height
|
||||
it settles at — its resolved `max-height` (the layout chain above it is
|
||||
all `height: auto`, so it always grows to `min(content, max-height)`).
|
||||
Feed the base method that settled height instead of the mid-reflow one,
|
||||
so the bar appears only on genuine overflow and never flashes. All other
|
||||
base behavior (horizontal bar, anti-oscillation, scroll updates) is left
|
||||
untouched.
|
||||
|
||||
Deliberately overrides Textual's private `_refresh_scrollbars` and
|
||||
swaps the private `_container_size`; verified against Textual 8.2.7.
|
||||
Re-verify on major Textual upgrades.
|
||||
"""
|
||||
bound = self._settled_content_height()
|
||||
if bound is None:
|
||||
super()._refresh_scrollbars()
|
||||
return
|
||||
|
||||
original = self._container_size
|
||||
# Never report a viewport smaller than the settled height; `max(...)`
|
||||
# also guards the unlikely case where the real container is already
|
||||
# larger than the bound, so we only ever raise the comparison height.
|
||||
corrected_height = max(original.height, min(self.virtual_size.height, bound))
|
||||
if corrected_height == original.height:
|
||||
super()._refresh_scrollbars()
|
||||
return
|
||||
|
||||
self._container_size = Size(original.width, corrected_height)
|
||||
try:
|
||||
super()._refresh_scrollbars()
|
||||
finally:
|
||||
self._container_size = original
|
||||
|
||||
def _settled_content_height(self) -> int | None:
|
||||
"""Return the content-row height this widget settles at, if knowable.
|
||||
|
||||
Returns `None` (so the caller defers to the base behavior) unless the
|
||||
vertical overflow is `auto` and `max-height` resolves to a fixed cell
|
||||
count, the only case where the flash-suppression bound is well-defined.
|
||||
"""
|
||||
styles = self.styles
|
||||
if styles.overflow_y != "auto" or not styles.has_rule("max_height"):
|
||||
return None
|
||||
max_height = styles.max_height
|
||||
cells = max_height.cells if max_height is not None else None
|
||||
if cells is None:
|
||||
return None
|
||||
# box-sizing is border-box by default, so subtract border/padding to get
|
||||
# the content-row count the base method compares `virtual_size` against.
|
||||
return max(1, cells - self.gutter.height)
|
||||
|
||||
def _cursor_at_visual_top(self) -> bool:
|
||||
"""Return whether the cursor cannot move up further."""
|
||||
try:
|
||||
@@ -1232,6 +1292,10 @@ class ChatTextArea(TextArea):
|
||||
self._reset_paste_burst_state()
|
||||
self._skip_history_change_events += 1
|
||||
self.text = text
|
||||
# The suppressed Changed event (see above) is what would normally toggle
|
||||
# the clear/copy buttons, so sync them now to hide/show in the same frame
|
||||
# the text swaps — otherwise an emptied draft keeps the buttons for a frame.
|
||||
self._sync_owner_action_buttons(text)
|
||||
if cursor_at_end:
|
||||
self.move_cursor_to_end()
|
||||
else:
|
||||
@@ -1251,8 +1315,22 @@ class ChatTextArea(TextArea):
|
||||
self._skip_history_change_events += 1
|
||||
self._reset_paste_burst_state()
|
||||
self.text = ""
|
||||
# Hide the clear/copy buttons in the same frame the draft empties; the
|
||||
# suppressed Changed event would otherwise leave them for an extra frame.
|
||||
self._sync_owner_action_buttons("")
|
||||
self.move_cursor((0, 0))
|
||||
|
||||
def _sync_owner_action_buttons(self, text: str) -> None:
|
||||
"""Match the owner's clear/copy buttons to programmatically set text.
|
||||
|
||||
History/clear text swaps suppress the `Changed` event that normally
|
||||
drives button visibility, so the owner is updated directly to keep the
|
||||
buttons in lockstep with the draft (matching the `Changed`-path gate).
|
||||
"""
|
||||
owner = self._chat_input_owner
|
||||
if owner is not None:
|
||||
owner._set_action_buttons_visible(visible=bool(text.strip()))
|
||||
|
||||
def discard_text(self) -> bool:
|
||||
"""Clear the draft via an undoable edit (restorable with ctrl+z).
|
||||
|
||||
|
||||
@@ -227,6 +227,93 @@ class _RecordingApp(App[None]):
|
||||
self.submitted.append(event)
|
||||
|
||||
|
||||
class TestChatInputScrollbar:
|
||||
"""Regression tests for the chat input's vertical scrollbar behavior.
|
||||
|
||||
`ChatTextArea` is `height: auto; max-height: 8; overflow-y: auto`. The base
|
||||
`TextArea` grows its `virtual_size` height the moment a row is inserted, a
|
||||
frame before this auto-height widget's container reflows to match. Left to
|
||||
the base `_refresh_scrollbars`, that one-frame mismatch makes a short draft
|
||||
look like it overflows and flashes the vertical scrollbar on, then off. The
|
||||
`ChatTextArea._refresh_scrollbars` override corrects the comparison height
|
||||
so the bar appears only on genuine overflow.
|
||||
"""
|
||||
|
||||
async def test_newline_into_short_draft_never_flashes_scrollbar(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A newline below `max-height` must never show the vertical scrollbar.
|
||||
|
||||
Records the scrollbar decision on every refresh triggered by the insert
|
||||
(not just the settled state) so the one-frame flash is caught. Fails
|
||||
against the unpatched base behavior, which shows the bar mid-reflow.
|
||||
"""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat = app.query_one(ChatInput)
|
||||
text_area = chat.input_widget
|
||||
assert text_area is not None
|
||||
text_area.focus()
|
||||
await pilot.pause()
|
||||
|
||||
decisions: list[bool] = []
|
||||
original = ChatTextArea._refresh_scrollbars
|
||||
|
||||
def _record(self: ChatTextArea) -> None:
|
||||
original(self)
|
||||
decisions.append(self.show_vertical_scrollbar)
|
||||
|
||||
monkeypatch.setattr(ChatTextArea, "_refresh_scrollbars", _record)
|
||||
await pilot.press("shift+enter")
|
||||
for _ in range(4):
|
||||
await pilot.pause()
|
||||
|
||||
assert text_area.text == "\n"
|
||||
assert text_area.max_scroll_y == 0
|
||||
assert decisions, "expected a scrollbar refresh during the insert"
|
||||
assert not any(decisions), (
|
||||
f"vertical scrollbar flashed during newline insert: {decisions}"
|
||||
)
|
||||
assert text_area.show_vertical_scrollbar is False
|
||||
|
||||
async def test_overflowing_draft_keeps_visible_scrollbar(self) -> None:
|
||||
"""A draft taller than `max-height` keeps a real, scrollable bar."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat = app.query_one(ChatInput)
|
||||
text_area = chat.input_widget
|
||||
assert text_area is not None
|
||||
text_area.focus()
|
||||
await pilot.pause()
|
||||
|
||||
for _ in range(15):
|
||||
await pilot.press("shift+enter")
|
||||
for _ in range(3):
|
||||
await pilot.pause()
|
||||
|
||||
assert text_area.max_scroll_y > 0
|
||||
assert text_area.show_vertical_scrollbar is True
|
||||
assert text_area.scrollbar_size_vertical > 0
|
||||
# The cursor stays in view at the bottom of the overflowing draft.
|
||||
rel_y = text_area.cursor_location[0] - text_area.scroll_offset.y
|
||||
assert 0 <= rel_y < text_area.size.height
|
||||
|
||||
async def test_settled_content_height_resolves_max_height(self) -> None:
|
||||
"""The flash-suppression bound resolves to `max-height` in content rows.
|
||||
|
||||
Guards the override silently disabling itself: if `max-height` stops
|
||||
resolving to a fixed cell count, `_settled_content_height` returns
|
||||
`None` and the flash returns.
|
||||
"""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatInput).input_widget
|
||||
assert text_area is not None
|
||||
await pilot.pause()
|
||||
# max-height: 8 with no border/padding -> 8 content rows.
|
||||
assert text_area._settled_content_height() == 8
|
||||
|
||||
|
||||
class TestChatTextAreaKeybindings:
|
||||
"""Regression tests for terminal key aliases in the chat input."""
|
||||
|
||||
@@ -418,6 +505,31 @@ class TestInputActionButtons:
|
||||
await pilot.pause()
|
||||
assert actions.display is False
|
||||
|
||||
async def test_history_navigation_hides_buttons_in_same_frame(self) -> None:
|
||||
"""Emptying the draft via history/clear hides the buttons synchronously."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
actions = chat_input.query_one("#input-actions")
|
||||
|
||||
# Recalling content shows the buttons in the same frame (no pause).
|
||||
text_area.set_text_from_history("recalled", cursor_at_end=True)
|
||||
assert actions.display is True
|
||||
|
||||
# Tabbing forward to an empty draft hides them in the same frame,
|
||||
# before the suppressed Changed event would otherwise process.
|
||||
text_area.set_text_from_history("", cursor_at_end=True)
|
||||
assert actions.display is False
|
||||
|
||||
# clear_text empties the draft and hides them synchronously too.
|
||||
text_area.set_text_from_history("recalled", cursor_at_end=True)
|
||||
assert actions.display is True
|
||||
text_area.clear_text()
|
||||
assert actions.display is False
|
||||
|
||||
async def test_copy_button_double_click_does_not_select_label(self) -> None:
|
||||
"""Double-clicking `[ COPY ]` should not trigger Textual word selection."""
|
||||
app = _ChatInputTestApp()
|
||||
|
||||
Reference in New Issue
Block a user