fix(code): keep notification center open for API-key entry (#4568)

Pressing `Esc` in the API-key entry prompt reached from a notification
now returns to the notification center instead of closing all open
modals.

---

When a user opens the notification center with `ctrl+n`, selects a
service notification, and chooses **Enter API key**, they are still
working through that notification. If they decide not to enter a key
yet, `Esc` should take them back to the notification details so they can
review, suppress, or choose another notification — not drop them back
into the main chat screen.

Before this change, selecting **Enter API key** dismissed the
notification center before the API-key prompt opened. Because the center
was already gone, pressing `Esc` in the prompt fell through to the base
chat screen instead of returning to the notifications viewer.

For example:

- Before:
  1. Press `ctrl+n` to open notifications.
  2. Open a Tavily API-key notification.
  3. Select **Enter API key**.
  4. Press `Esc` in the API-key prompt.
5. The prompt closes and the user lands in the main chat screen, losing
the notification context.
- After:
  1. Press `ctrl+n` to open notifications.
  2. Open a Tavily API-key notification.
  3. Select **Enter API key**.
  4. Press `Esc` in the API-key prompt.
5. The prompt closes and the user returns to the notification center,
with the notification details still available.

The fix treats `ENTER_API_KEY` as an in-place action, mirroring the
existing `SUPPRESS` handling: instead of dismissing the center, the
detail modal posts a `NotificationActionRequested` message. The app
handles it in a worker so the message pump is not blocked while the
prompt awaits input, pushes the `AuthPromptScreen` on top of the
still-mounted center, then reloads the center once the action resolves.
Saving the key drops the notification entry, and the center dismisses
only when the list becomes empty.

Made by [Open
SWE](https://openswe.vercel.app/agents/7f8edaca-58bc-3065-4243-6edc6d5d3449)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-08 20:27:44 -04:00
committed by GitHub
parent 1398feeca5
commit 6e8941776c
4 changed files with 378 additions and 14 deletions
+73 -8
View File
@@ -367,6 +367,7 @@ if TYPE_CHECKING:
from deepagents_code.tui.widgets.goal_review import GoalReviewMenu, GoalReviewResult
from deepagents_code.tui.widgets.model_selector import ModelSelectorScreen
from deepagents_code.tui.widgets.notification_center import (
NotificationActionRequested,
NotificationSuppressRequested,
)
from deepagents_code.tui.widgets.restart_prompt import RestartChoice
@@ -14180,24 +14181,78 @@ class DeepAgentsApp(App):
message: NotificationSuppressRequested,
) -> None:
"""Suppress the notice in place and refresh the open center."""
message.stop()
await self._dispatch_notification_action(message.key, ActionId.SUPPRESS)
await self._refresh_open_center()
def on_notification_action_requested(
self,
message: NotificationActionRequested,
) -> None:
"""Dispatch an in-place notification action, keeping the center open.
The action (e.g. `ENTER_API_KEY`) pushes a follow-up modal on top
of the still-mounted center, so it must run in a worker rather than
block the message pump while that modal awaits input.
"""
message.stop()
# `group` is for observability only; with `exclusive=False` it does
# not single-flight. `exclusive=True` would be wrong here — a
# re-trigger for the same key would cancel an in-progress API-key
# prompt mid-entry.
self.run_worker(
self._dispatch_in_place_notification_action(
message.key,
message.action_id,
),
exclusive=False,
group=f"notification-action-{message.key}",
)
async def _dispatch_in_place_notification_action(
self,
key: str,
action_id: ActionId,
) -> None:
"""Run an in-place action, then refresh the still-open center.
The action's follow-up modal (e.g. the API-key prompt) stacks on
top of the center so Esc returns to it. Once the action resolves,
the center is reloaded so any handled entry drops out; reloading an
empty list dismisses the center.
"""
await self._dispatch_notification_action(key, action_id)
await self._refresh_open_center()
async def _refresh_open_center(self) -> None:
"""Reload the notification center if it is still the active screen.
Shared tail of the in-place action handlers (SUPPRESS and the
`IN_PLACE_ACTIONS` worker). No-ops when the center is no longer on
top e.g. concurrently dismissed because the registry is already
authoritative and the next open re-renders from it.
A `NoMatches` from a dismiss/mount race a concurrent dismissal can
detach the `VerticalScroll` before `reload` queries it is
downgraded to a warning toast; the worst case is a stale or
partially-rebuilt row list, which the next open heals. Any other
exception is a genuine `reload` bug and propagates so it surfaces
instead of being mischaracterized as a transient race.
"""
from textual.css.query import NoMatches
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
message.stop()
await self._dispatch_notification_action(message.key, ActionId.SUPPRESS)
screen = self.screen
if not isinstance(screen, NotificationCenterScreen):
return
try:
await screen.reload(self._notice_registry.list_all())
except Exception as exc: # defend against dismiss/mount races
# A concurrent dismissal can detach the VerticalScroll before
# `reload` queries it. The worst case is a stale row list,
# which the next open of the center will heal. Log + toast
# so the failure surfaces instead of vanishing into a worker.
except NoMatches as exc: # dismiss/mount race detached the scroll
logger.warning(
"Failed to refresh notification center after suppress: %s",
"Failed to refresh notification center after in-place action: %s",
exc,
exc_info=True,
)
@@ -14432,7 +14487,17 @@ class DeepAgentsApp(App):
# exactly membership in `SERVICE_API_KEY_ENV`.
env_var = SERVICE_API_KEY_ENV.get(service)
if env_var is None:
# Misconfiguration: an ENTER_API_KEY action on a tool with no
# known env var. Log for devs, and tell the user why nothing
# opened — via the in-place path the center would otherwise just
# reload unchanged with no explanation.
self._log_unknown_action(entry, ActionId.ENTER_API_KEY)
self.notify(
f"No API-key entry is available for {service}.",
severity="warning",
timeout=6,
markup=False,
)
return
from deepagents_code.tui.widgets.auth import AuthPromptScreen, AuthResult
@@ -4,11 +4,15 @@ Surfaces a list of `PendingNotification` entries as single-line rows.
Selecting a row drills into a dedicated detail modal
(`UpdateAvailableScreen` for update entries, `NotificationDetailScreen`
otherwise) stacked on top of the center. When the detail modal
dismisses with any non-SUPPRESS action the center dismisses with a
`NotificationActionResult` so the app layer can dispatch; SUPPRESS is
handled in place via `NotificationSuppressRequested` so the remaining
notifications stay reachable. When the detail cancels, the center
stays open on the list.
dismisses with a terminal action (one that closes the center) the
center dismisses with a `NotificationActionResult` so the app layer can
dispatch. Actions that must keep the center open are handled in place:
SUPPRESS via
`NotificationSuppressRequested` (so the remaining notifications stay
reachable) and actions in `IN_PLACE_ACTIONS` via
`NotificationActionRequested` (so a follow-up modal, e.g. the API-key
prompt, stacks on top and Esc returns to the center). When the detail
cancels, the center stays open on the list.
"""
from __future__ import annotations
@@ -88,6 +92,50 @@ class NotificationSuppressRequested(Message):
self.key = key
IN_PLACE_ACTIONS: frozenset[ActionId] = frozenset({ActionId.ENTER_API_KEY})
"""Actions handled in place without dismissing the center.
Each opens a follow-up modal on top of the center, so the center stays
mounted and Esc in that modal returns here (rationale in
`NotificationActionRequested`). SUPPRESS is also handled in place but
routes through its own `NotificationSuppressRequested` message, so it is
deliberately excluded from this set.
"""
class NotificationActionRequested(Message):
"""Posted for an action that opens a follow-up modal in place.
Some actions (those in `IN_PLACE_ACTIONS`, currently `ENTER_API_KEY`)
push another modal, such as the API-key prompt, on top of the
still-open center. Dismissing the center first would drop that stack,
so Esc in the follow-up modal would fall through to the base screen
instead of returning here. The app handles this message by dispatching
the action while the center stays mounted, then reloading it with the
refreshed registry snapshot.
"""
def __init__(self, key: str, action_id: ActionId) -> None:
"""Initialize the message.
Args:
key: Registry key of the notification the action targets.
action_id: The in-place action the user selected. Must be a
member of `IN_PLACE_ACTIONS`.
Raises:
ValueError: If `action_id` is not an in-place action, which
would be a programmer error (the message is only meant to
carry actions that keep the center open).
"""
super().__init__()
if action_id not in IN_PLACE_ACTIONS:
msg = f"{action_id} is not an in-place action"
raise ValueError(msg)
self.key = key
self.action_id = action_id
class _NotificationRow(Static):
"""Clickable single-line row displaying a notification's title."""
@@ -332,6 +380,12 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
# in `NotificationSuppressRequested`'s class docstring.
self.post_message(NotificationSuppressRequested(entry.key))
return
if action_id in IN_PLACE_ACTIONS:
# Keep the center open so the follow-up modal (e.g. the
# API-key prompt) stacks on top and Esc returns here.
# Rationale is in `NotificationActionRequested`'s docstring.
self.post_message(NotificationActionRequested(entry.key, action_id))
return
self.dismiss(NotificationActionResult(entry.key, action_id))
try:
+197 -1
View File
@@ -15205,6 +15205,28 @@ def _missing_dep_entry(
)
def _service_dep_entry(tool: str = "tavily") -> PendingNotification:
"""A missing-dep entry whose primary action opens the API-key prompt."""
from deepagents_code.notifications import (
ActionId,
MissingDepPayload,
NotificationAction,
PendingNotification,
)
return PendingNotification(
key=f"dep:{tool}",
title="Web search disabled",
body=f"No {tool} API key is set.",
actions=(
NotificationAction(ActionId.ENTER_API_KEY, "Enter API key", primary=True),
NotificationAction(ActionId.OPEN_WEBSITE, "Open website"),
NotificationAction(ActionId.SUPPRESS, "Don't show"),
),
payload=MissingDepPayload(tool=tool, url=f"https://{tool}.com"),
)
def _update_entry(latest: str = "2.0.0") -> PendingNotification:
from deepagents_code.notifications import (
ActionId,
@@ -15504,6 +15526,8 @@ class TestNotificationCenterIntegration:
async with app.run_test() as pilot:
await pilot.pause()
app._push_screen_wait = AsyncMock() # ty: ignore
messages: list[str] = []
app.notify = lambda message, **_: messages.append(message) # ty: ignore
with caplog.at_level(logging.WARNING):
await app._dispatch_notification_action(
entry.key, ActionId.ENTER_API_KEY
@@ -15511,9 +15535,181 @@ class TestNotificationCenterIntegration:
await pilot.pause()
app._push_screen_wait.assert_not_awaited() # ty: ignore
# Non-service tool: nothing opened, entry untouched, dev-facing log only.
# Non-service tool: nothing opened and the entry is untouched, but the
# user is told why (dev-facing log) plus a warning toast.
assert app._notice_registry.get("dep:ripgrep") is entry
assert "Unknown action_id" in caplog.text
assert any("No API-key entry is available" in m for m in messages)
async def test_enter_api_key_esc_returns_to_notification_center(self) -> None:
"""Esc in the API-key prompt returns to the center, not the base screen."""
from deepagents_code.tui.widgets.auth import AuthPromptScreen
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._notice_registry.add(_service_dep_entry("tavily"))
async with app.run_test() as pilot:
await pilot.pause()
app._open_notification_center()
await pilot.pause()
assert isinstance(app.screen, NotificationCenterScreen)
await pilot.press("enter") # drill into the tavily entry
await pilot.pause()
# The primary action (ENTER_API_KEY) is selected; open it.
await pilot.press("enter")
for _ in range(6):
await pilot.pause()
# The API-key prompt stacked on top of the still-open center.
assert isinstance(app.screen, AuthPromptScreen)
await pilot.press("escape")
for _ in range(6):
await pilot.pause()
# Back at the notification center, not the base chat screen.
assert isinstance(app.screen, NotificationCenterScreen)
assert app._notice_registry.get("dep:tavily") is not None
async def test_enter_api_key_save_reloads_open_center(self) -> None:
"""Saving the key removes the entry and reloads the still-open center."""
from deepagents_code.notifications import ActionId
from deepagents_code.tui.widgets.auth import AuthResult
from deepagents_code.tui.widgets.notification_center import (
NotificationActionRequested,
NotificationCenterScreen,
_NotificationRow,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._notice_registry.add(_service_dep_entry("tavily"))
app._notice_registry.add(_missing_dep_entry("ripgrep"))
async with app.run_test() as pilot:
await pilot.pause()
app._open_notification_center()
await pilot.pause()
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
app.notify = lambda *_a, **_k: None # ty: ignore
app.screen.post_message(
NotificationActionRequested("dep:tavily", ActionId.ENTER_API_KEY)
)
for _ in range(6):
await pilot.pause()
assert app._notice_registry.get("dep:tavily") is None
assert isinstance(app.screen, NotificationCenterScreen)
keys = [r.notification.key for r in app.screen.query(_NotificationRow)]
assert keys == ["dep:ripgrep"]
async def test_enter_api_key_save_last_entry_dismisses_center(self) -> None:
"""Saving the only key removes it and dismisses the emptied center."""
from deepagents_code.notifications import ActionId
from deepagents_code.tui.widgets.auth import AuthResult
from deepagents_code.tui.widgets.notification_center import (
NotificationActionRequested,
NotificationCenterScreen,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._notice_registry.add(_service_dep_entry("tavily"))
async with app.run_test() as pilot:
await pilot.pause()
app._open_notification_center()
await pilot.pause()
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
app.notify = lambda *_a, **_k: None # ty: ignore
app.screen.post_message(
NotificationActionRequested("dep:tavily", ActionId.ENTER_API_KEY)
)
for _ in range(6):
await pilot.pause()
assert app._notice_registry.get("dep:tavily") is None
assert not isinstance(app.screen, NotificationCenterScreen)
async def test_enter_api_key_reload_failure_surfaces_toast(self) -> None:
"""A reload race after saving logs and toasts instead of vanishing."""
from textual.css.query import NoMatches
from deepagents_code.notifications import ActionId
from deepagents_code.tui.widgets.auth import AuthResult
from deepagents_code.tui.widgets.notification_center import (
NotificationActionRequested,
NotificationCenterScreen,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._notice_registry.add(_service_dep_entry("tavily"))
app._notice_registry.add(_missing_dep_entry("ripgrep"))
async with app.run_test() as pilot:
await pilot.pause()
app._open_notification_center()
await pilot.pause()
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
toasts: list[tuple[str, object]] = []
app.notify = lambda message, **kw: toasts.append( # ty: ignore
(message, kw.get("severity"))
)
assert isinstance(app.screen, NotificationCenterScreen)
race = NoMatches("scroll detached")
app.screen.reload = AsyncMock(side_effect=race) # ty: ignore
app.screen.post_message(
NotificationActionRequested("dep:tavily", ActionId.ENTER_API_KEY)
)
for _ in range(6):
await pilot.pause()
# The save side effect still applied, and the failed refresh
# surfaced as a warning toast rather than vanishing in the worker.
assert app._notice_registry.get("dep:tavily") is None
assert any(
sev == "warning" and "Could not refresh notifications" in msg
for msg, sev in toasts
)
async def test_refresh_open_center_noops_when_center_not_top(self) -> None:
"""The guard skips reload when the center is no longer the active screen."""
from textual.screen import ModalScreen
from textual.widgets import Static
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
class _Blocker(ModalScreen[None]):
def compose(self): # noqa: ANN202
yield Static("blocker")
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._notice_registry.add(_service_dep_entry("tavily"))
async with app.run_test() as pilot:
await pilot.pause()
app._open_notification_center()
await pilot.pause()
center = app.screen
assert isinstance(center, NotificationCenterScreen)
center.reload = AsyncMock() # ty: ignore
# Push another modal so the center is no longer on top, then run
# the shared refresh directly: it must see a non-center screen
# and skip the reload entirely.
await app.push_screen(_Blocker())
await pilot.pause()
assert not isinstance(app.screen, NotificationCenterScreen)
await app._refresh_open_center()
center.reload.assert_not_awaited() # ty: ignore
async def test_suppress_message_reloads_center_in_place(self) -> None:
"""Posting NotificationSuppressRequested refreshes the open center."""
@@ -13,6 +13,7 @@ from deepagents_code.notifications import (
UpdateAvailablePayload,
)
from deepagents_code.tui.widgets.notification_center import (
NotificationActionRequested,
NotificationActionResult,
NotificationCenterScreen,
NotificationSuppressRequested,
@@ -39,6 +40,20 @@ def _dep_entry(key: str = "dep:ripgrep") -> PendingNotification:
)
def _service_entry(key: str = "dep:tavily") -> PendingNotification:
return PendingNotification(
key=key,
title="Web search disabled",
body="No Tavily API key is set.",
actions=(
NotificationAction(ActionId.ENTER_API_KEY, "Enter API key", primary=True),
NotificationAction(ActionId.OPEN_WEBSITE, "Open tavily.com"),
NotificationAction(ActionId.SUPPRESS, "Don't show notification again"),
),
payload=MissingDepPayload(tool="tavily", url="https://tavily.com"),
)
def _update_entry() -> PendingNotification:
return PendingNotification(
key="update:available",
@@ -157,6 +172,40 @@ class TestNotificationCenterScreen:
assert [m.key for m in messages] == ["dep:ripgrep"]
async def test_in_place_action_keeps_center_open_and_posts_message(self) -> None:
"""ENTER_API_KEY posts a request message and leaves the center up."""
messages: list[NotificationActionRequested] = []
class _App(App):
def on_notification_action_requested(
self, message: NotificationActionRequested
) -> None:
messages.append(message)
app = _App()
screen = NotificationCenterScreen([_service_entry()])
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("enter") # drill into the tavily entry
await pilot.pause()
assert isinstance(app.screen, NotificationDetailScreen)
# ENTER_API_KEY is the primary (first) action, selected on mount.
await pilot.press("enter")
await pilot.pause()
# Center is still the active screen; no dismissal fired.
assert isinstance(app.screen, NotificationCenterScreen)
assert [(m.key, m.action_id) for m in messages] == [
("dep:tavily", ActionId.ENTER_API_KEY)
]
def test_action_requested_rejects_non_in_place_action(self) -> None:
"""Constructing the message with a terminal action fails fast."""
with pytest.raises(ValueError, match="not an in-place action"):
NotificationActionRequested("dep:ripgrep", ActionId.INSTALL)
async def test_reload_rebuilds_rows_and_preserves_selection_by_key(self) -> None:
"""`reload` re-renders the list and keeps the cursor on the same entry."""
dep = _dep_entry()