From 951bcaa040b8cac04a7c0041a7839600d18ae8ee Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:33:37 -0400 Subject: [PATCH 01/10] Store ordered open-tab rows beside saved sessions --- src/toad/db.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/toad/db.py b/src/toad/db.py index f6520687b..858d74a72 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -52,6 +52,12 @@ class Session(ToadTables, TypedTable, declared_name="sessions"): meta_json: SessionMeta = field(default_factory=SessionMeta) +@dataclass(frozen=True) +class OpenTabRow(ToadTables, TypedTable, declared_name="open_tabs"): + session_id: Annotated[int, Column(primary_key=True)] + position: int + + @dataclass(frozen=True) class ModelHistory(ToadTables, TypedTable, declared_name="model_history"): agent_identity: Annotated[str, Column(primary_key=True)] @@ -90,6 +96,23 @@ def transaction(): return await asyncio.to_thread(transaction) + async def tab_write(self, session_id: int, position: int) -> None: + def write(db): + OpenTabRow.delete(db, where="{session_id} = ?", parameters=(session_id,)) + OpenTabRow(session_id, position).insert(db) + + await self._transaction(write) + + async def tab_delete(self, session_id: int) -> None: + await self._transaction(lambda db: OpenTabRow.delete( + db, where="{session_id} = ?", parameters=(session_id,), + )) + + async def tab_list(self) -> list[OpenTabRow]: + return await self._transaction(lambda db: OpenTabRow.select( + db, order_by=("position", "session_id"), + )) + async def record_model_usage( self, agent_identity: str, model_id: str, *, last_used: int | None = None, ) -> None: From 6ef273d428051ba972096e2e5e40c22997201f5e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:49:01 -0400 Subject: [PATCH 02/10] Make TabOrder read and write the DB roster directly --- src/toad/db.py | 26 +++++++++++--------------- src/toad/tab_order.py | 36 +++++++++++++++++++++++------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/toad/db.py b/src/toad/db.py index 858d74a72..c1b1d11bd 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -89,27 +89,23 @@ class DB: def __init__(self): self.path = paths.get_state() / "toad.db" - async def _transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: - def transaction(): - with closing(SQLiteFile.writer(self.path)) as db, db: - return operation(db) - - return await asyncio.to_thread(transaction) + def transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: + with closing(SQLiteFile.writer(self.path)) as db, db: + return operation(db) - async def tab_write(self, session_id: int, position: int) -> None: - def write(db): - OpenTabRow.delete(db, where="{session_id} = ?", parameters=(session_id,)) - OpenTabRow(session_id, position).insert(db) + async def _transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: + return await asyncio.to_thread(self.transaction, operation) - await self._transaction(write) + def tab_write(self, session_id: int, position: int) -> None: + self.transaction(lambda db: OpenTabRow(session_id, position).upsert(db)) - async def tab_delete(self, session_id: int) -> None: - await self._transaction(lambda db: OpenTabRow.delete( + def tab_delete(self, session_id: int) -> None: + self.transaction(lambda db: OpenTabRow.delete( db, where="{session_id} = ?", parameters=(session_id,), )) - async def tab_list(self) -> list[OpenTabRow]: - return await self._transaction(lambda db: OpenTabRow.select( + def tab_list(self) -> list[OpenTabRow]: + return self.transaction(lambda db: OpenTabRow.select( db, order_by=("position", "session_id"), )) diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index f5b17bfdc..29ec73b30 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -3,56 +3,64 @@ from collections.abc import Callable, Collection, Mapping from toad.core.events import CoreEventStream, TabHistoryChanged +from toad.db import DB class TabOrder: def __init__( - self, focus: Callable[[str, int | None], object], home: str + self, focus: Callable[[str, int | None], object], home: str, db: DB ) -> None: # What is shown while no tab is open. self.home = home - self._open: list[str] = [] + self.db = db self._visits: list[str] = [] self._cursor = -1 self._focus = focus self.events = CoreEventStream(self) def __contains__(self, mode: str | None) -> bool: - return mode in self._open + return mode in self.names @property def names(self) -> tuple[str, ...]: - return tuple(self._open) + return tuple(str(row.session_id) for row in self.db.tab_list()) @property def recent(self) -> tuple[str, ...]: """Visited sources first, derived from the existing navigation history.""" - return tuple(dict.fromkeys((*reversed(self._visits[:self._cursor + 1]), *self._open))) + return tuple(dict.fromkeys((*reversed(self._visits[:self._cursor + 1]), *self.names))) def open(self, mode: str, *, after: str | None = None) -> None: if mode in self: return - insertion = self._open.index(after) + 1 if after in self else len(self._open) - self._open.insert(insertion, mode) + order = list(self.names) + insertion = order.index(after) + 1 if after in self else len(order) + order.insert(insertion, mode) + self.reorder(order) + + def reorder(self, modes: Collection[str]) -> None: + for position, mode in enumerate(modes): + self.db.tab_write(int(mode), position) def project[T](self, values: Mapping[str, T]) -> tuple[T, ...]: - return tuple(values[mode] for mode in self._open if mode in values) + return tuple(values[mode] for mode in self.names if mode in values) def previous(self, current: str, *, eligible: Collection[str] | None = None) -> str: """The tab to show on leaving ``current``; home once no tab is open.""" - if not self._open: + opened = self.names + if not opened: return self.home candidates = [ mode - for mode in self._open + for mode in opened if mode != current and (eligible is None or mode in eligible) ] preceding = ( - self._open[: self._open.index(current)] if current in self else self._open + opened[: opened.index(current)] if current in self else opened ) return next( (mode for mode in reversed(preceding) if mode in candidates), - candidates[0] if candidates else current if current in self else self._open[-1], + candidates[0] if candidates else current if current in self else opened[-1], ) def history_target(self, direction: int) -> tuple[int, str] | None: @@ -85,7 +93,9 @@ def record_visit(self, mode: str, history_index: int | None = None) -> None: self.events.publish(TabHistoryChanged()) def close(self, modes: Collection[str]) -> None: - self._open[:] = [mode for mode in self._open if mode not in modes] + for mode in modes: + self.db.tab_delete(int(mode)) + self.reorder(self.names) retained = [] cursor = -1 for index, mode in enumerate(self._visits): From 61d2d2f967de751c8613132c13aed27e9046b5d2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:58:31 -0400 Subject: [PATCH 03/10] Wire application tab order to its prepared database --- src/toad/app.py | 1 + src/toad/db.py | 10 +++++----- src/toad/tab_order.py | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index c718d84a6..00437dc82 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -187,6 +187,7 @@ def __init__( self.tab_order = TabOrder( lambda mode, index: self.select_session(mode, history_index=index), home=mode or WELCOME, + db=DB(), ) self.coordination_access = CoordinationAccess(self) diff --git a/src/toad/db.py b/src/toad/db.py index c1b1d11bd..0d230f453 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -86,8 +86,8 @@ def remember(self, db: sqlite3.Connection) -> None: class DB: """Toad's durable state; one transaction owner runs entirely off the UI thread.""" - def __init__(self): - self.path = paths.get_state() / "toad.db" + def __init__(self, path: Path | None = None): + self.path = path if path is not None else paths.get_state() / "toad.db" def transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: with closing(SQLiteFile.writer(self.path)) as db, db: @@ -122,10 +122,10 @@ async def recent_models(self, agent_identity: str, limit: int = 20) -> list[str] return [row.model_id for row in rows] async def create(self) -> None: - def create(db): - ToadTables.install_schema(db, WriterOwnedFile()) + await asyncio.to_thread(self.prepare) - await self._transaction(create) + def prepare(self) -> None: + self.transaction(lambda db: ToadTables.install_schema(db, WriterOwnedFile())) async def session_new( self, diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index 29ec73b30..5bfdedf27 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -13,6 +13,7 @@ def __init__( # What is shown while no tab is open. self.home = home self.db = db + self.db.prepare() self._visits: list[str] = [] self._cursor = -1 self._focus = focus From 828d9a9dc4c721648f4eb5677224a3ee016e4b83 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:59:07 -0400 Subject: [PATCH 04/10] Restore saved open tabs in database position order at startup --- src/toad/application_lifetime.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/toad/application_lifetime.py b/src/toad/application_lifetime.py index 9f93baad9..d639a738e 100644 --- a/src/toad/application_lifetime.py +++ b/src/toad/application_lifetime.py @@ -119,16 +119,28 @@ async def start(self) -> None: if app.saved_settings.ensure_installation(): app.call_later(self.usage.publish, "toad-install") self.usage.publish("toad-run") - if self.resumed_session is None: - # No tab is open: the launch starts no agent and registers no thread. - await app.select_session(app.tab_order.home) - else: + database = app.tab_order.db + for tab in database.tab_list(): + saved = await database.session_get(tab.session_id) + if saved is None: + database.tab_delete(tab.session_id) + app.log.error(f"Removed open tab for missing saved session {tab.session_id}") + continue + await app.session_navigation.launch( + saved.agent_identity, + agent_session_id=saved.agent_session_id, + session_pk=tab.session_id, + project_path=saved.meta_json.cwd, + ) + if self.resumed_session is not None: # The thread's declaration decides how it opens, as from the sidebar. navigation = await app.preparation.run_thread(self.launch_navigation) await app.session_navigation.new( partial(navigation.launch_screen, app), original=(navigation.root, navigation.thread.incarnation), ) + if not app.tab_order.names: + await app.select_session(app.tab_order.home) app.terminal_attention.attach() app.set_timer(1, self.check_version) app.run_worker(partial(self.set_process_title, "toad"), thread=True) From 93a97ad5dd607636134cdad21325ec420151a0c9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:17:33 -0400 Subject: [PATCH 05/10] Give durable tabs a declared identity family and owned restoration --- src/toad/acp/agent_session.py | 5 +- src/toad/app.py | 7 ++- src/toad/application_lifetime.py | 14 +---- src/toad/comms_root.py | 2 +- src/toad/db.py | 38 +++++++---- src/toad/navigation_preparation.py | 7 --- src/toad/navigation_target.py | 2 +- src/toad/screens/main.py | 14 +++-- src/toad/screens/welcome.py | 6 ++ src/toad/session_entry.py | 47 +++++++------- src/toad/session_navigation.py | 83 +++++++++++++++++++++---- src/toad/session_presentation.py | 2 +- src/toad/session_scope.py | 4 ++ src/toad/session_tracker.py | 73 +++++++++++----------- src/toad/tab_identity/__init__.py | 24 +++++++ src/toad/tab_identity/history_origin.py | 41 ++++++++++++ src/toad/tab_identity/kinds.py | 58 +++++++++++++++++ src/toad/tab_order.py | 34 +++++----- src/toad/target_commands.py | 2 +- src/toad/thread_navigation.py | 11 ++-- src/toad/widgets/comms_sidebar.py | 2 +- 21 files changed, 341 insertions(+), 135 deletions(-) create mode 100644 src/toad/tab_identity/__init__.py create mode 100644 src/toad/tab_identity/history_origin.py create mode 100644 src/toad/tab_identity/kinds.py diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 2f3e88998..16899478a 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -430,7 +430,10 @@ async def new(self) -> None: cursor_token=cursor_token, queue_token=queue_token, ).consume_metadata(response.field_meta) - if self.supports_load: + if self.pk is not None: + await DB().session_started(self.pk, self.agent.session_id) + authority.require() + elif self.supports_load: db = DB() session_name = ( self.chosen_name diff --git a/src/toad/app.py b/src/toad/app.py index 00437dc82..15cebd079 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -176,7 +176,6 @@ def __init__( self.clipboard_transport = Clipboard.for_platform() self.terminal_attention = TerminalAttention(self) - self._session_tracker = SessionTracker() self.session_navigation = SessionNavigation(self) self.thread_navigation = ThreadNavigator(self) self.thread_actions = ThreadActions(self) @@ -185,10 +184,11 @@ def __init__( self.sidebar_layout = SidebarLayout() self._mode_switch_lock = asyncio.Lock() self.tab_order = TabOrder( - lambda mode, index: self.select_session(mode, history_index=index), + lambda identity, index: self.select_session(identity.mode_name, history_index=index), home=mode or WELCOME, db=DB(), ) + self._session_tracker = SessionTracker(self.tab_order) self.coordination_access = CoordinationAccess(self) from toad.file_kind import FileKind @@ -355,7 +355,8 @@ async def _switch_mode_ready(self, mode: str, *, history_index: int | None = Non await after_input() await self.workspace_screen.layout_navigation() if mode != previous: - self.tab_order.record_visit(mode, history_index) + if entry := self.session_navigation.get(mode): + self.tab_order.record_visit(entry.identity, history_index) self.events.publish(core_events.SessionSelected(mode, previous)) finally: self.screen.refresh() diff --git a/src/toad/application_lifetime.py b/src/toad/application_lifetime.py index d639a738e..9128556b6 100644 --- a/src/toad/application_lifetime.py +++ b/src/toad/application_lifetime.py @@ -11,6 +11,7 @@ from toad import get_version from toad.version import VersionMonitor +from toad.tab_identity.kinds import MailboxTab if TYPE_CHECKING: from ssl import SSLContext @@ -121,21 +122,12 @@ async def start(self) -> None: self.usage.publish("toad-run") database = app.tab_order.db for tab in database.tab_list(): - saved = await database.session_get(tab.session_id) - if saved is None: - database.tab_delete(tab.session_id) - app.log.error(f"Removed open tab for missing saved session {tab.session_id}") - continue - await app.session_navigation.launch( - saved.agent_identity, - agent_session_id=saved.agent_session_id, - session_pk=tab.session_id, - project_path=saved.meta_json.cwd, - ) + await tab.identity.restore(app.session_navigation) if self.resumed_session is not None: # The thread's declaration decides how it opens, as from the sidebar. navigation = await app.preparation.run_thread(self.launch_navigation) await app.session_navigation.new( + MailboxTab(navigation.thread.incarnation, navigation.root), partial(navigation.launch_screen, app), original=(navigation.root, navigation.thread.incarnation), ) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index f67a9affd..b17f65c6b 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -110,7 +110,7 @@ def tab(self, entry: SessionEntry, sessions: SessionNavigation): return entry.unobserved_tab() def tabs(self, sessions: SessionNavigation): - return {entry.mode: self.tab(entry, sessions) for entry in sessions.members} + return {entry.identity: self.tab(entry, sessions) for entry in sessions.members} def require(self, access: CoordinationAccess, selected: RouteSelection): return access.open_selected(selected) def references_for(self, root: str) -> ReferenceContext: diff --git a/src/toad/db.py b/src/toad/db.py index 0d230f453..e6cabe04e 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -18,6 +18,7 @@ from toad import paths from toad.agent_schema import AgentDefinition +from toad.tab_identity import TabIdentity SessionTimestamp = Annotated[RecordedTimestamp, TimestampText, Column(storage=TextStorage)] @@ -42,7 +43,7 @@ class Session(ToadTables, TypedTable, declared_name="sessions"): id: Annotated[int | None, Column(primary_key=True, auto_increment=True)] = None agent: str agent_identity: str - agent_session_id: str + agent_session_id: str | None title: str protocol: str = "acp" prompt_count: int = 0 @@ -54,7 +55,7 @@ class Session(ToadTables, TypedTable, declared_name="sessions"): @dataclass(frozen=True) class OpenTabRow(ToadTables, TypedTable, declared_name="open_tabs"): - session_id: Annotated[int, Column(primary_key=True)] + identity: Annotated[TabIdentity, Column(primary_key=True)] position: int @@ -84,7 +85,7 @@ def remember(self, db: sqlite3.Connection) -> None: class DB: - """Toad's durable state; one transaction owner runs entirely off the UI thread.""" + """Toad's durable state, including the sole open-tab roster.""" def __init__(self, path: Path | None = None): self.path = path if path is not None else paths.get_state() / "toad.db" @@ -96,17 +97,21 @@ def transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: async def _transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: return await asyncio.to_thread(self.transaction, operation) - def tab_write(self, session_id: int, position: int) -> None: - self.transaction(lambda db: OpenTabRow(session_id, position).upsert(db)) + def tab_write(self, identity: TabIdentity, position: int) -> None: + self.transaction(lambda db: OpenTabRow(identity, position).upsert(db)) - def tab_delete(self, session_id: int) -> None: - self.transaction(lambda db: OpenTabRow.delete( - db, where="{session_id} = ?", parameters=(session_id,), - )) + def tab_delete(self, identity: TabIdentity) -> None: + self.transaction(lambda db: OpenTabRow.delete(db, identity=identity)) + + def tab_reorder(self, identities: tuple[TabIdentity, ...]) -> None: + def reorder(db): + for position, identity in enumerate(identities): + OpenTabRow(identity, position).upsert(db) + self.transaction(reorder) def tab_list(self) -> list[OpenTabRow]: return self.transaction(lambda db: OpenTabRow.select( - db, order_by=("position", "session_id"), + db, order_by=("position",), )) async def record_model_usage( @@ -132,10 +137,10 @@ async def session_new( title: str, agent: str, agent_identity: str, - agent_session_id: str, + agent_session_id: str | None, protocol: str = "acp", meta: SessionMeta | None = None, - ) -> int | None: + ) -> int: row = Session( title=title, agent=agent, @@ -144,7 +149,14 @@ async def session_new( protocol=protocol, meta_json=meta if meta is not None else SessionMeta(), ) - return await self._transaction(lambda db: row.insert(db).lastrowid) + def insert(db): + identity = row.insert(db).lastrowid + assert identity is not None, "A saved ACP session must have a SQLite row id" + return identity + return await self._transaction(insert) + + async def session_started(self, id: int, agent_session_id: str) -> None: + await self._update_session(id, agent_session_id=agent_session_id) async def _update_session(self, id: int, **changes) -> None: def update(db): diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index 0fdba6d5a..081a13a33 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -210,15 +210,8 @@ async def run(self) -> str: navigator = self.navigator try: - await navigator.mount_loading(self) prepared = await navigator.app.navigation_reader.read(self.request) if prepared is None: - await navigator.app.session_navigation.close_many((self.view.id,)) - return navigator.app.selected_mode - if ( - navigator.app.workspace_sessions.views.get(self.view.id) - is not self.view - ): return navigator.app.selected_mode if not await navigator.app.preparation.run_thread( root_is_current, self.request.root diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index fcfcdfb30..2599918f0 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -83,7 +83,7 @@ async def open(self, context: NavigationContext) -> str: class SessionTarget(NavigationTarget): async def open(self, context: NavigationContext) -> str: app = context.app - destination = context.owner_mode if app.session_tracker.get_session(context.owner_mode) else "store" + destination = context.owner_mode if app.session_navigation.session_details(context.owner_mode) else "store" if app.selected_mode != destination: await app.select_session(destination) return app.selected_mode diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 6a263773a..ff6a4d2b9 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -44,6 +44,7 @@ from toad.session_presentation import OperationalSessionPresentation from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin from toad.session_tracker import SessionDetails, SidebarState +from toad.tab_identity.history_origin import SessionHistoryOrigin from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.conversation import Conversation @@ -311,7 +312,7 @@ def bind_thread(self, owner, root: str) -> None: def identity_changed(self, previous) -> None: if self.id is not None: self.app.session_tracker.bind_identity( - self.id, previous.thread_name(), self.thread_identity.thread_name() + self.session_details.identity, previous.thread_name(), self.thread_identity.thread_name() ) @classmethod @@ -364,6 +365,9 @@ def session_disposition(self) -> SessionDisposition: return self.presentation.operational_binding.session_disposition session_details: SessionDetails + + def history_origin(self) -> SessionHistoryOrigin: + return SessionHistoryOrigin(self.session_details.identity) """The registered entry supplies its existing details before mounting this view.""" def make_conversation(self) -> Conversation: @@ -559,12 +563,12 @@ def on_session_tabs_changed(self, message: CoreEventMessage) -> None: def on_session_title_changed(self, message: CoreEventMessage) -> None: """The user or the agent process named this session; the tab keeps it.""" if self.id is not None: - self.app.session_tracker.name_session(self.id, message.event.name) + self.app.session_tracker.name_session(self.session_details.identity, message.event.name) @handles(core_events.SessionThreadTitle) def on_session_thread_title(self, message: CoreEventMessage) -> None: """The thread presented a title; an authored name keeps the tab's label.""" - if (details := self.app.session_tracker.get_session(self.id)) is not None: + if (details := self.app.session_tracker.get_session(self.session_details.identity)) is not None: if details.update_thread_title(message.event.title): self.app.session_tracker.events.publish(SessionChangedEvent(self.id)) @@ -572,13 +576,13 @@ def on_session_thread_title(self, message: CoreEventMessage) -> None: def on_session_subtitle_changed(self, message: CoreEventMessage) -> None: if self.id is not None: self.app.session_tracker.update_session( - self.id, subtitle=message.event.subtitle + self.session_details.identity, subtitle=message.event.subtitle ) @handles(core_events.SessionPathChanged) def on_session_path_changed(self, message: CoreEventMessage) -> None: if self.id is not None: - self.app.session_tracker.update_session(self.id, path=message.event.path) + self.app.session_tracker.update_session(self.session_details.identity, path=message.event.path) def on_mount(self) -> None: self.observe_core(self.app.events) diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index fac8d8414..02870506e 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -185,6 +185,9 @@ async def present_selected(self, event: CoreEventMessage) -> None: self.present() +from toad.tab_identity.history_origin import WelcomeHistoryOrigin + + class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" @@ -205,6 +208,9 @@ class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False): "[b]↑ ↓[/b] choose [b]enter[/b] open" ) + def history_origin(self) -> WelcomeHistoryOrigin: + return WelcomeHistoryOrigin() + def __init__(self) -> None: super().__init__() app = cast("ToadApp", self.app) diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 68c30680f..0123cc40e 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -21,6 +21,7 @@ from toad.core.projection import MroProjection from toad.owner_preparation import ExistingOwnerPlan from toad.session_scope import SessionScope +from toad.tab_identity import TabIdentity from toad.session_tracker import ( CommsViewKey, NamedTab, @@ -36,8 +37,12 @@ class SessionEntry(DeclaredFamily, affix="Entry"): - def __init__(self, mode: str) -> None: - self.mode = mode + def __init__(self, identity: TabIdentity) -> None: + self.identity = identity + + @property + def mode(self) -> str: + return self.identity.mode_name @property def owner_mode(self) -> str: @@ -88,7 +93,7 @@ def entered(self, previous: str) -> None: """Only entries with a return destination retain entry intent.""" async def return_to(self, sessions: SessionNavigation) -> None: - await sessions.app.select_session(sessions.app.tab_order.previous(self.mode)) + await sessions.app.select_session(sessions.previous(self.identity)) def forget(self, sessions: SessionNavigation) -> None: """End the view's observations before forgetting its logical membership.""" @@ -113,7 +118,7 @@ def __init__( factory: Callable[[], MainScreen], original: tuple[str, ThreadIncarnation] | None = None, ) -> None: - super().__init__(details.mode_name) + super().__init__(details.identity) self.details = details self.factory = factory self.original = original @@ -179,7 +184,7 @@ async def reconnect(self, sessions, selected, snapshot, targets): ) def unobserved_tab(self) -> OpenTab: - return NamedTab(self.mode, self.details.name.display or "New Session") + return NamedTab(self.identity, self.details.name.display or "New Session") def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: source = self.source(sessions) @@ -198,7 +203,7 @@ def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: return self.unobserved_tab() if row is not None: return ThreadTab( - self.mode, source.session_disposition.present(row, row.disconnected) + self.identity, source.session_disposition.present(row, row.disconnected) ) return self.unobserved_tab() @@ -206,30 +211,30 @@ async def return_to(self, sessions: SessionNavigation) -> None: app = sessions.app # Another session is preferred to a file or history tab between them. remaining = [ - details.mode_name + details.identity for details in app.session_tracker.ordered_sessions - if details.mode_name != self.mode + if details.identity != self.identity ] await app.select_session( - app.tab_order.previous(self.mode, eligible=remaining or None) + sessions.previous(self.identity, eligible=remaining or None) ) def forget(self, sessions: SessionNavigation) -> None: super().forget(sessions) - sessions.app.session_tracker.close_session(self.mode) + sessions.app.session_tracker.close_session(self.identity) class HistorySessionEntry(SessionEntry, MroProjection[OpenTab]): def __init__( self, - mode: str, + identity: TabIdentity, key: CommsViewKey, project: Path, owner_root: str | None, participants: tuple[ThreadIncarnation, ...], owner, ) -> None: - super().__init__(mode) + super().__init__(identity) self.key, self.project, self.owner_root = key, project, owner_root self.participants, self.owner = participants, owner @@ -267,7 +272,7 @@ def source(self, sessions: SessionNavigation) -> MainScreen | None: return sessions.source(self.key.owner_mode) def unobserved_tab(self) -> OpenTab: - return NamedTab(self.mode, self.key.conversation.initial_label) + return NamedTab(self.identity, self.key.conversation.initial_label) def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: return self.project_value(observed.references_for(self.key.root), observed) @@ -280,7 +285,7 @@ def preparing_tab(self, references, observed) -> OpenTab: def referenced_tab(self, references, observed) -> OpenTab: conversation = self.key.conversation return NamedTab( - self.mode, + self.identity, conversation.label(references), conversation.unread(observed.sidebar.observation), ) @@ -293,7 +298,7 @@ async def return_to(self, sessions: SessionNavigation) -> None: await app.select_session( owner if sessions.get(owner) is not None - else app.tab_order.previous(self.mode) + else sessions.previous(self.identity) ) def sync_owner_root( @@ -320,16 +325,16 @@ def sync_project( class PreviewSessionEntry(SessionEntry): """A read-only file in its own tab.""" - def __init__(self, mode: str, path: Path, origin: str) -> None: - super().__init__(mode) + def __init__(self, identity: TabIdentity, path: Path, origin: str) -> None: + super().__init__(identity) self.path, self.origin = path, origin @classmethod - def of(cls, mode: str, path: Path, origin: str) -> PreviewSessionEntry: + def of(cls, identity: TabIdentity, path: Path, origin: str) -> PreviewSessionEntry: from toad.acp.agent_log import AgentLog return (AcpLogSessionEntry if AgentLog.writes(path) else cls)( - mode, path, origin + identity, path, origin ) def __call__(self): @@ -351,7 +356,7 @@ def address(self) -> Path: return self.path def unobserved_tab(self) -> OpenTab: - return NamedTab(self.mode, self.title) + return NamedTab(self.identity, self.title) def entered(self, previous: str) -> None: if previous != self.mode: @@ -362,7 +367,7 @@ async def return_to(self, sessions: SessionNavigation) -> None: target = ( self.origin if sessions.get(self.origin) is not None - else app.tab_order.previous(self.mode) + else sessions.previous(self.identity) ) await app.select_session(target) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index e590f6415..7f9c5fc69 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Callable from functools import partial -from itertools import count from pathlib import Path from typing import TYPE_CHECKING, cast @@ -22,7 +21,7 @@ from toad.core import events as core_events from toad.core.events import CoreEventStream -from toad.navigation_preparation import CommsNavigationRequest +from toad.navigation_preparation import CommsNavigationRequest, ThreadNavigationRequest from toad.session_entry import ( HistorySessionEntry, NativeSessionEntry, @@ -30,7 +29,10 @@ SessionEntry, ) from toad.session_scope import AgentAttached, AgentBinding, AgentSession -from toad.session_tracker import OpenTab, SessionDetails +from toad.session_tracker import CommsViewKey, OpenTab, SessionDetails +from toad.tab_identity import SessionTabIdentity, TabIdentity +from toad.tab_identity.kinds import AcpTab, HistoryTab, MailboxTab, PreviewTab +from toad.db import SessionMeta if TYPE_CHECKING: from agent_comms.ui_model.sidebar import ThreadRowModel @@ -45,7 +47,6 @@ class SessionNavigation: def __init__(self, app: ToadApp) -> None: self.app = app self.events = CoreEventStream(self) - self.identities = count(1) self.entries: dict[str, dict[str, SessionEntry]] = {} self._retire_task: asyncio.Task[None] | None = None self._retire_requested = False @@ -121,6 +122,52 @@ def source(self, mode: str) -> MainScreen | None: entry = self.get(mode) return entry.source(self) if entry else None + def session_details(self, mode: str | None) -> SessionDetails | None: + entry = self.get(mode) + return self.app.session_tracker.get_session(entry.identity) if entry else None + + 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: + def prepare(): + service = self.app.coordination_access.observation.observed().service + if service.root != Path(identity.root): + raise ValueError(f"Mailbox tab belongs to another bus: {identity.root}") + snapshot = service.registry.snapshot() + name = snapshot.canonical_name(identity.key.name) + if not identity.key.matches_recorded_name(name, snapshot): + raise ValueError(f"Mailbox tab's thread was replaced: {identity.key}") + return ThreadNavigationRequest(service, name, self.app.project_dir, ()).read() + + prepared = await self.app.preparation.run_thread(prepare) + await self.new( + 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) + + def participants(): + service = self.app.coordination_access.observation.observed().service + if service.root != Path(identity.root): + raise ValueError(f"History tab belongs to another bus: {identity.root}") + return identity.key.threads_needed( + service.registry.snapshot(), source.thread_identity.thread_name() + ) + + entry = HistorySessionEntry( + identity, + CommsViewKey(identity.root, identity.owner.mode_name, identity.key), + source.project_root, 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.""" for entry in self.members: @@ -186,7 +233,8 @@ async def open(self, entry: SessionEntry, *, after: str | None = None) -> str: if self.get(entry.mode) is not None: raise ValueError(f"Logical session already registered: {entry.mode}") self.entries.setdefault(entry.owner_mode, {})[entry.mode] = entry - app.tab_order.open(entry.mode, after=after) + previous = self.get(after) + app.tab_order.open(entry.identity, after=previous.identity if previous else None) self.publish() await app.select_session(entry.mode) await entry.ready(self) @@ -194,12 +242,17 @@ async def open(self, entry: SessionEntry, *, after: str | None = None) -> str: async def new( self, + identity: SessionTabIdentity, factory: Callable[[], MainScreen], *, title: str = "New Session", original=None, ) -> SessionDetails: - details = self.app.session_tracker.new_session(title=title) + 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] + details = self.app.session_tracker.new_session(identity, title=title) await self.open(NativeSessionEntry(details, factory, original)) return details @@ -213,7 +266,6 @@ async def launch( initial_prompt: str | None = None, ) -> None: from toad.agents import read_agents - from toad.db import DB from toad.screens.main import MainScreen app = self.app @@ -233,7 +285,7 @@ async def launch( return agent, title = None, None if session_pk is not None: - saved = await DB().session_get(session_pk) + saved = await app.tab_order.db.session_get(session_pk) if saved is not None: title, agent = saved.title, saved.meta_json.agent_data if agent is None: @@ -244,7 +296,13 @@ async def launch( app.show_error("Agent not found", "Launch agent") return project = project_path if project_path is not None else app.project_dir + if session_pk is None: + session_pk = await app.tab_order.db.session_new( + title or "New Session", agent.name, agent.identity, agent_session_id, + meta=SessionMeta(cwd=project, agent_data=agent), + ) await self.new( + AcpTab(session_pk), lambda: MainScreen( project, make_binding(agent), @@ -301,7 +359,10 @@ async def history( entry = self.find(prepared.key.conversation.view_identity(prepared.key)) if entry is None: entry = HistorySessionEntry( - f"comms-{next(self.identities)}", + HistoryTab( + prepared.key.conversation, source.history_origin(), + prepared.key.root, + ), prepared.key, project_path, prepared.owner_root, @@ -319,7 +380,7 @@ async def preview(self, path: Path, *, position=None) -> str: entry = self.find(path) if entry is None: entry = PreviewSessionEntry.of( - f"preview-{next(self.identities)}", path, self.app.selected_mode + PreviewTab(path), path, self.app.selected_mode ) await self.open(entry, after=self.app.selected_mode) else: @@ -370,7 +431,7 @@ async def close_many(self, modes: tuple[str, ...]) -> None: ) for member in closing: member.forget(self) - self.app.tab_order.close({member.mode for member in closing}) + 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. for member in closing: diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 85d718dd4..8c8d5edd5 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -484,7 +484,7 @@ async def _trim_retained(self, selected: Conversation) -> None: *( owner for identity in app.tab_order.recent - for owner in candidates.get(identity, ()) + for owner in candidates.get(identity.mode_name, ()) ), ) ) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index fc8cc092c..d4f8c2ac3 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -333,6 +333,10 @@ def remove(self) -> Awaitable[None]: ... class ProjectSessionOrigin(SessionOrigin, SessionScope): + @abstractmethod + def history_origin(self): + """The durable opening source of this view's history tabs.""" + """A project source may navigate and resolve threads without an ACP session.""" @property diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index 2be0f5742..61c165d99 100644 --- a/src/toad/session_tracker.py +++ b/src/toad/session_tracker.py @@ -2,7 +2,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from operator import attrgetter from time import time from typing import TYPE_CHECKING, Iterable, Sequence @@ -11,6 +10,8 @@ from textual.content import Content from toad.core.events import CoreEventStream, SessionChangedEvent, SessionClosedEvent +from toad.tab_identity import SessionTabIdentity, TabIdentity +from toad.tab_order import TabOrder if TYPE_CHECKING: from toad.conversation_kind import ConversationKind @@ -20,9 +21,13 @@ class OpenTab(ABC): """A tab reads its label and activity from the source it represents.""" - mode_name: str + identity: TabIdentity busy = False + @property + def mode_name(self) -> str: + return self.identity.mode_name + @property @abstractmethod def title(self) -> str: ... @@ -143,10 +148,8 @@ def adopt_identity(self, previous: str, current: str) -> SessionName: class SessionDetails: """Tracks a concurrent session.""" - index: int - """Index of session, used in sorting.""" - mode_name: str - """The screen mode name.""" + identity: SessionTabIdentity + """The durable thing this tab opens.""" name: SessionName = field( default_factory=lambda: ThreadSessionName("New Session") ) @@ -161,6 +164,10 @@ class SessionDetails: created_at: float = field(default_factory=time) """Creation time for local sessions without a wire identity.""" + @property + def mode_name(self) -> str: + return self.identity.mode_name + @property def initial_title(self) -> str | None: """The workspace placeholder is not an authored session name.""" @@ -187,59 +194,53 @@ def update_thread_title(self, thread_title: str) -> bool: class SessionTracker: """Tracks concurrent agent settings""" - def __init__(self) -> None: - self.sessions: dict[str, SessionDetails] = {} - self._session_index = 0 + def __init__(self, order: TabOrder) -> None: + self.sessions: dict[TabIdentity, SessionDetails] = {} + self.order = order self.events = CoreEventStream(self) @property def session_count(self) -> int: return len(self.sessions) - def new_session(self, *, title: str = "New Session") -> SessionDetails: - self._session_index += 1 - mode_name = f"session-{self._session_index}" - session_meta = SessionDetails( - index=self._session_index, - mode_name=mode_name, - name=ThreadSessionName(title), - ) - self.sessions[mode_name] = session_meta - self.events.publish(SessionChangedEvent(mode_name)) - return session_meta + def new_session(self, identity: SessionTabIdentity, *, title: str = "New Session") -> SessionDetails: + details = SessionDetails(identity=identity, name=ThreadSessionName(title)) + self.sessions[identity] = details + self.events.publish(SessionChangedEvent(identity.mode_name)) + return details - def close_session(self, mode_name: str) -> None: - if mode_name in self.sessions: - del self.sessions[mode_name] - self.events.publish(SessionClosedEvent(mode_name)) + def close_session(self, identity: TabIdentity) -> None: + if identity in self.sessions: + del self.sessions[identity] + self.events.publish(SessionClosedEvent(identity.mode_name)) - def bind_identity(self, mode_name: str, previous: str, current: str) -> None: + def bind_identity(self, identity: TabIdentity, previous: str, current: str) -> None: """The thread's name arrives: the user's name keeps the tab's label.""" - details = self.sessions[mode_name] + details = self.sessions[identity] adopted = details.name.adopt_identity(previous, current) if adopted != details.name: details.name = adopted - self.events.publish(SessionChangedEvent(mode_name)) + self.events.publish(SessionChangedEvent(identity.mode_name)) - def get_session(self, mode_name: str) -> SessionDetails | None: - return self.sessions.get(mode_name, None) + def get_session(self, identity: TabIdentity) -> SessionDetails | None: + return self.sessions.get(identity) - def name_session(self, mode_name: str, name: str) -> SessionDetails: + def name_session(self, identity: TabIdentity, name: str) -> SessionDetails: """The user named this session; its tab keeps the name.""" - session_details = self.sessions[mode_name] + session_details = self.sessions[identity] before = session_details.name.display session_details.name_session(name) if session_details.name.display != before: - self.events.publish(SessionChangedEvent(mode_name)) + self.events.publish(SessionChangedEvent(identity.mode_name)) return session_details def update_session( self, - mode_name: str, + identity: TabIdentity, subtitle: str | None = None, path: str | None = None, ) -> SessionDetails: - session_details = self.sessions[mode_name] + session_details = self.sessions[identity] before = ( session_details.name.display, session_details.subtitle, @@ -255,12 +256,12 @@ def update_session( session_details.path, ) if after != before: - self.events.publish(SessionChangedEvent(mode_name)) + self.events.publish(SessionChangedEvent(identity.mode_name)) return session_details @property def ordered_sessions(self) -> Sequence[SessionDetails]: - return sorted(self.sessions.values(), key=attrgetter("index")) + return self.order.project(self.sessions) def __iter__(self) -> Iterable[SessionDetails]: return iter(self.ordered_sessions) diff --git a/src/toad/tab_identity/__init__.py b/src/toad/tab_identity/__init__.py new file mode 100644 index 000000000..4074baa36 --- /dev/null +++ b/src/toad/tab_identity/__init__.py @@ -0,0 +1,24 @@ +"""Durable tab identities; Textual names are a projection of the wire identity.""" + +from abc import abstractmethod +from hashlib import sha256 +from functools import cached_property +import json + +from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import FieldCodec + + +class TabIdentity(DeclaredFamily, affix="Tab", discovery_package="toad.tab_identity"): + @cached_property + def mode_name(self) -> str: + key = json.dumps(FieldCodec.encode(self), sort_keys=True, separators=(",", ":")) + return f"{self.declared_name}-{sha256(key.encode()).hexdigest()}" + + @abstractmethod + async def restore(self, navigation) -> None: ... + + +class SessionTabIdentity(TabIdentity): + """An agent or mailbox tab can own history tabs.""" + diff --git a/src/toad/tab_identity/history_origin.py b/src/toad/tab_identity/history_origin.py new file mode 100644 index 000000000..1d66ad42c --- /dev/null +++ b/src/toad/tab_identity/history_origin.py @@ -0,0 +1,41 @@ +"""History opened from a resident welcome view or from a durable agent tab.""" + +from abc import abstractmethod +from dataclasses import dataclass + +from agent_comms.declared_family import DeclaredFamily + +from toad.constants import WELCOME +from toad.tab_identity import SessionTabIdentity + + +class HistoryOrigin(DeclaredFamily, affix="HistoryOrigin"): + @property + @abstractmethod + def mode_name(self) -> str: ... + + @abstractmethod + async def restore(self, navigation) -> None: ... + + +@dataclass(frozen=True) +class WelcomeHistoryOrigin(HistoryOrigin): + @property + def mode_name(self) -> str: + return WELCOME + + async def restore(self, navigation) -> None: + await navigation.app.select_session(WELCOME) + + +@dataclass(frozen=True) +class SessionHistoryOrigin(HistoryOrigin): + identity: SessionTabIdentity + + @property + 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) diff --git a/src/toad/tab_identity/kinds.py b/src/toad/tab_identity/kinds.py new file mode 100644 index 000000000..742542e38 --- /dev/null +++ b/src/toad/tab_identity/kinds.py @@ -0,0 +1,58 @@ +"""Each tab kind carries the typed key and the operation that reopens it.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated + +from agent_comms.field_codec import PathText +from agent_comms.thread_identity import ThreadIncarnation + +from toad.conversation_kind import ConversationKind +from toad.tab_identity import SessionTabIdentity, TabIdentity +from toad.tab_identity.history_origin import HistoryOrigin + + +@dataclass(frozen=True) +class AcpTab(SessionTabIdentity): + key: int + + async def restore(self, navigation) -> None: + saved = await navigation.app.tab_order.db.session_get(self.key) + if saved is None: + navigation.app.tab_order.close((self,)) + navigation.app.log.error(f"Removed tab for missing ACP session {self.key}") + return + await navigation.launch( + saved.agent_identity, + agent_session_id=saved.agent_session_id, + session_pk=self.key, + project_path=saved.meta_json.cwd, + ) + + +@dataclass(frozen=True) +class MailboxTab(SessionTabIdentity): + key: ThreadIncarnation + root: str + + async def restore(self, navigation) -> None: + await navigation.restore_mailbox(self) + + +@dataclass(frozen=True) +class HistoryTab(TabIdentity): + key: ConversationKind + owner: HistoryOrigin + root: str + + async def restore(self, navigation) -> None: + await self.owner.restore(navigation) + await navigation.restore_history(self) + + +@dataclass(frozen=True) +class PreviewTab(TabIdentity): + key: Annotated[Path, PathText] + + async def restore(self, navigation) -> None: + await navigation.preview(self.key) diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index 5bfdedf27..5ef440d84 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -4,34 +4,35 @@ from toad.core.events import CoreEventStream, TabHistoryChanged from toad.db import DB +from toad.tab_identity import TabIdentity class TabOrder: def __init__( - self, focus: Callable[[str, int | None], object], home: str, db: DB + self, focus: Callable[[TabIdentity, int | None], object], home: str, db: DB ) -> None: # What is shown while no tab is open. self.home = home self.db = db self.db.prepare() - self._visits: list[str] = [] + self._visits: list[TabIdentity] = [] self._cursor = -1 self._focus = focus self.events = CoreEventStream(self) - def __contains__(self, mode: str | None) -> bool: + def __contains__(self, mode: TabIdentity | None) -> bool: return mode in self.names @property - def names(self) -> tuple[str, ...]: - return tuple(str(row.session_id) for row in self.db.tab_list()) + def names(self) -> tuple[TabIdentity, ...]: + return tuple(row.identity for row in self.db.tab_list()) @property - def recent(self) -> tuple[str, ...]: + def recent(self) -> tuple[TabIdentity, ...]: """Visited sources first, derived from the existing navigation history.""" return tuple(dict.fromkeys((*reversed(self._visits[:self._cursor + 1]), *self.names))) - def open(self, mode: str, *, after: str | None = None) -> None: + def open(self, mode: TabIdentity, *, after: TabIdentity | None = None) -> None: if mode in self: return order = list(self.names) @@ -39,18 +40,17 @@ def open(self, mode: str, *, after: str | None = None) -> None: order.insert(insertion, mode) self.reorder(order) - def reorder(self, modes: Collection[str]) -> None: - for position, mode in enumerate(modes): - self.db.tab_write(int(mode), position) + def reorder(self, modes: Collection[TabIdentity]) -> None: + self.db.tab_reorder(tuple(modes)) - def project[T](self, values: Mapping[str, T]) -> tuple[T, ...]: + def project[T](self, values: Mapping[TabIdentity, T]) -> tuple[T, ...]: return tuple(values[mode] for mode in self.names if mode in values) - def previous(self, current: str, *, eligible: Collection[str] | None = None) -> str: + def previous(self, current: TabIdentity, *, eligible: Collection[TabIdentity] | None = None) -> TabIdentity | None: """The tab to show on leaving ``current``; home once no tab is open.""" opened = self.names if not opened: - return self.home + return None candidates = [ mode for mode in opened @@ -64,7 +64,7 @@ def previous(self, current: str, *, eligible: Collection[str] | None = None) -> candidates[0] if candidates else current if current in self else opened[-1], ) - def history_target(self, direction: int) -> tuple[int, str] | None: + def history_target(self, direction: int) -> tuple[int, TabIdentity] | None: index = self._cursor + direction if 0 <= index < len(self._visits): return index, self._visits[index] @@ -78,7 +78,7 @@ def navigate(self, direction: int) -> None: else: self._focus(mode, index) - def record_visit(self, mode: str, history_index: int | None = None) -> None: + def record_visit(self, mode: TabIdentity, history_index: int | None = None) -> None: if mode not in self: return if ( @@ -93,9 +93,9 @@ def record_visit(self, mode: str, history_index: int | None = None) -> None: self._cursor = len(self._visits) - 1 self.events.publish(TabHistoryChanged()) - def close(self, modes: Collection[str]) -> None: + def close(self, modes: Collection[TabIdentity]) -> None: for mode in modes: - self.db.tab_delete(int(mode)) + self.db.tab_delete(mode) self.reorder(self.names) retained = [] cursor = -1 diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index 6c6e64abb..51ff69600 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -184,7 +184,7 @@ def available(self, ctx: TargetContext) -> bool: return ( not ctx.targets and ctx.mode is not None - and ctx.app.session_tracker.get_session(ctx.mode) is not None + and ctx.app.session_navigation.session_details(ctx.mode) is not None ) def execute(self, ctx: TargetContext) -> None: diff --git a/src/toad/thread_navigation.py b/src/toad/thread_navigation.py index 7902292fb..40601b905 100644 --- a/src/toad/thread_navigation.py +++ b/src/toad/thread_navigation.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from agent_comms.errors import RelationViolationError +from toad.tab_identity.kinds import MailboxTab from toad.navigation_preparation import ( OpenThread, ThreadNavigationRequest, @@ -96,13 +97,13 @@ async def close(self) -> None: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - async def mount_loading(self, opening: ThreadOpening) -> None: - await self.app.session_navigation.new( - lambda: opening.view, title=opening.request.target - ) - async def mount(self, prepared: ThreadNavigation, opening: ThreadOpening) -> str: view = opening.view + await self.app.session_navigation.new( + MailboxTab(prepared.thread.incarnation, prepared.root), + lambda: view, title=prepared.thread.name, + original=(prepared.root, prepared.thread.incarnation), + ) prepared.bind(view) self.app.session_navigation.bind_opened( view, diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index 49cc2c4e3..9e4f03a9c 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -286,7 +286,7 @@ def mode_name(self) -> str | None: return self.navigation_tree.open_views.get(self.target_name) def action_open_selected(self) -> None: - if self.mode_name is None or cast("ToadApp", self.app).session_tracker.get_session(self.mode_name) is None: + if self.mode_name is None or cast("ToadApp", self.app).session_navigation.session_details(self.mode_name) is None: super().action_open_selected() else: self.navigation_tree.remember_row(self) From f607622b0a75d7df681b2a440b8d614ec53966d2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:21:05 -0400 Subject: [PATCH 06/10] Keep loading tabs typed and migrate identity consumers --- src/toad/db.py | 17 ++++--- src/toad/navigation_preparation.py | 10 ++++ src/toad/screens/welcome.py | 4 +- src/toad/session_navigation.py | 2 +- src/toad/session_scope.py | 4 +- src/toad/tab_order.py | 10 ++-- src/toad/thread_navigation.py | 17 ++++--- tests/model/test_session_name.py | 20 ++++---- tests/model/test_tab_visit_history.py | 47 +++++++++++-------- .../test_thread_open_during_slow_load.py | 8 ++-- 10 files changed, 85 insertions(+), 54 deletions(-) diff --git a/src/toad/db.py b/src/toad/db.py index e6cabe04e..7018b6433 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -97,18 +97,22 @@ def transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: async def _transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: return await asyncio.to_thread(self.transaction, operation) - def tab_write(self, identity: TabIdentity, position: int) -> None: - self.transaction(lambda db: OpenTabRow(identity, position).upsert(db)) - - def tab_delete(self, identity: TabIdentity) -> None: - self.transaction(lambda db: OpenTabRow.delete(db, identity=identity)) - def tab_reorder(self, identities: tuple[TabIdentity, ...]) -> None: def reorder(db): for position, identity in enumerate(identities): OpenTabRow(identity, position).upsert(db) + self.transaction(reorder) + def tab_close(self, identities: tuple[TabIdentity, ...]) -> None: + def close(db): + for identity in identities: + OpenTabRow.delete(db, identity=identity) + for position, row in enumerate(OpenTabRow.select(db, order_by=("position",))): + replace(row, position=position).upsert(db) + + self.transaction(close) + def tab_list(self) -> list[OpenTabRow]: return self.transaction(lambda db: OpenTabRow.select( db, order_by=("position",), @@ -153,6 +157,7 @@ def insert(db): identity = row.insert(db).lastrowid assert identity is not None, "A saved ACP session must have a SQLite row id" return identity + return await self._transaction(insert) async def session_started(self, id: int, agent_session_id: str) -> None: diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index 081a13a33..c542ebe63 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -19,6 +19,7 @@ from toad.owner_preparation import ExistingOwnerPlan from toad.session_tracker import CommsViewKey +from toad.tab_identity import SessionTabIdentity ResultT = TypeVar("ResultT") @@ -193,11 +194,13 @@ def __init__( owner_mode: str, request: ThreadNavigationRequest, origin: ThreadOrigin, + identity: SessionTabIdentity, ) -> None: from toad.screens.main import MainScreen self.navigator, self.owner_mode = navigator, owner_mode self.request, self.origin = request, origin + self.identity = identity self.view = MainScreen.opening(request, origin, navigator.app) self.task = asyncio.create_task(self.run(), name="thread-navigation") @@ -210,8 +213,15 @@ async def run(self) -> str: navigator = self.navigator try: + await navigator.mount_loading(self) prepared = await navigator.app.navigation_reader.read(self.request) if prepared is None: + await navigator.app.session_navigation.close_many((self.view.id,)) + return navigator.app.selected_mode + if ( + navigator.app.workspace_sessions.views.get(self.view.id) + is not self.view + ): return navigator.app.selected_mode if not await navigator.app.preparation.run_thread( root_is_current, self.request.root diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 02870506e..39c3e6732 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -25,6 +25,7 @@ from toad.navigation_target import channel_target, ThreadTarget from toad.screens.session_view import SessionView from toad.session_scope import ProjectSessionOrigin +from toad.tab_identity.history_origin import WelcomeHistoryOrigin from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import ( @@ -185,9 +186,6 @@ async def present_selected(self, event: CoreEventMessage) -> None: self.present() -from toad.tab_identity.history_origin import WelcomeHistoryOrigin - - class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 7f9c5fc69..c599bbd7b 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -110,7 +110,7 @@ def processes(self): for process in source.presentation.operational_binding.borrowed_processes() ) - def get(self, mode: str) -> SessionEntry | None: + def get(self, mode: str | None) -> SessionEntry | None: return next( (group[mode] for group in self.entries.values() if mode in group), None ) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index d4f8c2ac3..2787d1c9f 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -333,12 +333,12 @@ def remove(self) -> Awaitable[None]: ... class ProjectSessionOrigin(SessionOrigin, SessionScope): + """A project source may navigate and resolve threads without an ACP session.""" + @abstractmethod def history_origin(self): """The durable opening source of this view's history tabs.""" - """A project source may navigate and resolve threads without an ACP session.""" - @property def origin(self) -> SessionOrigin: return self diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index 5ef440d84..7eac70e7f 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -46,8 +46,10 @@ def reorder(self, modes: Collection[TabIdentity]) -> None: def project[T](self, values: Mapping[TabIdentity, T]) -> tuple[T, ...]: return tuple(values[mode] for mode in self.names if mode in values) - def previous(self, current: TabIdentity, *, eligible: Collection[TabIdentity] | None = None) -> TabIdentity | None: - """The tab to show on leaving ``current``; home once no tab is open.""" + def previous( + self, current: TabIdentity, *, eligible: Collection[TabIdentity] | None = None + ) -> TabIdentity | None: + """The previous tab, or no tab when the roster is empty.""" opened = self.names if not opened: return None @@ -94,9 +96,7 @@ def record_visit(self, mode: TabIdentity, history_index: int | None = None) -> N self.events.publish(TabHistoryChanged()) def close(self, modes: Collection[TabIdentity]) -> None: - for mode in modes: - self.db.tab_delete(mode) - self.reorder(self.names) + self.db.tab_close(tuple(modes)) retained = [] cursor = -1 for index, mode in enumerate(self._visits): diff --git a/src/toad/thread_navigation.py b/src/toad/thread_navigation.py index 40601b905..7bd5b6183 100644 --- a/src/toad/thread_navigation.py +++ b/src/toad/thread_navigation.py @@ -65,6 +65,11 @@ async def open(self, *, owner_mode: str, project_path: Path, target: str) -> str requested_root = str(comms.root) if not origin.current(self, owner_mode): return app.selected_mode + identity = await app.preparation.run_thread( + lambda: MailboxTab( + comms.registry.snapshot().require(target).incarnation, requested_root + ) + ) mounted_root = str(Path(requested_root).expanduser()) open_threads = [] for details in app.session_tracker.ordered_sessions: @@ -83,7 +88,7 @@ async def open(self, *, owner_mode: str, project_path: Path, target: str) -> str key = owner_mode, request.root, request.target if pending := self.pending.get(key): return await asyncio.shield(pending.task) - opening = ThreadOpening(self, owner_mode, request, origin) + opening = ThreadOpening(self, owner_mode, request, origin, identity) self.pending[key] = opening return await asyncio.shield(opening.task) @@ -97,13 +102,13 @@ async def close(self) -> None: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - async def mount(self, prepared: ThreadNavigation, opening: ThreadOpening) -> str: - view = opening.view + async def mount_loading(self, opening: ThreadOpening) -> None: await self.app.session_navigation.new( - MailboxTab(prepared.thread.incarnation, prepared.root), - lambda: view, title=prepared.thread.name, - original=(prepared.root, prepared.thread.incarnation), + opening.identity, lambda: opening.view, title=opening.request.target ) + + async def mount(self, prepared: ThreadNavigation, opening: ThreadOpening) -> str: + view = opening.view prepared.bind(view) self.app.session_navigation.bind_opened( view, diff --git a/tests/model/test_session_name.py b/tests/model/test_session_name.py index 6cb81edf2..584e4a8c4 100644 --- a/tests/model/test_session_name.py +++ b/tests/model/test_session_name.py @@ -3,6 +3,9 @@ import pytest from toad.core.events import SessionChangedEvent +from toad.db import DB +from toad.tab_order import TabOrder +from toad.tab_identity.kinds import AcpTab from toad.session_tracker import ( AuthoredSessionName, SessionDetails, @@ -22,7 +25,7 @@ ], ) def test_session_label_owns_presented_title(name, expected): - details = SessionDetails(index=1, mode_name="session-1", name=name) + details = SessionDetails(identity=AcpTab(1), name=name) assert details.update_thread_title("Presented title") == (name.display != expected) assert details.name.display == expected @@ -46,18 +49,19 @@ def record(self, event: SessionChangedEvent, _subscription) -> None: self.append(event) -def test_session_tracker_publishes_only_changed_labels(): - tracker = SessionTracker() - details = tracker.new_session(title="old") +def test_session_tracker_publishes_only_changed_labels(tmp_path): + order = TabOrder(lambda identity, index: None, home="home", db=DB(tmp_path / "toad.db")) + tracker = SessionTracker(order) + details = tracker.new_session(AcpTab(1), title="old") changes = SessionChanges() with tracker.events.subscribe(SessionChangedEvent, changes.record): - tracker.bind_identity(details.mode_name, "old", "old") + tracker.bind_identity(details.identity, "old", "old") assert changes == [] - tracker.bind_identity(details.mode_name, "old", "new") + tracker.bind_identity(details.identity, "old", "new") assert details.name.display == "new" assert len(changes) == 1 - tracker.name_session(details.mode_name, "Chosen") + tracker.name_session(details.identity, "Chosen") assert len(changes) == 2 - tracker.bind_identity(details.mode_name, "new", "renamed") + tracker.bind_identity(details.identity, "new", "renamed") assert details.name.display == "Chosen" assert len(changes) == 2 diff --git a/tests/model/test_tab_visit_history.py b/tests/model/test_tab_visit_history.py index b7dd823ee..332501f95 100644 --- a/tests/model/test_tab_visit_history.py +++ b/tests/model/test_tab_visit_history.py @@ -1,35 +1,44 @@ """Tab order keeps one opening order and a branching visit history.""" from toad.tab_order import TabOrder +from toad.db import DB +from toad.tab_identity.kinds import PreviewTab -def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no_new_code(): +def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no_new_code(tmp_path): + first, second, third, new_kind, closed = ( + PreviewTab(tmp_path / name) for name in ("first", "second", "third", "new-kind", "closed") + ) focused = [] - order = TabOrder(lambda mode, index: focused.append((mode, index)), home="home") - for mode in ("first", "second", "third"): + order = TabOrder( + lambda mode, index: focused.append((mode, index)), + home="home", db=DB(tmp_path / "toad.db"), + ) + for mode in (first, second, third): order.open(mode) order.record_visit(mode) order.navigate(-1) mode, index = focused[-1] order.record_visit(mode, index) - order.open("new-kind", after=mode) - order.record_visit("new-kind") - assert order.names == ("first", "second", "new-kind", "third") + order.open(new_kind, after=mode) + order.record_visit(new_kind) + assert order.names == (first, second, new_kind, third) assert order.history_target(+1) is None - assert order.previous("new-kind") == "second" - order.close({"second", "third"}) + assert order.previous(new_kind) == second + order.close({second, third}) order.navigate(-1) - assert focused[-1][0] == "first" - assert order.previous("new-kind") == "first" - assert order.project({"first": 1, "new-kind": 2, "closed": 3}) == (1, 2) + assert focused[-1][0] == first + assert order.previous(new_kind) == first + assert order.project({first: 1, new_kind: 2, closed: 3}) == (1, 2) -def test_home_is_shown_only_while_no_tab_is_open(): - order = TabOrder(lambda mode, index: None, home="home") - assert order.previous("closed") == "home" - order.open("only") +def test_home_is_shown_only_while_no_tab_is_open(tmp_path): + only, closed = PreviewTab(tmp_path / "only"), PreviewTab(tmp_path / "closed") + order = TabOrder(lambda mode, index: None, home="home", db=DB(tmp_path / "toad.db")) + assert order.previous(closed) is None + order.open(only) # Leaving the only tab for "the previous one" stays on it: home is not a tab to go back to. - assert order.previous("only") == "only" - assert order.previous("closed") == "only" - order.close({"only"}) - assert order.previous("only") == "home" + assert order.previous(only) == only + assert order.previous(closed) == only + order.close({only}) + assert order.previous(only) is None diff --git a/tests/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index 26b6395f1..b484a083b 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -85,8 +85,8 @@ def journey_group(roster): agent = view.conversation.agent_binding await ui.until(lambda: bool(view.query(ThreadLoading)), why="the loading block") await ui.until_shows(ThreadLoading.LABEL) - assert tuple(app.tab_order.names) == (*tabs_before, tab) - details = app.session_tracker.get_session(tab) + assert tuple(app.tab_order.names) == (*tabs_before, app.session_navigation.get(tab).identity) + details = app.session_navigation.session_details(tab) assert details.name.display == "fresh-peer" workspace.release("load-fresh-peer") @@ -94,11 +94,11 @@ def journey_group(roster): await ui.until(lambda: view.conversation.agent_ready, seconds=12, why="the opened thread to be ready") await ui.until_shows("SAVED_FIRST_OPEN_fresh-peer") assert app.selected_session is view and view.conversation.agent_binding is agent - assert app.session_tracker.get_session(tab) is details + assert app.session_navigation.session_details(tab) is details await ui.until(lambda: not view.query(ThreadLoading), why="the loading block to go") # This peer has no Core input queue. The real-owner first-message case is # test_native_journey.py::test_a_second_thread_opened_from_the_app_answers_its_first_message. - assert tuple(app.tab_order.names) == (*tabs_before, tab) + assert tuple(app.tab_order.names) == (*tabs_before, app.session_navigation.get(tab).identity) assert all(len(drawn) <= 3 and not any(name.startswith("pending-") for name in drawn) for drawn in app.tab_sets_drawn) finally: From 346a9675bfcbb07e0ded1bd5eacd63f3827873f8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:22:44 -0400 Subject: [PATCH 07/10] Restore welcome history through its reader identity owner --- src/toad/screens/welcome.py | 22 ++++++++++++---------- src/toad/session_scope.py | 3 +++ src/toad/tab_identity/__init__.py | 5 +---- src/toad/tab_identity/history_origin.py | 2 ++ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 39c3e6732..1bbebe8eb 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -26,6 +26,7 @@ from toad.screens.session_view import SessionView from toad.session_scope import ProjectSessionOrigin from toad.tab_identity.history_origin import WelcomeHistoryOrigin +from toad.thread_identity import ThreadIdentity from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import ( @@ -233,19 +234,20 @@ def navigation_context(self) -> NavigationContext: self.app, self.id, self.project_path, self.thread_identity.thread_name() ) - async def open_sidebar_target(self, target: NavigationTarget) -> str: + def read_history_identity(self, project: str) -> ThreadIdentity: + service = self.app.coordination_access.observation.observed().service + return ThreadIdentity( + service.messaging.user_identity(project).incarnation, str(service.root) + ) + + async def prepare_history_origin(self) -> None: app = cast("ToadApp", self.app) project = str(self.project_path) - from toad.thread_identity import ThreadIdentity - - def identity(): - service = app.coordination_access.observation.observed().service - return ThreadIdentity( - service.messaging.user_identity(project).incarnation, str(service.root) - ) - with self.changing_identity(): - self._identity = await app.preparation.run_thread(identity) + self._identity = await app.preparation.run_thread(self.read_history_identity, project) + + async def open_sidebar_target(self, target: NavigationTarget) -> str: + await self.prepare_history_origin() return await super().open_sidebar_target(target) async def prepare_presentation(self) -> None: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 2787d1c9f..15121eaf6 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -339,6 +339,9 @@ class ProjectSessionOrigin(SessionOrigin, SessionScope): def history_origin(self): """The durable opening source of this view's history tabs.""" + async def prepare_history_origin(self) -> None: + """An agent view already has its thread; resident views acquire theirs.""" + @property def origin(self) -> SessionOrigin: return self diff --git a/src/toad/tab_identity/__init__.py b/src/toad/tab_identity/__init__.py index 4074baa36..2f94ca2dc 100644 --- a/src/toad/tab_identity/__init__.py +++ b/src/toad/tab_identity/__init__.py @@ -1,9 +1,7 @@ """Durable tab identities; Textual names are a projection of the wire identity.""" from abc import abstractmethod -from hashlib import sha256 from functools import cached_property -import json from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec @@ -12,8 +10,7 @@ class TabIdentity(DeclaredFamily, affix="Tab", discovery_package="toad.tab_identity"): @cached_property def mode_name(self) -> str: - key = json.dumps(FieldCodec.encode(self), sort_keys=True, separators=(",", ":")) - return f"{self.declared_name}-{sha256(key.encode()).hexdigest()}" + return f"{self.declared_name}-{FieldCodec.digest(self)}" @abstractmethod async def restore(self, navigation) -> None: ... diff --git a/src/toad/tab_identity/history_origin.py b/src/toad/tab_identity/history_origin.py index 1d66ad42c..4bb89285d 100644 --- a/src/toad/tab_identity/history_origin.py +++ b/src/toad/tab_identity/history_origin.py @@ -26,6 +26,8 @@ def mode_name(self) -> str: async def restore(self, navigation) -> None: await navigation.app.select_session(WELCOME) + source = navigation.app.workspace_sessions.origin(WELCOME).require() + await source.prepare_history_origin() @dataclass(frozen=True) From bb10b021d5351341fbc23d17e6dec9de8ef31804 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:25:57 -0400 Subject: [PATCH 08/10] Keep automatic titles for ACP launches saved before their handshake --- src/toad/widgets/conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 0e7e15172..764fa056d 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -589,7 +589,7 @@ def _initialize_session( self._agent_session_id = agent_session_id self._session_pk = session_pk self._auto_title_eligible = ( - agent_session_id is None and session_pk is None and session_title is None + agent_session_id is None and session_title is None ) self._agent_fail = False self._mouse_down_offset: Offset | None = None From 3dbaef46fd4afb1f2bdb455ed3df8fe999ba6808 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:46:20 -0400 Subject: [PATCH 09/10] Own tab queries in memory and persist roster writes off the UI thread --- src/toad/application_lifetime.py | 10 ++++-- src/toad/db.py | 22 ++++--------- src/toad/session_navigation.py | 17 +++++----- src/toad/tab_identity/__init__.py | 7 ++++ src/toad/tab_identity/kinds.py | 11 ++++--- src/toad/tab_order.py | 47 ++++++++++++++++++--------- tests/model/test_tab_visit_history.py | 28 +++++++++++----- 7 files changed, 87 insertions(+), 55 deletions(-) diff --git a/src/toad/application_lifetime.py b/src/toad/application_lifetime.py index 9128556b6..a68c98aea 100644 --- a/src/toad/application_lifetime.py +++ b/src/toad/application_lifetime.py @@ -12,6 +12,7 @@ from toad import get_version from toad.version import VersionMonitor from toad.tab_identity.kinds import MailboxTab +from toad.tab_identity import TabTargetGone if TYPE_CHECKING: from ssl import SSLContext @@ -120,9 +121,12 @@ async def start(self) -> None: if app.saved_settings.ensure_installation(): app.call_later(self.usage.publish, "toad-install") self.usage.publish("toad-run") - database = app.tab_order.db - for tab in database.tab_list(): - await tab.identity.restore(app.session_navigation) + for identity in await app.tab_order.load(): + try: + 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}") if self.resumed_session is not None: # The thread's declaration decides how it opens, as from the sidebar. navigation = await app.preparation.run_thread(self.launch_navigation) diff --git a/src/toad/db.py b/src/toad/db.py index 7018b6433..4709b99cf 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -85,7 +85,7 @@ def remember(self, db: sqlite3.Connection) -> None: class DB: - """Toad's durable state, including the sole open-tab roster.""" + """Toad's durable state; its transaction owner runs off the UI thread.""" def __init__(self, path: Path | None = None): self.path = path if path is not None else paths.get_state() / "toad.db" @@ -97,24 +97,16 @@ def transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: async def _transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T: return await asyncio.to_thread(self.transaction, operation) - def tab_reorder(self, identities: tuple[TabIdentity, ...]) -> None: - def reorder(db): + async def tab_roster(self, identities: tuple[TabIdentity, ...]) -> None: + def write(db): + OpenTabRow.delete(db) for position, identity in enumerate(identities): OpenTabRow(identity, position).upsert(db) - self.transaction(reorder) + await self._transaction(write) - def tab_close(self, identities: tuple[TabIdentity, ...]) -> None: - def close(db): - for identity in identities: - OpenTabRow.delete(db, identity=identity) - for position, row in enumerate(OpenTabRow.select(db, order_by=("position",))): - replace(row, position=position).upsert(db) - - self.transaction(close) - - def tab_list(self) -> list[OpenTabRow]: - return self.transaction(lambda db: OpenTabRow.select( + async def tab_list(self) -> list[OpenTabRow]: + return await self._transaction(lambda db: OpenTabRow.select( db, order_by=("position",), )) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index c599bbd7b..584cfb84c 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -30,7 +30,7 @@ ) from toad.session_scope import AgentAttached, AgentBinding, AgentSession from toad.session_tracker import CommsViewKey, OpenTab, SessionDetails -from toad.tab_identity import SessionTabIdentity, TabIdentity +from toad.tab_identity import SessionTabIdentity, TabIdentity, TabTargetGone from toad.tab_identity.kinds import AcpTab, HistoryTab, MailboxTab, PreviewTab from toad.db import SessionMeta @@ -134,11 +134,11 @@ async def restore_mailbox(self, identity: MailboxTab) -> None: def prepare(): service = self.app.coordination_access.observation.observed().service if service.root != Path(identity.root): - raise ValueError(f"Mailbox tab belongs to another bus: {identity.root}") + raise TabTargetGone(identity, f"Mailbox belongs to another bus: {identity.root}") snapshot = service.registry.snapshot() name = snapshot.canonical_name(identity.key.name) if not identity.key.matches_recorded_name(name, snapshot): - raise ValueError(f"Mailbox tab's thread was replaced: {identity.key}") + raise TabTargetGone(identity, f"Mailbox thread was replaced: {identity.key}") return ThreadNavigationRequest(service, name, self.app.project_dir, ()).read() prepared = await self.app.preparation.run_thread(prepare) @@ -154,7 +154,7 @@ async def restore_history(self, identity: HistoryTab) -> None: def participants(): service = self.app.coordination_access.observation.observed().service if service.root != Path(identity.root): - raise ValueError(f"History tab belongs to another bus: {identity.root}") + raise TabTargetGone(identity, f"History belongs to another bus: {identity.root}") return identity.key.threads_needed( service.registry.snapshot(), source.thread_identity.thread_name() ) @@ -234,7 +234,7 @@ async def open(self, entry: SessionEntry, *, after: str | None = None) -> str: raise ValueError(f"Logical session already registered: {entry.mode}") self.entries.setdefault(entry.owner_mode, {})[entry.mode] = entry previous = self.get(after) - app.tab_order.open(entry.identity, after=previous.identity if previous else None) + await app.tab_order.open(entry.identity, after=previous.identity if previous else None) self.publish() await app.select_session(entry.mode) await entry.ready(self) @@ -269,6 +269,7 @@ async def launch( from toad.screens.main import MainScreen app = self.app + database = await app.get_db() make_binding: Callable[[AgentDefinition], AgentBinding] = AgentAttached if agent_session_id is not None: make_binding = partial(AgentSession, session_id=agent_session_id) @@ -285,7 +286,7 @@ async def launch( return agent, title = None, None if session_pk is not None: - saved = await app.tab_order.db.session_get(session_pk) + saved = await database.session_get(session_pk) if saved is not None: title, agent = saved.title, saved.meta_json.agent_data if agent is None: @@ -297,7 +298,7 @@ async def launch( return project = project_path if project_path is not None else app.project_dir if session_pk is None: - session_pk = await app.tab_order.db.session_new( + session_pk = await database.session_new( title or "New Session", agent.name, agent.identity, agent_session_id, meta=SessionMeta(cwd=project, agent_data=agent), ) @@ -431,7 +432,7 @@ async def close_many(self, modes: tuple[str, ...]) -> None: ) for member in closing: member.forget(self) - self.app.tab_order.close({member.identity for member in closing}) + 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. for member in closing: diff --git a/src/toad/tab_identity/__init__.py b/src/toad/tab_identity/__init__.py index 2f94ca2dc..04489b1ce 100644 --- a/src/toad/tab_identity/__init__.py +++ b/src/toad/tab_identity/__init__.py @@ -7,6 +7,13 @@ from agent_comms.field_codec import FieldCodec +class TabTargetGone(Exception): + def __init__(self, identity: "TabIdentity", reason: str) -> None: + self.identity = identity + self.reason = reason + super().__init__(f"{identity}: {reason}") + + class TabIdentity(DeclaredFamily, affix="Tab", discovery_package="toad.tab_identity"): @cached_property def mode_name(self) -> str: diff --git a/src/toad/tab_identity/kinds.py b/src/toad/tab_identity/kinds.py index 742542e38..bc537473f 100644 --- a/src/toad/tab_identity/kinds.py +++ b/src/toad/tab_identity/kinds.py @@ -8,7 +8,7 @@ from agent_comms.thread_identity import ThreadIncarnation from toad.conversation_kind import ConversationKind -from toad.tab_identity import SessionTabIdentity, TabIdentity +from toad.tab_identity import SessionTabIdentity, TabIdentity, TabTargetGone from toad.tab_identity.history_origin import HistoryOrigin @@ -17,11 +17,10 @@ class AcpTab(SessionTabIdentity): key: int async def restore(self, navigation) -> None: - saved = await navigation.app.tab_order.db.session_get(self.key) + database = await navigation.app.get_db() + saved = await database.session_get(self.key) if saved is None: - navigation.app.tab_order.close((self,)) - navigation.app.log.error(f"Removed tab for missing ACP session {self.key}") - return + raise TabTargetGone(self, f"Saved ACP session {self.key} no longer exists") await navigation.launch( saved.agent_identity, agent_session_id=saved.agent_session_id, @@ -55,4 +54,6 @@ class PreviewTab(TabIdentity): key: Annotated[Path, PathText] async def restore(self, navigation) -> None: + 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) diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index 7eac70e7f..17291732d 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -1,6 +1,10 @@ """One owner for logical open order and visited-tab navigation.""" +import asyncio from collections.abc import Callable, Collection, Mapping +from contextlib import asynccontextmanager + +from agent_comms.child_process import join_retirement from toad.core.events import CoreEventStream, TabHistoryChanged from toad.db import DB @@ -13,35 +17,47 @@ def __init__( ) -> None: # What is shown while no tab is open. self.home = home - self.db = db - self.db.prepare() + self._db = db + self._open: list[TabIdentity] = [] + self._write_lock = asyncio.Lock() self._visits: list[TabIdentity] = [] self._cursor = -1 self._focus = focus self.events = CoreEventStream(self) + async def load(self) -> tuple[TabIdentity, ...]: + async with self._write_lock: + self._open = [row.identity for row in await self._db.tab_list()] + return self.names + + @asynccontextmanager + async def editing(self): + async with self._write_lock: + yield + # A cancelled caller must join its SQLite writer before releasing the lock. + await join_retirement(asyncio.create_task(self._db.tab_roster(self.names))) + def __contains__(self, mode: TabIdentity | None) -> bool: - return mode in self.names + return mode in self._open @property def names(self) -> tuple[TabIdentity, ...]: - return tuple(row.identity for row in self.db.tab_list()) + return tuple(self._open) @property def recent(self) -> tuple[TabIdentity, ...]: """Visited sources first, derived from the existing navigation history.""" return tuple(dict.fromkeys((*reversed(self._visits[:self._cursor + 1]), *self.names))) - def open(self, mode: TabIdentity, *, after: TabIdentity | None = None) -> None: - if mode in self: - return - order = list(self.names) - insertion = order.index(after) + 1 if after in self else len(order) - order.insert(insertion, mode) - self.reorder(order) + async def open(self, mode: TabIdentity, *, after: TabIdentity | None = None) -> None: + async with self.editing(): + if mode not in self: + insertion = self._open.index(after) + 1 if after in self else len(self._open) + self._open.insert(insertion, mode) - def reorder(self, modes: Collection[TabIdentity]) -> None: - self.db.tab_reorder(tuple(modes)) + async def reorder(self, modes: Collection[TabIdentity]) -> None: + async with self.editing(): + self._open[:] = modes def project[T](self, values: Mapping[TabIdentity, T]) -> tuple[T, ...]: return tuple(values[mode] for mode in self.names if mode in values) @@ -95,8 +111,9 @@ def record_visit(self, mode: TabIdentity, history_index: int | None = None) -> N self._cursor = len(self._visits) - 1 self.events.publish(TabHistoryChanged()) - def close(self, modes: Collection[TabIdentity]) -> None: - self.db.tab_close(tuple(modes)) + async def close(self, modes: Collection[TabIdentity]) -> None: + async with self.editing(): + self._open[:] = (identity for identity in self._open if identity not in modes) retained = [] cursor = -1 for index, mode in enumerate(self._visits): diff --git a/tests/model/test_tab_visit_history.py b/tests/model/test_tab_visit_history.py index 332501f95..647b34d13 100644 --- a/tests/model/test_tab_visit_history.py +++ b/tests/model/test_tab_visit_history.py @@ -1,44 +1,54 @@ """Tab order keeps one opening order and a branching visit history.""" +import pytest + from toad.tab_order import TabOrder from toad.db import DB from toad.tab_identity.kinds import PreviewTab -def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no_new_code(tmp_path): +@pytest.mark.asyncio +async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no_new_code(tmp_path): first, second, third, new_kind, closed = ( PreviewTab(tmp_path / name) for name in ("first", "second", "third", "new-kind", "closed") ) focused = [] + database = DB(tmp_path / "toad.db") + await database.create() order = TabOrder( lambda mode, index: focused.append((mode, index)), - home="home", db=DB(tmp_path / "toad.db"), + home="home", db=database, ) + await order.load() for mode in (first, second, third): - order.open(mode) + await order.open(mode) order.record_visit(mode) order.navigate(-1) mode, index = focused[-1] order.record_visit(mode, index) - order.open(new_kind, after=mode) + await order.open(new_kind, after=mode) order.record_visit(new_kind) assert order.names == (first, second, new_kind, third) assert order.history_target(+1) is None assert order.previous(new_kind) == second - order.close({second, third}) + await order.close({second, third}) order.navigate(-1) assert focused[-1][0] == first assert order.previous(new_kind) == first assert order.project({first: 1, new_kind: 2, closed: 3}) == (1, 2) -def test_home_is_shown_only_while_no_tab_is_open(tmp_path): +@pytest.mark.asyncio +async def test_home_is_shown_only_while_no_tab_is_open(tmp_path): only, closed = PreviewTab(tmp_path / "only"), PreviewTab(tmp_path / "closed") - order = TabOrder(lambda mode, index: None, home="home", db=DB(tmp_path / "toad.db")) + database = DB(tmp_path / "toad.db") + await database.create() + order = TabOrder(lambda mode, index: None, home="home", db=database) + await order.load() assert order.previous(closed) is None - order.open(only) + await order.open(only) # Leaving the only tab for "the previous one" stays on it: home is not a tab to go back to. assert order.previous(only) == only assert order.previous(closed) == only - order.close({only}) + await order.close({only}) assert order.previous(only) is None From 12b01ef6bd3854428678ee87ef2009c302db799e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 17:20:16 -0400 Subject: [PATCH 10/10] Save test sessions like launch does and stop rewriting an unchanged tab roster SessionNavigation.new now takes the tab identity, so every caller names one: - SessionNavigation.saved owns writing a native session's row and returns its AcpTab; launch and the test support's saved_tab both use it. - Thread tabs in tests pass MailboxTab(incarnation, root), as the restore path does. - The MCP permission observer called a deleted default_source; it now saves its real agent definition. TabOrder.editing writes the roster only when the open list changed, and the uncalled TabOrder.reorder is deleted. A saved AcpTab with no session row is now dropped at startup without a crash; a test covers it. Co-Authored-By: Claude Opus 5.5 --- src/toad/session_navigation.py | 25 +++++++++++++------ src/toad/tab_order.py | 10 +++----- .../core_adapters/mcp_permission_observer.py | 7 ++++-- tests/support/ui.py | 17 +++++++++++-- .../textual/test_hidden_session_retirement.py | 2 ++ tests/textual/test_initial_prompt_handoff.py | 7 +++--- tests/textual/test_plan_display.py | 2 ++ tests/textual/test_sidebar_retirement.py | 5 +++- .../test_sidebar_state_across_views.py | 2 ++ tests/textual/test_stale_tab_restore.py | 15 +++++++++++ tests/textual/test_tab_close.py | 2 ++ ...rkspace_tabs_share_one_agent_connection.py | 2 ++ 12 files changed, 75 insertions(+), 21 deletions(-) create mode 100644 tests/textual/test_stale_tab_restore.py diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 584cfb84c..5d1c4437d 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -256,6 +256,17 @@ async def new( await self.open(NativeSessionEntry(details, factory, original)) return details + async def saved( + self, agent: AgentDefinition, project: Path, *, + title: str = "New Session", agent_session_id: str | None = None, + ) -> AcpTab: + """A native session is saved before it opens; its row is the tab's identity.""" + database = await self.app.get_db() + return AcpTab(await database.session_new( + title, agent.name, agent.identity, agent_session_id, + meta=SessionMeta(cwd=project, agent_data=agent), + )) + async def launch( self, agent_identity: str, @@ -297,17 +308,17 @@ async def launch( app.show_error("Agent not found", "Launch agent") return project = project_path if project_path is not None else app.project_dir - if session_pk is None: - session_pk = await database.session_new( - title or "New Session", agent.name, agent.identity, agent_session_id, - meta=SessionMeta(cwd=project, agent_data=agent), - ) + tab = ( + AcpTab(session_pk) if session_pk is not None + else await self.saved(agent, project, title=title or "New Session", + agent_session_id=agent_session_id) + ) await self.new( - AcpTab(session_pk), + tab, lambda: MainScreen( project, make_binding(agent), - session_pk=session_pk, + session_pk=tab.key, initial_prompt=initial_prompt, ), title=title or "New Session", diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index 17291732d..9dfe291a3 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -33,9 +33,11 @@ async def load(self) -> tuple[TabIdentity, ...]: @asynccontextmanager async def editing(self): async with self._write_lock: + before = self.names yield - # A cancelled caller must join its SQLite writer before releasing the lock. - await join_retirement(asyncio.create_task(self._db.tab_roster(self.names))) + if self.names != before: + # A cancelled caller must join its SQLite writer before releasing the lock. + await join_retirement(asyncio.create_task(self._db.tab_roster(self.names))) def __contains__(self, mode: TabIdentity | None) -> bool: return mode in self._open @@ -55,10 +57,6 @@ async def open(self, mode: TabIdentity, *, after: TabIdentity | None = None) -> insertion = self._open.index(after) + 1 if after in self else len(self._open) self._open.insert(insertion, mode) - async def reorder(self, modes: Collection[TabIdentity]) -> None: - async with self.editing(): - self._open[:] = modes - def project[T](self, values: Mapping[TabIdentity, T]) -> tuple[T, ...]: return tuple(values[mode] for mode in self.names if mode in values) diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index ec8f15c7b..b079724dd 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -157,13 +157,16 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer app = ToadApp(agent_binding=NoAgent(), project_dir=str(root / "project")) async with app.run_test(size=(120, 40)) as pilot: # Toad starts with no tab open; the observed session is opened here. - await app.session_navigation.new(app.session_navigation.default_source) + definition = AgentDefinition.decode(AGENT_DATA) + await app.session_navigation.new( + await app.session_navigation.saved(definition, root / "project"), + lambda: MainScreen(app.project_dir, app.agent_binding)) session = app.selected_session assert isinstance(session, MainScreen) await session.wait_content_ready() await pilot.pause() view = session.conversation - agent = Agent(root / "project", AgentDefinition.decode(AGENT_DATA), SESSION_ID) + agent = Agent(root / "project", definition, SESSION_ID) observer = Observer(case, app, pilot, agent, session, root) try: view.bind_agent(agent) diff --git a/tests/support/ui.py b/tests/support/ui.py index 2b8e0c013..7e9c94c6c 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -12,15 +12,28 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING -from support.agents import AppAgents +from support.agents import AppAgents, command from toad.navigation_context import NavigationContext if TYPE_CHECKING: from textual.pilot import Pilot from toad.app import ToadApp + from toad.tab_identity.kinds import AcpTab + + +async def saved_tab(app: ToadApp) -> AcpTab: + """A test-made native session is saved the way launch saves one, with the app's agent; + a session with no agent saves an inert one.""" + from toad.session_scope import AgentAttached + + binding = app.agent_binding + agent = (binding.agent if isinstance(binding, AgentAttached) + else command("true", name="Test session", identity="test-session")) + return await app.session_navigation.saved(agent, Path(app.project_dir)) class NotShown(AssertionError): @@ -317,7 +330,7 @@ async def new_session(self) -> str: app = self.app details = await app.session_navigation.new( - lambda: MainScreen(app.project_dir, app.agent_binding) + await saved_tab(app), lambda: MainScreen(app.project_dir, app.agent_binding) ) await self.step() return details.mode_name diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index 9714241fd..78f95a715 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -14,6 +14,7 @@ from toad.screens.main import MainScreen from toad.target_commands import TargetContext from toad.widgets.conversation import Conversation +from toad.tab_identity.kinds import MailboxTab def declare_owners(workspace): @@ -36,6 +37,7 @@ def source(owner): modes = {} for owner in owners[1:]: details = await app.session_navigation.new( + MailboxTab(owner.incarnation, str(workspace.comms.root)), lambda owner=owner: source(owner), original=(str(workspace.comms.root), owner.incarnation)) modes[owner.name] = details.mode_name await ui.select_tab(first) diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index b2cb7ded9..4a61b40f5 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -10,6 +10,7 @@ from toad.core.input_events import UserInputSubmitted from toad.screens.main import MainScreen from toad.session_presentation import OperationalSessionSources +from support.ui import saved_tab class ObservedLaunchSources(OperationalSessionSources): @@ -41,7 +42,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th async with workspace.app(size=(110, 35)) as ui: app, _pilot = ui.textual owner = ObservedLaunchScreen(workspace.project, initial_prompt="INITIAL_LOCAL_UI_EVENT") - await app.session_navigation.new(lambda: owner) + await app.session_navigation.new(await saved_tab(app), lambda: owner) await owner.wait_content_ready() await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() @@ -57,7 +58,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th document, undo = editor.document, editor.history view._initial_prompt = "NEWEST_PENDING_LOCAL_UI_EVENT" owner.publication.clear() - await app.session_navigation.new(lambda: MainScreen(workspace.project)) + await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(workspace.project)) await app.selected_session.wait_content_ready() await owner.presentation.evict() assert view._initial_prompt is None @@ -81,7 +82,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th assert editor.text == "DRAFT" # The consumed value must stay absent on another eviction. - await app.session_navigation.new(lambda: MainScreen(workspace.project)) + await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(workspace.project)) await app.selected_session.wait_content_ready() await owner.presentation.evict() assert owner.presentation.state.initial_prompt is None diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index f86b14b47..b780e5a49 100644 --- a/tests/textual/test_plan_display.py +++ b/tests/textual/test_plan_display.py @@ -17,6 +17,7 @@ from toad.widgets.note import Note from toad.widgets.plan import Plan from toad.widgets.strike_text import StrikeText +from support.ui import saved_tab FIRST = {"entries": [{"content": f"ACP_{name}_ITEM", "priority": "high", "status": name} for name in get_args(PlanEntryStatus)]} @@ -151,6 +152,7 @@ async def test_a_plan_sent_while_no_view_shows_the_agent_is_drawn_when_its_view_ editor.insert("Detached plan draft") original_document, original_history = editor.document, editor.history await app.session_navigation.new( + await saved_tab(app), lambda: MainScreen(workspace.project, app.agent_binding.for_session("plan-blank")) ) assert agent.controller.surface.target is None diff --git a/tests/textual/test_sidebar_retirement.py b/tests/textual/test_sidebar_retirement.py index 669fec0c6..94e1412e6 100644 --- a/tests/textual/test_sidebar_retirement.py +++ b/tests/textual/test_sidebar_retirement.py @@ -16,6 +16,7 @@ from toad.widgets.side_bar import SideBarCollapsible from toad.widgets.sidebar_viewport import SidebarViewport from toad.widgets.thread_comms import ThreadCommsSidebar +from support.ui import saved_tab class InstalledApp(ToadApp): @@ -185,13 +186,15 @@ async def test_leaving_a_view_stops_its_sidebar_hydration(workspace): app, _ = ui.textual screen = await started(workspace, ui) sidebar = await reveal(ui, screen) + # The next tab is saved first, so leaving the view does not wait on the database. + tab = await saved_tab(app) await sidebar.evict() sidebar._start_hydration() assert sidebar._panels_loading workers = tuple(worker for worker in sidebar.workers if worker.node is sidebar and worker.group == "sidebar-panels") await asyncio.sleep(0) - await app.session_navigation.new(lambda: MainScreen(workspace.project)) + await app.session_navigation.new(tab, lambda: MainScreen(workspace.project)) await ui.step(.02) assert not sidebar.panels and not sidebar._panels_loading assert all(worker.is_finished for worker in workers) diff --git a/tests/textual/test_sidebar_state_across_views.py b/tests/textual/test_sidebar_state_across_views.py index 2d50802bc..71e4d827a 100644 --- a/tests/textual/test_sidebar_state_across_views.py +++ b/tests/textual/test_sidebar_state_across_views.py @@ -7,6 +7,7 @@ from toad.screens.main import MainScreen from toad.session_tracker import SidebarSelection from toad.widgets.comms_sidebar import ChannelGroup, CommsSidebar +from toad.tab_identity.kinds import MailboxTab class FrameApp(ToadApp): @@ -72,6 +73,7 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac await ui.step() owner = ui.selected_tab worker = await app.session_navigation.new( + MailboxTab(workspace.comms.registry.require("worker").incarnation, str(workspace.comms.root)), lambda: MainScreen.from_thread( workspace.project, str(workspace.comms.root), diff --git a/tests/textual/test_stale_tab_restore.py b/tests/textual/test_stale_tab_restore.py new file mode 100644 index 000000000..acd161937 --- /dev/null +++ b/tests/textual/test_stale_tab_restore.py @@ -0,0 +1,15 @@ +"""Textual-facing: a saved tab whose target is gone is dropped at startup, not a crash.""" +from toad.db import DB +from toad.tab_identity.kinds import AcpTab + + +async def test_a_saved_tab_without_its_session_is_removed_at_startup(workspace): + stale = AcpTab(999) + database = DB() + await database.create() + await database.tab_roster((stale,)) + async with workspace.app(session=False) as ui: + app, _ = ui.textual + await ui.step() + assert stale not in app.tab_order + assert stale not in [row.identity for row in await database.tab_list()] diff --git a/tests/textual/test_tab_close.py b/tests/textual/test_tab_close.py index fd2ba263b..fac40ca66 100644 --- a/tests/textual/test_tab_close.py +++ b/tests/textual/test_tab_close.py @@ -9,6 +9,7 @@ from toad.preferences import SETTINGS from toad.screens.main import MainScreen from toad.widgets.session_tabs import SessionTabClose +from toad.tab_identity.kinds import MailboxTab @dataclass @@ -58,6 +59,7 @@ def failure(error): for thread in threads: owner = thread.incarnation details = await app.session_navigation.new( + MailboxTab(owner, root), partial(MainScreen.from_thread, workspace.project, root, owner), title=thread.name, original=(root, owner)) modes.append(details.mode_name) diff --git a/tests/textual/test_workspace_tabs_share_one_agent_connection.py b/tests/textual/test_workspace_tabs_share_one_agent_connection.py index a535f16f5..5f83538e1 100644 --- a/tests/textual/test_workspace_tabs_share_one_agent_connection.py +++ b/tests/textual/test_workspace_tabs_share_one_agent_connection.py @@ -8,6 +8,7 @@ from toad.app import ToadApp from toad.screens.main import MainScreen from toad.session_scope import AgentAttached, AgentSession +from support.ui import saved_tab async def ready(agent): @@ -42,6 +43,7 @@ async def test_a_second_tab_reuses_the_first_tabs_agent_process_and_closing_it_k connection = first.process owner_tab = ui.selected_tab details = await app.session_navigation.new( + await saved_tab(app), lambda: MainScreen(project, AgentSession(definition, "beta")) ) second_source = app.workspace_sessions.require(details.mode_name)