From 8f923903744f008391c7678216176482f9fa8120 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:00:28 -0400 Subject: [PATCH 1/2] Detach retired session views before asynchronous cleanup --- src/toad/session_navigation.py | 44 ++++++++++++++++++++++++---------- src/toad/workspace_sessions.py | 7 +++++- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 5d1c4437d..7e94e3f55 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable +from dataclasses import dataclass from functools import partial from pathlib import Path from typing import TYPE_CHECKING, cast @@ -28,7 +29,7 @@ PreviewSessionEntry, SessionEntry, ) -from toad.session_scope import AgentAttached, AgentBinding, AgentSession +from toad.session_scope import AgentAttached, AgentBinding, AgentSession, SessionScope from toad.session_tracker import CommsViewKey, OpenTab, SessionDetails from toad.tab_identity import SessionTabIdentity, TabIdentity, TabTargetGone from toad.tab_identity.kinds import AcpTab, HistoryTab, MailboxTab, PreviewTab @@ -43,6 +44,13 @@ from toad.screens.main import MainScreen +@dataclass(frozen=True) +class Detached: + members: tuple[SessionEntry, ...] + views: tuple[SessionScope, ...] + returning: SessionEntry | None + + class SessionNavigation: def __init__(self, app: ToadApp) -> None: self.app = app @@ -224,7 +232,8 @@ def views_retired(self, root, retired) -> None: ) ) if removed: - task = asyncio.ensure_future(self.close_many(removed)) + detached = self.detach(removed) + task = asyncio.ensure_future(self.finish(detached)) self._closing_retired.add(task) task.add_done_callback(self._closing_retired.discard) @@ -427,11 +436,14 @@ async def close(self, mode: str) -> None: await self.close_many((mode,)) async def close_many(self, modes: tuple[str, ...]) -> None: + await self.finish(self.detach(modes)) + + def detach(self, modes: tuple[str, ...]) -> Detached: entries = tuple( entry for mode in modes if (entry := self.get(mode)) is not None ) if not entries: - return + return Detached((), (), None) closing = tuple( member for member in self.members @@ -443,23 +455,31 @@ async def close_many(self, modes: tuple[str, ...]) -> None: ) for member in closing: member.forget(self) - await self.app.tab_order.close({member.identity for member in closing}) - # Retire membership before publication and slow widget exits. Delayed - # opening metadata cannot revive a closed owner during teardown. + views = [] for member in closing: group = self.entries[member.owner_mode] del group[member.mode] if not group: del self.entries[member.owner_mode] + if view := self.app.workspace_sessions.detach(member.mode): + views.append(view) self.publish() + return Detached(closing, tuple(views), returning) + + async def finish(self, detached: Detached) -> None: + if not detached.members: + return try: - # Closed entries cannot reopen. Show the surviving workspace - # before joining hydration, native sessions and widget removal. - if returning is not None: - await returning.return_to(self) + await self.app.tab_order.close({member.identity for member in detached.members}) + if detached.returning is not None: + for view in detached.views: + if view.id == detached.returning.mode: + await view.retire_presentation() + await detached.returning.return_to(self) finally: - for member in closing: - await self.app.workspace_sessions.close(member.mode) + for view in detached.views: + await view.close_presentation() + await view.remove() def observed(self, event: core_events.CoordinationObserved) -> None: # Views on the observed root retire through the observation service diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 5bfdd47bd..4ab5b7025 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -249,10 +249,15 @@ def release_frame_callback(self, owner, callback) -> bool: owner.call_later(callback) return True - async def close(self, identity: str) -> None: + def detach(self, identity: str) -> SessionScope | None: if view := self.views.pop(identity, None): if self.source.matches(view): self.source = DetachedWorkspaceSource() + return view + return None + + async def close(self, identity: str) -> None: + if view := self.detach(identity): await view.close_presentation() await view.remove() From 1c1f443d39a2177007b8650ac8a29b5d357c2a21 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:06:15 -0400 Subject: [PATCH 2/2] Retain detached view identity and own presentation teardown once --- src/toad/session_navigation.py | 16 +++++++++------- src/toad/workspace_sessions.py | 7 +++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 7e94e3f55..379b100e6 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -49,6 +49,7 @@ class Detached: members: tuple[SessionEntry, ...] views: tuple[SessionScope, ...] returning: SessionEntry | None + returning_view: SessionScope | None class SessionNavigation: @@ -443,7 +444,7 @@ def detach(self, modes: tuple[str, ...]) -> Detached: entry for mode in modes if (entry := self.get(mode)) is not None ) if not entries: - return Detached((), (), None) + return Detached((), (), None, None) closing = tuple( member for member in self.members @@ -456,6 +457,7 @@ def detach(self, modes: tuple[str, ...]) -> Detached: for member in closing: member.forget(self) views = [] + returning_view = None for member in closing: group = self.entries[member.owner_mode] del group[member.mode] @@ -463,23 +465,23 @@ def detach(self, modes: tuple[str, ...]) -> Detached: del self.entries[member.owner_mode] if view := self.app.workspace_sessions.detach(member.mode): views.append(view) + if member is returning: + returning_view = view self.publish() - return Detached(closing, tuple(views), returning) + return Detached(closing, tuple(views), returning, returning_view) async def finish(self, detached: Detached) -> None: if not detached.members: return try: await self.app.tab_order.close({member.identity for member in detached.members}) + if detached.returning_view is not None: + await detached.returning_view.retire_presentation() if detached.returning is not None: - for view in detached.views: - if view.id == detached.returning.mode: - await view.retire_presentation() await detached.returning.return_to(self) finally: for view in detached.views: - await view.close_presentation() - await view.remove() + await self.app.workspace_sessions.finish(view) def observed(self, event: core_events.CoordinationObserved) -> None: # Views on the observed root retire through the observation service diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 4ab5b7025..4b3859768 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -258,8 +258,11 @@ def detach(self, identity: str) -> SessionScope | None: async def close(self, identity: str) -> None: if view := self.detach(identity): - await view.close_presentation() - await view.remove() + await self.finish(view) + + async def finish(self, view: SessionScope) -> None: + await view.close_presentation() + await view.remove() async def aclose(self) -> None: """Finalize owned sources while their frame and operational bindings exist."""