From 3ef6bfc182b8aefb978a05008762b929fafb19d8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:02:52 -0500 Subject: [PATCH] A send right after a view refreshes its page reader is not refused as a changed route ChannelDestination.shown asked whether the view still held the very page-reader object the send was built on. A DM view replaces its reader after it opens (and on every refresh), so a message sent in that window was refused as "Message route changed; reopen this view" and dumped back into the composer, appended to whatever the user had typed since. The question is whether the view still shows this conversation from this root (IDEN-7: the identity check was wider than that). - ReplaceablePageSource.reads_from(root): a closed history reads none; WirePageBuffer reads from its loader's root. - ChannelDestination keeps the root and the conversation, drops the reader it held, and asks the view's current reader. Found by the exact pilot settle (OpenHCSDev/textual#257): the old 20 ms sleep let the reader swap land before the test's send. test_input_failure_draft 8/8 and test_stale_route_writes pass on Textual main and on the settle build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/transcript_preparation.py | 5 +++++ src/toad/wire_history.py | 13 ++++++++----- tests/textual/test_stale_route_writes.py | 3 +-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 3a1faff54..5fb5869d1 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace from itertools import islice +from pathlib import Path from typing import TYPE_CHECKING, ClassVar from weakref import ReferenceType, ref @@ -507,6 +508,10 @@ class ReplaceablePageSource(PreparedPageSource): @abstractmethod def replacement(self, through) -> ReplaceablePageSource: ... + def reads_from(self, root: str | Path) -> bool: + """Whether this reader reads a conversation from ``root``; a closed one reads none.""" + return False + class SavedHistory(ReplaceablePageSource): """Prepared pages for a snapshot; storage and work limits belong to the runtime. diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 5502acfbc..6f0346114 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -439,15 +439,15 @@ async def execute(self, runtime, key): @dataclass(frozen=True) class ChannelDestination(BusDestination): - """A channel or inbox page: shown while its view still reads this conversation from this source.""" + """A channel or inbox page: shown while its view still reads this conversation from this + root. A page reader the view replaces (a refresh, the next page) still shows it.""" - source: WirePageBuffer view: CommsChatView def shown(self) -> bool: view = self.view - return view.is_attached and self.source is view.message_history.page_source and ( - self.conversation == view.conversation) + return view.is_attached and self.conversation == view.conversation and ( + view.message_history.page_source.reads_from(self.root)) async def presented(self, sent: MessageReference) -> None: await self.view.show_sent(sent) @@ -467,6 +467,9 @@ def __init__(self, *args, revision: ConversationObservation, **kwargs): def replacement(self, through) -> WirePageBuffer: return type(self)(self.loader, through, self.runtime, revision=self.revision) + def reads_from(self, root: str | Path) -> bool: + return self.loader.root == root + def uses_observation(self, observation: Observation) -> bool: return observation.observes(self.loader.root) @@ -502,7 +505,7 @@ def begin_submission( self, view: CommsChatView, owner: SubmissionOwner, submission: SubmittedText ) -> SubmissionStart: """A channel or inbox message queues behind the view's earlier ones; compose stays open.""" - return owner.start_bus(submission, ChannelDestination(self.loader.root, self.loader.conversation, self, view)) + return owner.start_bus(submission, ChannelDestination(self.loader.root, self.loader.conversation, view)) def schedule_refresh(self, view: CommsChatView): history = view.message_history diff --git a/tests/textual/test_stale_route_writes.py b/tests/textual/test_stale_route_writes.py index f7ce5e91b..66de54042 100644 --- a/tests/textual/test_stale_route_writes.py +++ b/tests/textual/test_stale_route_writes.py @@ -19,8 +19,7 @@ async def test_a_view_that_changed_before_its_send_left_keeps_the_draft_editable messaging = workspace.comms.messaging before = messaging.bus.bus.revision() # Built for a conversation the view no longer shows: the view moved on before the send left. - stale = ChannelDestination(workspace.roots.wire, ChannelConversation(ChannelTarget("#elsewhere")), - view.message_history.page_source, view) + stale = ChannelDestination(workspace.roots.wire, ChannelConversation(ChannelTarget("#elsewhere")), view) await view.submissions.start_bus(OrdinaryInputSubmission("STALE_DRAFT"), stale).prepare() await ui.until(lambda: view.prompt.text == "STALE_DRAFT", why="the refused draft back in the composer") assert not view.submissions.delivering