Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from textual.containers import Container, VerticalScroll
from textual.content import Content
from textual.css.query import NoMatches
from textual.dom import NoScreen
from textual.events import Click
from textual.message import Message
from textual.notifications import Notification as _Notification, Notify as _Notify
Expand Down Expand Up @@ -2517,6 +2518,34 @@ def _is_scrollable(self) -> bool:
return self.max_scroll_y > 0


_MIN_TOAST_ROWS = 6
"""Rows kept clear above the bottom chrome so a toast is never pushed offscreen.

A toast is at most a few rows tall (1 row of margin, 1 of padding either side,
and the wrapped body). On a short terminal — or with a tall composed message in
the chat input — anchoring above the chrome would otherwise leave the rack no
room at all, which is worse than overlapping the input.
"""


class _BottomChrome(Container):
"""Bottom container the toast rack is anchored above.

Textual docks `ToastRack` to the bottom of the screen, where it covers the
chat input. This container's height changes whenever the chat input grows,
or the startup tip / goal status / subagent panels appear, so it announces
its own resizes and lets the app re-anchor the rack (see
`DeepAgentsApp._anchor_toast_rack`).
"""

class Resized(Message, namespace="bottom_chrome"):
"""Posted whenever the bottom chrome's geometry changes."""

def on_resize(self, _event: Resize) -> None:
"""Announce the new geometry so the app can re-anchor toasts."""
self.post_message(self.Resized())


class _MainScreen(Screen[None]):
"""Default screen containing the main chat interface.

Expand Down Expand Up @@ -3787,7 +3816,7 @@ def compose(self) -> ComposeResult:
id="welcome-banner",
)
yield Container(id="messages")
with Container(id="bottom-app-container"):
with _BottomChrome(id="bottom-app-container"):
# Live fan-out panel for subagents spawned from js_eval. Hidden
# until the first spawn event; sits at the top of the bottom
# container, above the startup tip and input.
Expand Down Expand Up @@ -6534,6 +6563,9 @@ def on_chat_scrolled(self, _event: _ChatScroll.Scrolled) -> None:

def on_resize(self, _event: Resize) -> None:
"""Scale cached message heights when terminal width changes."""
# A terminal resize moves the bottom chrome even when its height is
# unchanged, so re-anchor before the early returns below.
self._anchor_toast_rack()
try:
chat = self.query_one("#chat", VerticalScroll)
except NoMatches:
Expand All @@ -6549,6 +6581,37 @@ def on_resize(self, _event: Resize) -> None:
self._message_measure_width = width
self._sync_transcript_spacers()

def on_bottom_chrome_resized(self, _event: _BottomChrome.Resized) -> None:
"""Re-anchor toasts when the chat input (or a bottom panel) resizes."""
self._anchor_toast_rack()

def _anchor_toast_rack(self) -> None:
"""Float toasts above the chat input instead of on top of it.

Textual docks its `ToastRack` to the bottom of the screen, so toasts
cover the chat input the user is typing into. Growing the rack's bottom
margin to the height of the bottom chrome (panels, chat input, and
status bar) lifts toasts to just above the input box.
"""
try:
chrome = self.query_one("#bottom-app-container", _BottomChrome)
# Resolve the rack from the chrome's own screen: every screen
# composes its own rack, and modals must not retune the main one.
screen = chrome.screen
rack = screen.get_child_by_id("textual-toastrack")
except (NoMatches, NoScreen):
return
region = chrome.region
if not region:
# Not laid out yet; the chrome's first resize re-anchors.
return
screen_height = screen.size.height
headroom = max(screen_height - _MIN_TOAST_ROWS, 0)
margin_bottom = min(max(screen_height - region.y, 0), headroom)
Comment thread
robin-agent[bot] marked this conversation as resolved.
Resolved
if rack.styles.margin.bottom == margin_bottom:
return
rack.styles.margin = (0, 0, margin_bottom, 0)

def _update_status(self, message: str) -> None:
"""Update the status bar with a message."""
if self._status_bar:
Expand Down
65 changes: 65 additions & 0 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from deepagents_code._version import CHANGELOG_URL, __version__
from deepagents_code.app import (
_DEEPAGENTS_IMPORT_LOCK,
_MIN_TOAST_ROWS,
_TYPING_IDLE_THRESHOLD_SECONDS,
DeepAgentsApp,
DeferredAction,
Expand Down Expand Up @@ -24756,6 +24757,70 @@ async def test_toast_click_event_opens_notification_center(self) -> None:
assert isinstance(app.screen, NotificationCenterScreen)


class TestToastAnchoring:
"""Toasts float above the chat input instead of covering it."""

@staticmethod
def _rack(app: DeepAgentsApp) -> Widget:
"""Return the toast rack composed into the app's main screen."""
return app.screen.get_child_by_id("textual-toastrack")

@staticmethod
def _chrome(app: DeepAgentsApp) -> Widget:
"""Return the bottom chrome (panels + chat input) container."""
return app.query_one("#bottom-app-container", Widget)

async def test_rack_margin_clears_the_bottom_chrome(self) -> None:
"""The rack's bottom margin reaches the top of the bottom chrome."""
app = DeepAgentsApp()
async with app.run_test(notifications=True) as pilot:
await pilot.pause()
expected = app.screen.size.height - self._chrome(app).region.y
assert expected > 1
assert self._rack(app).styles.margin.bottom == expected

async def test_toasts_do_not_cover_the_chat_input(self) -> None:
"""Displayed toasts stack directly above the bottom chrome."""
from textual.widgets._toast import Toast as _Toast

app = DeepAgentsApp()
async with app.run_test(notifications=True) as pilot:
await pilot.pause()
app.notify("first", timeout=60)
app.notify("second", timeout=60)
await pilot.pause()

toasts = list(app.screen.query(_Toast))
assert toasts
chrome_top = self._chrome(app).region.y
assert max(toast.region.bottom for toast in toasts) == chrome_top

async def test_growing_chat_input_lifts_the_rack(self) -> None:
"""A taller chat input pushes toasts further up the screen."""
app = DeepAgentsApp()
async with app.run_test(notifications=True) as pilot:
await pilot.pause()
before = self._rack(app).styles.margin.bottom

app.query_one("#input-area", ChatInput).styles.height = 12
await pilot.pause()

after = self._rack(app).styles.margin.bottom
assert after > before
assert after == app.screen.size.height - self._chrome(app).region.y

async def test_short_terminal_keeps_the_rack_onscreen(self) -> None:
"""A chrome taller than the terminal must not push toasts offscreen."""
app = DeepAgentsApp()
async with app.run_test(notifications=True, size=(80, 12)) as pilot:
await pilot.pause()
app.query_one("#input-area", ChatInput).styles.height = 20
await pilot.pause()

margin_bottom = self._rack(app).styles.margin.bottom
assert 0 <= margin_bottom <= 12 - _MIN_TOAST_ROWS


class TestFatalErrorRedaction:
"""`_fatal_error` must not leak local variables (which carry secrets).

Expand Down