diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index 29e758594..a6e386cde 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -300,8 +300,8 @@ def prompt(self, project: Checkout): async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): - snapshot = comms.registry.snapshot() - return comms.configuration.answer(self.peer.require_current(snapshot).name) + references = CurrentDisplayReferences.from_registry(str(comms.root), comms.registry.snapshot()) + return comms.configuration.answer(self.destination(references).name) return (await asyncio.to_thread(read)).status_label diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 5724eb231..1caaeca1c 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -114,6 +114,7 @@ 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(), diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 33a730f0f..a2f2747bb 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -10,7 +10,6 @@ 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 @@ -45,9 +44,10 @@ def entry_target(entry: RelationshipEntry) -> NavigationTarget: - for person in entry.person.present(): - return ThreadTarget(person.thread.name) - return NavigationTarget.named(entry.target) + person = entry.person + if person is None: + return NavigationTarget.named(entry.target) + return ThreadTarget(person.thread.name) @dataclass @@ -111,9 +111,7 @@ class RelationshipRow(ThreadStatusRow, actions=(CopyIdentityAction,)): available = True entry: RelationshipEntry | None = None - def present( - self, entry: RelationshipEntry, rows: Mapping[str, ThreadRowModel] - ) -> None: + def present(self, entry, model) -> None: previous, self.entry = self.entry, entry self.available, self.target = entry.available, entry_target(entry) if not entry.available: @@ -122,39 +120,36 @@ def present( Content(f"? {entry.target}\n Unavailable ยท Ctrl+C copies name"), layout=False, ) - 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: + elif entry.person is not None: + if ( + model is not None + and model.incarnation == entry.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, person), + self._present_person(entry), group="relationship-row", exclusive=True, ) - return - if previous != entry: + elif previous != entry: self.set_label(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 - ) + if entry.person is None: + 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._show_entry_detail(entry) + self.tooltip = Content(f"{entry.target}\n{entry.detail}") - async def _present_person(self, entry, person): + async def _present_person(self, entry): model = await self.app.preparation.run_thread( - ThreadRowModel.of, person, UnobservedUnread() + ThreadRowModel.of, entry.person, UnobservedUnread() ) if self.entry == entry: self._show_person(entry, model) @@ -212,8 +207,7 @@ def keys_for_threads(self, names: frozenset[str]) -> frozenset[str]: return frozenset( entry.target for entry in self.model.entries.values() - for person in entry.person.present() - if person.thread.name in names + if entry.person is not None and entry.person.thread.name in names ) def new_row(self, key: str): @@ -223,7 +217,10 @@ 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, threads) + row.present( + entry, + threads.get(entry.person.thread.name) if entry.person is not None else None, + ) def sync_members(self, changed: frozenset[str]) -> None: """Reconcile member rows from this group's entries and the tree's derived thread rows.""" diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index e6039da68..93e9d610c 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -16,6 +16,7 @@ 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, @@ -300,6 +301,10 @@ 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( diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index 60971cec8..be4771fdc 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -1,7 +1,5 @@ """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 @@ -34,64 +32,3 @@ 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()