From c6b094b596d5b355a5c1d67192d894990c3edf68 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:54:22 -0400 Subject: [PATCH 1/5] Consume captured relationship people through their presence capability --- src/toad/widgets/thread_comms.py | 46 +++++++++++++++++--------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index a2f2747bb..40ccd9c4d 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -44,10 +44,9 @@ def entry_target(entry: RelationshipEntry) -> NavigationTarget: - person = entry.person - if person is None: - return NavigationTarget.named(entry.target) - return ThreadTarget(person.thread.name) + for person in entry.person.present(): + return ThreadTarget(person.thread.name) + return NavigationTarget.named(entry.target) @dataclass @@ -120,36 +119,38 @@ def present(self, entry, model) -> None: Content(f"? {entry.target}\n Unavailable ยท Ctrl+C copies name"), layout=False, ) - elif entry.person is not None: - if ( - model is not None - and model.incarnation == entry.person.thread.incarnation - ): + self._show_entry_detail(entry) + return + for person in entry.person.present(): + if model is not None and model.incarnation == person.thread.incarnation: self.workers.cancel_group(self, "relationship-row") self._show_person(entry, model) elif previous != entry: self.run_worker( - self._present_person(entry), + self._present_person(entry, person), group="relationship-row", exclusive=True, ) - elif previous != entry: + return + if previous != entry: self.set_label(entry.target) - if entry.person is None: - self.tooltip = ( - Content(f"{entry.target}\n{entry.detail}") - if entry.detail - else entry.target - ) + self._show_entry_detail(entry) + + def _show_entry_detail(self, entry): + self.tooltip = ( + Content(f"{entry.target}\n{entry.detail}") + if entry.detail + else entry.target + ) def _show_person(self, entry, model): self.show(model) if entry.detail: - self.tooltip = Content(f"{entry.target}\n{entry.detail}") + self._show_entry_detail(entry) - async def _present_person(self, entry): + async def _present_person(self, entry, person): model = await self.app.preparation.run_thread( - ThreadRowModel.of, entry.person, UnobservedUnread() + ThreadRowModel.of, person, UnobservedUnread() ) if self.entry == entry: self._show_person(entry, model) @@ -207,7 +208,8 @@ def keys_for_threads(self, names: frozenset[str]) -> frozenset[str]: return frozenset( entry.target for entry in self.model.entries.values() - if entry.person is not None and entry.person.thread.name in names + for person in entry.person.present() + if person.thread.name in names ) def new_row(self, key: str): @@ -219,7 +221,7 @@ def show_member(self, key: str, row: RelationshipRow) -> None: threads = self.query_ancestor(ThreadCommsSidebar).model_state.shown.threads.rows row.present( entry, - threads.get(entry.person.thread.name) if entry.person is not None else None, + next((threads.get(person.thread.name) for person in entry.person.present()), None), ) def sync_members(self, changed: frozenset[str]) -> None: From d0ea074acf628dd5f920608061d9cbd380c6842c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:19:15 -0400 Subject: [PATCH 2/5] Read DM status from its one captured peer, not the reference catalog --- src/toad/conversation_kind.py | 7 +++++-- src/toad/wire_history.py | 5 ----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index a6e386cde..1911d9113 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -300,8 +300,11 @@ def prompt(self, project: Checkout): async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): - references = CurrentDisplayReferences.from_registry(str(comms.root), comms.registry.snapshot()) - return comms.configuration.answer(self.destination(references).name) + peer = self.peer.require_recorded() + snapshot = comms.registry.snapshot() + if not peer.current(snapshot): + raise KeyError(peer.lookup) + return comms.configuration.answer(snapshot.canonical_name(peer.name)) return (await asyncio.to_thread(read)).status_label diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 93e9d610c..e6039da68 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -16,7 +16,6 @@ from agent_comms.comms import Comms from agent_comms.repo import Checkout -from agent_comms.display_references import CurrentDisplayReferences from agent_comms.historical_views import ( ConversationHistoryView, ConversationObservation, @@ -301,10 +300,6 @@ class WireSource: def viewer(self) -> NamedViewer: return self.comms.views.display_viewer(self.project) - def references(self) -> CurrentDisplayReferences: - """Resolve stable destinations through this read's registry cut.""" - return CurrentDisplayReferences.from_registry(str(self.comms.root), self.comms.registry.snapshot()) - def observe(self, seen: ConversationObservation) -> SourceObservation: """The observed state owns how changed or unchanged history is drawn.""" revision = self.conversation.history_view.revision( From d9d96bcf891cd7a58b5d2043029963be2513f527 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:43:06 -0400 Subject: [PATCH 3/5] Use the strict peer owner and retain nominal relationship presence --- src/toad/conversation_kind.py | 5 +---- src/toad/widgets/thread_comms.py | 11 ++++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index 1911d9113..29e758594 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -300,11 +300,8 @@ def prompt(self, project: Checkout): async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): - peer = self.peer.require_recorded() snapshot = comms.registry.snapshot() - if not peer.current(snapshot): - raise KeyError(peer.lookup) - return comms.configuration.answer(snapshot.canonical_name(peer.name)) + return comms.configuration.answer(self.peer.require_current(snapshot).name) return (await asyncio.to_thread(read)).status_label diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 40ccd9c4d..33a730f0f 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -10,6 +10,7 @@ from toad.navigation_target import NavigationTarget, ThreadTarget import asyncio +from collections.abc import Mapping from dataclasses import dataclass, field from agent_comms.display_order import ThreadSort @@ -110,7 +111,9 @@ class RelationshipRow(ThreadStatusRow, actions=(CopyIdentityAction,)): available = True entry: RelationshipEntry | None = None - def present(self, entry, model) -> None: + def present( + self, entry: RelationshipEntry, rows: Mapping[str, ThreadRowModel] + ) -> None: previous, self.entry = self.entry, entry self.available, self.target = entry.available, entry_target(entry) if not entry.available: @@ -122,6 +125,7 @@ def present(self, entry, model) -> None: self._show_entry_detail(entry) return for person in entry.person.present(): + model = rows.get(person.thread.name) if model is not None and model.incarnation == person.thread.incarnation: self.workers.cancel_group(self, "relationship-row") self._show_person(entry, model) @@ -219,10 +223,7 @@ def new_row(self, key: str): def show_member(self, key: str, row: RelationshipRow) -> None: entry = self.model.entries[key] threads = self.query_ancestor(ThreadCommsSidebar).model_state.shown.threads.rows - row.present( - entry, - next((threads.get(person.thread.name) for person in entry.person.present()), None), - ) + row.present(entry, threads) def sync_members(self, changed: frozenset[str]) -> None: """Reconcile member rows from this group's entries and the tree's derived thread rows.""" From a05b0f5e38932d603b66cb74ae1d4911a50088ea Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:54:47 -0400 Subject: [PATCH 4/5] Start wire history without waiting for an unused sidebar answer --- src/toad/widgets/comms_chat.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 1caaeca1c..5724eb231 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -114,7 +114,6 @@ async def initialize_view(self) -> None: f"Conversation root {self._bound_root} differs from observed root {shared.root}" ) comms = shared - await self.app.coordination_access.current_sidebar() self.message_history.initialize_source(WireSource( comms, self.conversation, self.project_path, self._me, IrcMessageStyle(), From 95be07c37c8274c766b945cff3746f8c45203564 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:26:49 -0400 Subject: [PATCH 5/5] Exercise first wire page while real sidebar answer is pending --- .../test_channel_updates_and_returns.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index be4771fdc..60971cec8 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -1,5 +1,7 @@ """A real shared observer updates a shown channel and leaves its parked view alone.""" +import asyncio + async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_the_update(workspace): from toad.widgets.comms_chat import CommsChatView, session_thread_name @@ -32,3 +34,64 @@ async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_ await ui.until_shows("PARKED UPDATE") await ui.until(lambda: bool(access.notification_interest.reads), why="the returned channel to resubscribe") assert app.selected_session.query_one(CommsChatView) is chat + + +async def test_first_wire_page_paints_before_pending_sidebar_and_keeps_source_fences(workspace): + from agent_comms.launch_root import LaunchRoot + from support.roots import installed_wire + from toad.preferences import SETTINGS + from toad.widgets.channel_prompt import ChannelPrompt + from toad.widgets.comms_chat import CommsChatView + + sender = workspace.thread("sender", "first-page") + workspace.thread("reader", "first-page") + workspace.comms.messaging.send("sender", "#first-page", "FIRST_PAGE_WITHOUT_SIDEBAR") + other = installed_wire(workspace.roots.attempt / "other-route") + async with workspace.app(size=(113, 34)) as ui: + app = ui.app + access = app.coordination_access + await access.current_sidebar() + observed = access.observation.observed() + process = observed.process + # Join the actual receiver, not a replacement service or fake answer. + # Its child keeps running and the real sidebar reply remains in its pipe. + await process._reader.aclose() + async with asyncio.timeout(20), asyncio.TaskGroup() as work: + try: + app.saved_settings.toggle(SETTINGS.sidebar.show_archived) + sidebar = work.create_task(access.current_sidebar()) + references = work.create_task( + observed.current_references(access, str(workspace.comms.root)), + ) + await ui.until(lambda: bool(access.sidebar_waiters), + why="the genuine changed-interest sidebar answer to remain pending") + opening = work.create_task(ui.open_channel("#first-page", actor="reader")) + await ui.until_shows("FIRST_PAGE_WITHOUT_SIDEBAR") + chat = app.selected_session.query_one(CommsChatView) + history = chat.message_history + source = history.page_source + assert not history.undrawn + assert not sidebar.done() and not references.done() + assert not chat.query_one(ChannelPrompt)._candidates + finally: + # Reattach the same observer's one reader; deliver its original reply. + process.start_receiving(observed.receive, observed.receive_failed) + mode = await opening + answer = await sidebar + catalog = await references + assert catalog.thread(sender.incarnation).name == sender.name + assert answer.require_snapshot().references.thread(sender.incarnation).name == sender.name + await ui.until( + lambda: any(candidate.name == sender.name + for candidate in chat.query_one(ChannelPrompt)._candidates), + why="the genuine channel mention label to finish after its sidebar answer", + ) + assert chat.agent_info.plain == chat.conversation.label(catalog) + snapshot = history.source_snapshot() + source = history.page_source + with LaunchRoot.from_path(other.root).selected(): + assert not await history.source_is_current(snapshot) + assert await history.source_is_current(snapshot) + await app.session_navigation.close(mode) + assert source.closed + assert not await source.route_is_current()