Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
118 changes: 94 additions & 24 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3254,6 +3254,13 @@ def __init__(
they cannot drift out of sync.
"""

self._last_thread_unchanged: tuple[str, float] | None = None
"""Most recent same-thread toast, as `(text, monotonic timestamp)`.

The same-thread counterpart of `_last_model_unchanged`, for re-selecting
the thread the session is already on.
"""

self._model_install_switching = False
"""True while a provider extra install-then-switch flow is active."""

Expand Down Expand Up @@ -9740,7 +9747,10 @@ async def _build_thread_message(
(e.g. `' (Resume with /threads -r)'`).

Returns:
`Content` with a clickable thread ID, or a plain string.
`Content` with a clickable thread ID, or a plain string. The
`Content` carries the same dim italic styling `AppMessage`
applies to plain strings so linked and unlinked thread notes
look identical apart from the link.
"""
from deepagents_code.config import build_langsmith_thread_url

Expand All @@ -9749,14 +9759,24 @@ async def _build_thread_message(
asyncio.to_thread(build_langsmith_thread_url, thread_id),
timeout=2.0,
)
except (TimeoutError, Exception): # noqa: BLE001 # Resilient non-interactive mode error handling
except Exception: # Resilient non-interactive mode error handling
# Unlinked thread IDs are the only symptom, and they look identical
# to tracing simply being unconfigured — so log the cause. Covers a
# rejected `LANGSMITH_API_KEY`, a changed LangSmith payload shape,
# and network failures, none of which the user can otherwise see.
logger.debug(
"Could not resolve LangSmith thread URL for %s; rendering plain text",
thread_id,
exc_info=True,
)
url = None

if url:
note_style = TStyle(dim=True, italic=True)
return Content.assemble(
f"{prefix}: ",
(thread_id, TStyle(link=url)),
suffix,
(f"{prefix}: ", note_style),
(thread_id, TStyle(dim=True, italic=True, link=url)),
(suffix, note_style),
)
return f"{prefix}: {thread_id}{suffix}"

Expand Down Expand Up @@ -22620,7 +22640,15 @@ async def _resume_thread(self, thread_id: str) -> None:
if cwd_choice == "abort":
return
if await asyncio.to_thread(self._cwd_paths_equal, self._cwd, prev_cwd):
await self._mount_message(AppMessage(f"Already on thread: {thread_id}"))
self._last_thread_unchanged = self._notify_unchanged_once(
f"Already on thread: {thread_id}",
self._last_thread_unchanged,
)
# Log unconditionally, outside the toast dedup: the toast is
# transient and may be suppressed, so this is the only durable
# record that the resume was a deliberate no-op rather than a
# dropped command. Mirrors the same-model path.
logger.info("Thread unchanged (%s); resume was a no-op", thread_id)
else:
from deepagents_code.hooks.models.domain import (
SessionEndCause,
Expand All @@ -22643,6 +22671,7 @@ async def _resume_thread(self, thread_id: str) -> None:
# Save previous state for rollback on failure
prev_thread_id = self._lc_thread_id
prev_session_thread = self._session_state.thread_id
prev_previous_thread = self._session_state.previous_thread_id
prev_cwd = Path(self._cwd)

cwd_choice = await self._offer_thread_cwd_switch(
Expand Down Expand Up @@ -22711,9 +22740,15 @@ async def _resume_thread(self, thread_id: str) -> None:
# The switch succeeded: record the thread we just left so a
# subsequent bare `/threads -r` steps back to it rather than
# resolving `previous == current` and reporting "Already on
# thread". Set only after the last statement that can raise, so a
# failed switch (handled below) never leaves a stale pointer.
# thread". Set once the switch is materially complete -- the thread
# ID is committed and history is loaded. `_run_session_start_hook`
# below can still raise, so the rollback path restores this pointer
# explicitly rather than relying on statement order.
self._session_state.previous_thread_id = prev_session_thread

# Landing on a new thread re-arms the same-thread toast, so stepping
# back to a thread and re-selecting it announces itself again.
self._last_thread_unchanged = None
if not await self._run_session_start_hook(SessionStartCause.RESUME):
return
except Exception as exc:
Expand All @@ -22731,6 +22766,11 @@ async def _resume_thread(self, thread_id: str) -> None:
# Restore previous thread IDs so the user can retry
self._session_state.thread_id = prev_session_thread
self._lc_thread_id = prev_thread_id
# Also restore the back-pointer. A raise after it was set (the
# session-start hook) would otherwise leave `previous == current`,
# making a later bare `/threads -r` a no-op with nowhere to step
# back to.
self._session_state.previous_thread_id = prev_previous_thread
self._update_welcome_banner(
prev_session_thread,
missing_message=(
Expand Down Expand Up @@ -22790,6 +22830,48 @@ async def _mount_resume_adoption_failure(
body += f" {hint}"
await self._mount_message(ErrorMessage(body))

def _notify_unchanged_once(
self, message: str, last: tuple[str, float] | None
) -> tuple[str, float]:
"""Toast a no-op notice unless an identical toast is presumed on-screen.

A no-op re-selection — of the model or thread the session is already on,
for example — is transient feedback, not part of the conversation, so it
surfaces as a toast rather than an inline chat message.

Suppression is time-based: it lasts one `NOTIFICATION_TIMEOUT`, which
matches the toast lifetime only because `notify` is called without a
`timeout` override. Nothing inspects live toast state, so "on-screen" is
a presumption — a toast the user clicked away still suppresses. Once the
window expires, a later intentional no-op selection can toast again.

Callers own the record: pass the field you keep it in and assign the
result straight back, or dedup silently stops working. Separate fields
per call site keep unrelated notices from suppressing each other.

Args:
message: The notice to toast. Interpolated identifiers are rendered
literally — `markup=False` is load-bearing, because identifiers
containing square brackets would otherwise crash Textual's toast
renderer when parsed as Rich markup.
last: The caller's previous `(text, monotonic timestamp)` record, or
`None` when nothing has been toasted yet.

Returns:
The record the caller should store, never `None`: the new toast's
text and timestamp, or `last` unchanged when suppressed. Only
the explicit re-arm sites clear a caller's field.
"""
now = _monotonic()
if (
last is not None
and last[0] == message
and (now - last[1]) < self.NOTIFICATION_TIMEOUT
):
return last
self.notify(message, markup=False)
return (message, now)

async def _switch_model(
self,
model_spec: str,
Expand Down Expand Up @@ -22939,22 +23021,10 @@ async def _switch_model(
self._sync_status_model()
params_suffix = _format_model_params(extra_kwargs)
if announce_unchanged:
message = f"Already using {current}{params_suffix}"
# Suppress only while the previous identical toast is
# presumed still on-screen. Once it expires, a later
# intentional no-op selection must be able to toast again.
now = _monotonic()
last = self._last_model_unchanged
if (
last is None
or last[0] != message
or (now - last[1]) >= self.NOTIFICATION_TIMEOUT
):
# A no-op re-selection is transient feedback, not part
# of the conversation, so surface it as a toast rather
# than an inline chat message.
self.notify(message, markup=False)
self._last_model_unchanged = (message, now)
self._last_model_unchanged = self._notify_unchanged_once(
f"Already using {current}{params_suffix}",
self._last_model_unchanged,
)
logger.info(
"Model unchanged (%s); model_params=%s",
current,
Expand Down
13 changes: 13 additions & 0 deletions libs/code/deepagents_code/tui/widgets/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4622,6 +4622,19 @@ def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event hand
"""Open style-embedded hyperlinks on single click."""
open_style_link(event)

def on_mouse_move(self, event: MouseMove) -> None:
"""Show a pointer cursor over embedded links, text cursor elsewhere."""
self.styles.pointer = "pointer" if event_targets_link(event) else "text"

def on_leave(self) -> None:
"""Restore the pointer shape when the mouse leaves the message.

`"text"` restates this widget's CSS default rather than clearing the
inline style, so a subclass declaring a different `pointer` would be
forced back to `text` on leave.
"""
self.styles.pointer = "text"


class SummarizationMessage(AppMessage):
"""Widget displaying a summarization completion notification."""
Expand Down
110 changes: 110 additions & 0 deletions libs/code/tests/unit_tests/tui/widgets/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4654,6 +4654,116 @@ def test_click_on_suspicious_url_is_blocked(self) -> None:
event.stop.assert_not_called()


class _AppMessageApp(App[None]):
"""Minimal app that mounts an `AppMessage` for runtime pointer tests."""

def compose(self) -> ComposeResult:
yield AppMessage("Resumed thread: tid-1", id="app-msg")


class TestAppMessageLinkPointer:
"""Tests for the pointer cursor shown when hovering embedded links."""

@staticmethod
def _move_event(
*, link: str | None = None, meta: dict | None = None
) -> SimpleNamespace:
"""Build a minimal mouse-move-like event exposing the hovered style."""
return SimpleNamespace(style=SimpleNamespace(link=link, meta=meta or {}))

async def test_hovering_link_sets_pointer_cursor(self) -> None:
"""An OSC 8 `Style(link=...)` span switches the pointer to pointer."""
async with _AppMessageApp().run_test() as pilot:
msg = pilot.app.query_one("#app-msg", AppMessage)

msg.on_mouse_move(self._move_event(link="https://example.com")) # ty: ignore

assert msg.styles.pointer == "pointer"

async def test_hovering_text_keeps_text_pointer(self) -> None:
"""Plain message text keeps the text pointer."""
async with _AppMessageApp().run_test() as pilot:
msg = pilot.app.query_one("#app-msg", AppMessage)

msg.on_mouse_move(self._move_event()) # ty: ignore

assert msg.styles.pointer == "text"

async def test_leave_resets_pointer(self) -> None:
"""Leaving the message resets the pointer after a link hover."""
async with _AppMessageApp().run_test() as pilot:
msg = pilot.app.query_one("#app-msg", AppMessage)
msg.on_mouse_move(self._move_event(link="https://example.com")) # ty: ignore

msg.on_leave()

assert msg.styles.pointer == "text"

async def test_link_then_text_resets_pointer_without_leaving(self) -> None:
"""Moving off a link onto plain text resets the pointer without leaving.

`on_leave` cannot cover this: the mouse stays inside the widget, so only
the handler's non-link branch clears the inline `pointer` set by the
previous move. Without it the hand cursor sticks over non-link text.
"""
async with _AppMessageApp().run_test() as pilot:
msg = pilot.app.query_one("#app-msg", AppMessage)
msg.on_mouse_move(self._move_event(link="https://example.com")) # ty: ignore
assert msg.styles.pointer == "pointer"

msg.on_mouse_move(self._move_event()) # ty: ignore

assert msg.styles.pointer == "text"


class _LinkedAppMessageApp(App[None]):
"""Mounts an `AppMessage` whose thread ID is a real OSC 8 link span."""

PREFIX = "Resumed thread: "
URL = "https://smith.langchain.com/o/org/projects/p/proj/t/tid-123"

def compose(self) -> ComposeResult:
from textual.content import Content
from textual.style import Style as TStyle

note = TStyle(dim=True, italic=True)
yield AppMessage(
Content.assemble(
(self.PREFIX, note),
("tid-123", TStyle(dim=True, italic=True, link=self.URL)),
),
id="app-msg",
)


class TestAppMessagePointerEventDelivery:
"""Pins that Textual actually delivers hover events to `AppMessage`.

Every other pointer test in this repo calls `on_mouse_move` directly with a
stand-in event, which cannot catch Textual routing `MouseMove` elsewhere or
leaving `event.style` unpopulated at the hovered offset. This drives a real
`pilot.hover` instead, so the delivery assumption the whole family of
pointer handlers shares is verified in one place.
"""

async def test_hover_over_real_link_span_toggles_pointer(self) -> None:
"""Hovering a real link span sets the pointer and moving off resets it."""
async with _LinkedAppMessageApp().run_test() as pilot:
msg = pilot.app.query_one("#app-msg", AppMessage)
# `AppMessage` pads by 1 column, so content offset N sits at N + 1.
link_x = len(_LinkedAppMessageApp.PREFIX) + 1
prefix_x = 1

await pilot.hover("#app-msg", offset=(prefix_x, 0))
assert msg.styles.pointer == "text"

await pilot.hover("#app-msg", offset=(link_x, 0))
assert msg.styles.pointer == "pointer"

await pilot.hover("#app-msg", offset=(prefix_x, 0))
assert msg.styles.pointer == "text"


class TestMountMessageIdSync:
"""Tests for widget id sync in `_mount_message`."""

Expand Down
Loading