diff --git a/src/toad/app.py b/src/toad/app.py index 4ffb409a2..7f88a2e3f 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -34,7 +34,7 @@ from toad.core import session_requests from toad.core_event_carrier import CoreEventReceiver from metaclass_registry.mro_dispatch import handles -from toad.session_scope import AgentBinding +from toad.session_scope import AgentBinding, SessionScope from toad.render_backend import Renderer from toad.work_preparation import PreparationRuntime from toad.navigation_preparation import ( @@ -350,6 +350,11 @@ def _prewarm_conversation_css(self) -> None: })) + async def prepare_session(self, mode: str) -> SessionScope: + """Mount an owner shell under the same admission as visible transitions.""" + async with self._mode_switch_lock: + return await self.workspace_sessions.prepare(mode) + def select_session(self, mode: str, *, history_index: int | None = None) -> AwaitComplete: if mode != self.selected_mode: self.navigation_reader.invalidate() diff --git a/src/toad/application_lifetime.py b/src/toad/application_lifetime.py index 8bad36ea8..8a0ab9574 100644 --- a/src/toad/application_lifetime.py +++ b/src/toad/application_lifetime.py @@ -155,9 +155,10 @@ async def start(self) -> None: if app.saved_settings.ensure_installation(): app.call_later(self.usage.publish, "toad-install") self.usage.publish("toad-run") + destination = None for identity in await app.tab_order.load(): try: - await identity.restore(app.session_navigation) + destination = await identity.restore(app.session_navigation) except TabTargetGone as error: await app.tab_order.close((identity,)) app.log.error(f"Removed stale tab {error.identity}: {error.reason}") @@ -169,6 +170,8 @@ async def start(self) -> None: partial(navigation.launch_screen, app), original=(navigation.root, navigation.thread.incarnation), ) + elif destination is not None: + await app.session_navigation.open(destination) if not app.tab_order.names: await app.select_session(app.tab_order.home) app.terminal_attention.attach() diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 8b7ba2326..063622d4f 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -189,24 +189,20 @@ def unobserved_tab(self) -> OpenTab: return NamedTab(self.identity, self.details.name.display or "New Session") def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: - source = self.source(sessions) - if source is None or not source.belongs_to_wire(observed.service.root): - return self.unobserved_tab() - row = observed.sidebar.threads.rows.get(source.thread_identity.thread_name()) - originals = self.original_threads(sessions) - if ( - row is not None - and originals - and not any( - observed.observes(root) and identity == row.incarnation - for root, identity in originals - ) - ): - return self.unobserved_tab() + # The original wire identity exists before a native view. Unopened tabs + # still show backend status/unread; attachment state belongs to a view. + row = next( + ( + row + for root, identity in self.original_threads(sessions) + if observed.observes(root) + and (row := observed.sidebar.threads.rows.get(identity.name)) is not None + and row.incarnation == identity + ), + None, + ) if row is not None: - return ThreadTab( - self.identity, source.session_disposition.present(row, row.disconnected) - ) + return ThreadTab(self.identity, self.thread_row(sessions, row)) return self.unobserved_tab() async def return_to(self, sessions: SessionNavigation) -> None: diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 0eccbc0d4..06d1be6c0 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -140,7 +140,7 @@ def previous(self, current: TabIdentity, *, eligible=None) -> str: identity = self.app.tab_order.previous(current, eligible=eligible) return identity.mode_name if identity is not None else self.app.tab_order.home - async def restore_mailbox(self, identity: MailboxTab) -> None: + async def mailbox_entry(self, identity: MailboxTab) -> SessionEntry: def prepare(): service = self.app.coordination_access.observation.observed().service if service.root != Path(identity.root): @@ -152,15 +152,13 @@ def prepare(): return ThreadNavigationRequest(service, name, self.app.project_dir, ()).read() prepared = await self.app.preparation.run_thread(prepare) - await self.new( + return self.native_entry( identity, partial(prepared.launch_screen, self.app), title=prepared.thread.name, original=(prepared.root, prepared.thread.incarnation), ) - async def restore_history(self, identity: HistoryTab) -> None: - source = self.app.workspace_sessions.require(identity.owner.mode_name) - + async def history_entry(self, identity: HistoryTab, source: SessionScope) -> HistorySessionEntry: def participants(): service = self.app.coordination_access.observation.observed().service if service.root != Path(identity.root): @@ -169,14 +167,12 @@ def participants(): service.registry.snapshot(), source.thread_identity.thread_name() ) - entry = HistorySessionEntry( + return HistorySessionEntry( identity, CommsViewKey(identity.root, identity.owner.mode_name, identity.key), source.project_path, source.coordination_root, await self.app.preparation.run_thread(participants), source, ) - if self.get(entry.mode) is None: - await self.open(entry) def thread_row(self, row: ThreadRowModel) -> ThreadRowModel: """A thread's row as this app shows it, with the state of each open session of it.""" @@ -239,14 +235,22 @@ def views_retired(self, root, retired) -> None: self._closing_retired.add(task) task.add_done_callback(self._closing_retired.discard) - async def open(self, entry: SessionEntry, *, after: str | None = None) -> str: + async def register(self, entry: SessionEntry, *, after: str | None = None) -> None: + """Keep the logical tab without mounting or selecting its native resources.""" app = self.app - if self.get(entry.mode) is not None: + existing = self.get(entry.mode) + if existing is entry: + return + if existing is not None: raise ValueError(f"Logical session already registered: {entry.mode}") self.entries.setdefault(entry.owner_mode, {})[entry.mode] = entry previous = self.get(after) await app.tab_order.open(entry.identity, after=previous.identity if previous else None) self.publish() + + async def open(self, entry: SessionEntry, *, after: str | None = None) -> str: + await self.register(entry, after=after) + app = self.app await app.select_session(entry.mode) await entry.ready(self) return entry.mode @@ -259,13 +263,23 @@ async def new( title: str = "New Session", original=None, ) -> SessionDetails: + entry = self.native_entry(identity, factory, title=title, original=original) + await self.open(entry) + return self.app.session_tracker.sessions[identity] + + def native_entry( + self, + identity: SessionTabIdentity, + factory: Callable[[], MainScreen], + *, + title: str = "New Session", + original=None, + ) -> SessionEntry: existing = self.get(identity.mode_name) if existing is not None: - await self.app.select_session(existing.mode) - return self.app.session_tracker.sessions[identity] + return existing details = self.app.session_tracker.new_session(identity, title=title) - await self.open(NativeSessionEntry(details, factory, original)) - return details + return NativeSessionEntry(details, factory, original) async def saved( self, agent: AgentDefinition, project: Checkout, *, @@ -287,6 +301,22 @@ async def launch( project: Checkout, initial_prompt: str | None = None, ) -> None: + entry = await self.launch_entry( + agent_identity, agent_session_id=agent_session_id, + session_pk=session_pk, project=project, initial_prompt=initial_prompt, + ) + if entry is not None: + await self.open(entry) + + async def launch_entry( + self, + agent_identity: str, + *, + agent_session_id: str | None = None, + session_pk: int | None = None, + project: Checkout, + initial_prompt: str | None = None, + ) -> SessionEntry | None: from toad.agents import read_agents from toad.screens.main import MainScreen @@ -304,8 +334,7 @@ async def launch( None, ) if existing is not None: - await app.select_session(existing) - return + return self.get(existing) agent, title = None, None if session_pk is not None: saved = await database.session_get(session_pk) @@ -323,7 +352,7 @@ async def launch( else await self.saved(agent, project, title=title or "New Session", agent_session_id=agent_session_id) ) - await self.new( + return self.native_entry( tab, lambda: MainScreen( project, diff --git a/src/toad/tab_identity/__init__.py b/src/toad/tab_identity/__init__.py index 04489b1ce..2c32e770c 100644 --- a/src/toad/tab_identity/__init__.py +++ b/src/toad/tab_identity/__init__.py @@ -1,11 +1,19 @@ """Durable tab identities; Textual names are a projection of the wire identity.""" +from __future__ import annotations + from abc import abstractmethod from functools import cached_property +from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec +if TYPE_CHECKING: + from toad.session_entry import SessionEntry + from toad.session_navigation import SessionNavigation + from toad.session_scope import SessionScope + class TabTargetGone(Exception): def __init__(self, identity: "TabIdentity", reason: str) -> None: @@ -19,10 +27,24 @@ class TabIdentity(DeclaredFamily, affix="Tab", discovery_package="toad.tab_ident def mode_name(self) -> str: return f"{self.declared_name}-{FieldCodec.digest(self)}" + async def restore(self, navigation: SessionNavigation) -> SessionEntry: + """Restore logical membership, not a succession of visible conversations.""" + entry = navigation.get(self.mode_name) + if entry is None: + entry = await self.entry(navigation) + await navigation.register(entry) + return entry + @abstractmethod - async def restore(self, navigation) -> None: ... + async def entry(self, navigation: SessionNavigation) -> SessionEntry: ... class SessionTabIdentity(TabIdentity): """An agent or mailbox tab can own history tabs.""" + async def history_source(self, navigation: SessionNavigation) -> SessionScope: + """An ACP session must establish its actual identity before routing history.""" + entry = await self.restore(navigation) + await navigation.app.select_session(entry.mode) + await navigation.app.workspace_sessions.require(entry.mode).wait_content_ready() + return navigation.app.workspace_sessions.require(entry.mode) diff --git a/src/toad/tab_identity/history_origin.py b/src/toad/tab_identity/history_origin.py index 4bb89285d..48a1a4b6f 100644 --- a/src/toad/tab_identity/history_origin.py +++ b/src/toad/tab_identity/history_origin.py @@ -1,13 +1,20 @@ """History opened from a resident welcome view or from a durable agent tab.""" +from __future__ import annotations + from abc import abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily from toad.constants import WELCOME from toad.tab_identity import SessionTabIdentity +if TYPE_CHECKING: + from toad.session_navigation import SessionNavigation + from toad.session_scope import SessionScope + class HistoryOrigin(DeclaredFamily, affix="HistoryOrigin"): @property @@ -15,7 +22,7 @@ class HistoryOrigin(DeclaredFamily, affix="HistoryOrigin"): def mode_name(self) -> str: ... @abstractmethod - async def restore(self, navigation) -> None: ... + async def restore(self, navigation: SessionNavigation) -> SessionScope: ... @dataclass(frozen=True) @@ -24,10 +31,10 @@ class WelcomeHistoryOrigin(HistoryOrigin): def mode_name(self) -> str: return WELCOME - async def restore(self, navigation) -> None: - await navigation.app.select_session(WELCOME) - source = navigation.app.workspace_sessions.origin(WELCOME).require() + async def restore(self, navigation: SessionNavigation) -> SessionScope: + source = await navigation.app.prepare_session(WELCOME) await source.prepare_history_origin() + return source @dataclass(frozen=True) @@ -38,6 +45,5 @@ class SessionHistoryOrigin(HistoryOrigin): def mode_name(self) -> str: return self.identity.mode_name - async def restore(self, navigation) -> None: - if navigation.get(self.mode_name) is None: - await self.identity.restore(navigation) + async def restore(self, navigation: SessionNavigation) -> SessionScope: + return await self.identity.history_source(navigation) diff --git a/src/toad/tab_identity/kinds.py b/src/toad/tab_identity/kinds.py index 0abcdeb31..191f29e0b 100644 --- a/src/toad/tab_identity/kinds.py +++ b/src/toad/tab_identity/kinds.py @@ -9,6 +9,7 @@ from agent_comms.thread_identity import ThreadIncarnation from toad.conversation_kind import ConversationKind +from toad.session_entry import PreviewSessionEntry from toad.tab_identity import SessionTabIdentity, TabIdentity, TabTargetGone from toad.tab_identity.history_origin import HistoryOrigin @@ -17,7 +18,7 @@ class AcpTab(SessionTabIdentity): key: int - async def restore(self, navigation) -> None: + async def entry(self, navigation): database = await navigation.app.get_db() saved = await database.session_get(self.key) if saved is None: @@ -26,12 +27,15 @@ async def restore(self, navigation) -> None: project = Checkout.checked(saved.meta_json.cwd.require_present().path) except ValueError as error: raise TabTargetGone(self, f"Saved ACP session {self.key}: {error}") from error - await navigation.launch( + entry = await navigation.launch_entry( saved.agent_identity, agent_session_id=saved.agent_session_id, session_pk=self.key, project=project, ) + if entry is None: + raise TabTargetGone(self, f"Saved ACP agent {saved.agent_identity} is unavailable") + return entry @dataclass(frozen=True) @@ -39,8 +43,14 @@ class MailboxTab(SessionTabIdentity): key: ThreadIncarnation root: str - async def restore(self, navigation) -> None: - await navigation.restore_mailbox(self) + async def entry(self, navigation): + return await navigation.mailbox_entry(self) + + async def history_source(self, navigation): + # Mailbox metadata already carries the original thread identity. Its + # history needs the owner shell, not an ACP connection or painted body. + entry = await self.restore(navigation) + return await navigation.app.prepare_session(entry.mode) @dataclass(frozen=True) @@ -49,16 +59,16 @@ class HistoryTab(TabIdentity): owner: HistoryOrigin root: str - async def restore(self, navigation) -> None: - await self.owner.restore(navigation) - await navigation.restore_history(self) + async def entry(self, navigation): + source = await self.owner.restore(navigation) + return await navigation.history_entry(self, source) @dataclass(frozen=True) class PreviewTab(TabIdentity): key: Annotated[Path, PathText] - async def restore(self, navigation) -> None: + async def entry(self, navigation): if not await navigation.app.preparation.run_thread(self.key.exists): raise TabTargetGone(self, f"Preview file no longer exists: {self.key}") - await navigation.preview(self.key) + return PreviewSessionEntry.of(self, self.key, navigation.app.selected_mode) diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index b38d4407c..4f589477a 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -189,6 +189,7 @@ def origin(self, identity: str) -> SessionOrigin: async def prepare(self, identity: str) -> "SessionScope": if view := self.views.get(identity): return view + await self.app._init_mode("workspace") view = (self.app.session_navigation.get(identity) or self.resident[identity])() view.id = identity view.display = False diff --git a/tests/textual/test_tab_close.py b/tests/textual/test_tab_close.py index 7134ddaa7..20745830c 100644 --- a/tests/textual/test_tab_close.py +++ b/tests/textual/test_tab_close.py @@ -57,7 +57,8 @@ def failure(error): for thread in threads: owner = thread.incarnation identity = MailboxTab(owner, root) - await app.session_navigation.restore_mailbox(identity) + entry = await identity.restore(app.session_navigation) + await app.session_navigation.open(entry) modes.append(identity.mode_name) await app.selected_session.wait_content_ready() first, second = modes