Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from toad.core import session_requests
from toad.core_event_carrier import CoreEventReceiver
from metaclass_registry.mro_dispatch import handles
from toad.session_scope import AgentBinding
from toad.session_scope import AgentBinding, SessionScope
from toad.render_backend import Renderer
from toad.work_preparation import PreparationRuntime
from toad.navigation_preparation import (
Expand Down Expand Up @@ -350,6 +350,11 @@ def _prewarm_conversation_css(self) -> None:
}))


async def prepare_session(self, mode: str) -> SessionScope:
"""Mount an owner shell under the same admission as visible transitions."""
async with self._mode_switch_lock:
return await self.workspace_sessions.prepare(mode)

def select_session(self, mode: str, *, history_index: int | None = None) -> AwaitComplete:
if mode != self.selected_mode:
self.navigation_reader.invalidate()
Expand Down
5 changes: 4 additions & 1 deletion src/toad/application_lifetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ async def start(self) -> None:
if app.saved_settings.ensure_installation():
app.call_later(self.usage.publish, "toad-install")
self.usage.publish("toad-run")
destination = None
for identity in await app.tab_order.load():
try:
await identity.restore(app.session_navigation)
destination = await identity.restore(app.session_navigation)
except TabTargetGone as error:
await app.tab_order.close((identity,))
app.log.error(f"Removed stale tab {error.identity}: {error.reason}")
Expand All @@ -169,6 +170,8 @@ async def start(self) -> None:
partial(navigation.launch_screen, app),
original=(navigation.root, navigation.thread.incarnation),
)
elif destination is not None:
await app.session_navigation.open(destination)
if not app.tab_order.names:
await app.select_session(app.tab_order.home)
app.terminal_attention.attach()
Expand Down
30 changes: 13 additions & 17 deletions src/toad/session_entry.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Declared logical sessions own creation, projection and dependent lifetime.

Entries are the callable factories in WorkspaceSessions, not another roster.
Expand Down Expand Up @@ -189,24 +189,20 @@
return NamedTab(self.identity, self.details.name.display or "New Session")

def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab:
source = self.source(sessions)
if source is None or not source.belongs_to_wire(observed.service.root):
return self.unobserved_tab()
row = observed.sidebar.threads.rows.get(source.thread_identity.thread_name())
originals = self.original_threads(sessions)
if (
row is not None
and originals
and not any(
observed.observes(root) and identity == row.incarnation
for root, identity in originals
)
):
return self.unobserved_tab()
# The original wire identity exists before a native view. Unopened tabs
# still show backend status/unread; attachment state belongs to a view.
row = next(
(
row
for root, identity in self.original_threads(sessions)
if observed.observes(root)
and (row := observed.sidebar.threads.rows.get(identity.name)) is not None
and row.incarnation == identity
),
None,
)
if row is not None:
return ThreadTab(
self.identity, source.session_disposition.present(row, row.disconnected)
)
return ThreadTab(self.identity, self.thread_row(sessions, row))
return self.unobserved_tab()

async def return_to(self, sessions: SessionNavigation) -> None:
Expand Down
63 changes: 46 additions & 17 deletions src/toad/session_navigation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Opening and closing sessions use the existing workspace's declared factories."""

from __future__ import annotations
Expand Down Expand Up @@ -140,7 +140,7 @@
identity = self.app.tab_order.previous(current, eligible=eligible)
return identity.mode_name if identity is not None else self.app.tab_order.home

async def restore_mailbox(self, identity: MailboxTab) -> None:
async def mailbox_entry(self, identity: MailboxTab) -> SessionEntry:
def prepare():
service = self.app.coordination_access.observation.observed().service
if service.root != Path(identity.root):
Expand All @@ -152,15 +152,13 @@
return ThreadNavigationRequest(service, name, self.app.project_dir, ()).read()

prepared = await self.app.preparation.run_thread(prepare)
await self.new(
return self.native_entry(
identity, partial(prepared.launch_screen, self.app),
title=prepared.thread.name,
original=(prepared.root, prepared.thread.incarnation),
)

async def restore_history(self, identity: HistoryTab) -> None:
source = self.app.workspace_sessions.require(identity.owner.mode_name)

async def history_entry(self, identity: HistoryTab, source: SessionScope) -> HistorySessionEntry:
def participants():
service = self.app.coordination_access.observation.observed().service
if service.root != Path(identity.root):
Expand All @@ -169,14 +167,12 @@
service.registry.snapshot(), source.thread_identity.thread_name()
)

entry = HistorySessionEntry(
return HistorySessionEntry(
identity,
CommsViewKey(identity.root, identity.owner.mode_name, identity.key),
source.project_path, source.coordination_root,
await self.app.preparation.run_thread(participants), source,
)
if self.get(entry.mode) is None:
await self.open(entry)

def thread_row(self, row: ThreadRowModel) -> ThreadRowModel:
"""A thread's row as this app shows it, with the state of each open session of it."""
Expand Down Expand Up @@ -239,14 +235,22 @@
self._closing_retired.add(task)
task.add_done_callback(self._closing_retired.discard)

async def open(self, entry: SessionEntry, *, after: str | None = None) -> str:
async def register(self, entry: SessionEntry, *, after: str | None = None) -> None:
"""Keep the logical tab without mounting or selecting its native resources."""
app = self.app
if self.get(entry.mode) is not None:
existing = self.get(entry.mode)
if existing is entry:
return
if existing is not None:
raise ValueError(f"Logical session already registered: {entry.mode}")
self.entries.setdefault(entry.owner_mode, {})[entry.mode] = entry
previous = self.get(after)
await app.tab_order.open(entry.identity, after=previous.identity if previous else None)
self.publish()

async def open(self, entry: SessionEntry, *, after: str | None = None) -> str:
await self.register(entry, after=after)
app = self.app
await app.select_session(entry.mode)
await entry.ready(self)
return entry.mode
Expand All @@ -259,13 +263,23 @@
title: str = "New Session",
original=None,
) -> SessionDetails:
entry = self.native_entry(identity, factory, title=title, original=original)
await self.open(entry)
return self.app.session_tracker.sessions[identity]

def native_entry(
self,
identity: SessionTabIdentity,
factory: Callable[[], MainScreen],
*,
title: str = "New Session",
original=None,
) -> SessionEntry:
existing = self.get(identity.mode_name)
if existing is not None:
await self.app.select_session(existing.mode)
return self.app.session_tracker.sessions[identity]
return existing
details = self.app.session_tracker.new_session(identity, title=title)
await self.open(NativeSessionEntry(details, factory, original))
return details
return NativeSessionEntry(details, factory, original)

async def saved(
self, agent: AgentDefinition, project: Checkout, *,
Expand All @@ -287,6 +301,22 @@
project: Checkout,
initial_prompt: str | None = None,
) -> None:
entry = await self.launch_entry(
agent_identity, agent_session_id=agent_session_id,
session_pk=session_pk, project=project, initial_prompt=initial_prompt,
)
if entry is not None:
await self.open(entry)

async def launch_entry(
self,
agent_identity: str,
*,
agent_session_id: str | None = None,
session_pk: int | None = None,
project: Checkout,
initial_prompt: str | None = None,
) -> SessionEntry | None:
from toad.agents import read_agents
from toad.screens.main import MainScreen

Expand All @@ -304,8 +334,7 @@
None,
)
if existing is not None:
await app.select_session(existing)
return
return self.get(existing)
agent, title = None, None
if session_pk is not None:
saved = await database.session_get(session_pk)
Expand All @@ -323,7 +352,7 @@
else await self.saved(agent, project, title=title or "New Session",
agent_session_id=agent_session_id)
)
await self.new(
return self.native_entry(
tab,
lambda: MainScreen(
project,
Expand Down
24 changes: 23 additions & 1 deletion src/toad/tab_identity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
"""Durable tab identities; Textual names are a projection of the wire identity."""

from __future__ import annotations

from abc import abstractmethod
from functools import cached_property
from typing import TYPE_CHECKING

from agent_comms.declared_family import DeclaredFamily
from agent_comms.field_codec import FieldCodec

if TYPE_CHECKING:
from toad.session_entry import SessionEntry
from toad.session_navigation import SessionNavigation
from toad.session_scope import SessionScope


class TabTargetGone(Exception):
def __init__(self, identity: "TabIdentity", reason: str) -> None:
Expand All @@ -19,10 +27,24 @@ class TabIdentity(DeclaredFamily, affix="Tab", discovery_package="toad.tab_ident
def mode_name(self) -> str:
return f"{self.declared_name}-{FieldCodec.digest(self)}"

async def restore(self, navigation: SessionNavigation) -> SessionEntry:
"""Restore logical membership, not a succession of visible conversations."""
entry = navigation.get(self.mode_name)
if entry is None:
entry = await self.entry(navigation)
await navigation.register(entry)
return entry

@abstractmethod
async def restore(self, navigation) -> None: ...
async def entry(self, navigation: SessionNavigation) -> SessionEntry: ...


class SessionTabIdentity(TabIdentity):
"""An agent or mailbox tab can own history tabs."""

async def history_source(self, navigation: SessionNavigation) -> SessionScope:
"""An ACP session must establish its actual identity before routing history."""
entry = await self.restore(navigation)
await navigation.app.select_session(entry.mode)
await navigation.app.workspace_sessions.require(entry.mode).wait_content_ready()
return navigation.app.workspace_sessions.require(entry.mode)
20 changes: 13 additions & 7 deletions src/toad/tab_identity/history_origin.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
"""History opened from a resident welcome view or from a durable agent tab."""

from __future__ import annotations

from abc import abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING

from agent_comms.declared_family import DeclaredFamily

from toad.constants import WELCOME
from toad.tab_identity import SessionTabIdentity

if TYPE_CHECKING:
from toad.session_navigation import SessionNavigation
from toad.session_scope import SessionScope


class HistoryOrigin(DeclaredFamily, affix="HistoryOrigin"):
@property
@abstractmethod
def mode_name(self) -> str: ...

@abstractmethod
async def restore(self, navigation) -> None: ...
async def restore(self, navigation: SessionNavigation) -> SessionScope: ...


@dataclass(frozen=True)
Expand All @@ -24,10 +31,10 @@ class WelcomeHistoryOrigin(HistoryOrigin):
def mode_name(self) -> str:
return WELCOME

async def restore(self, navigation) -> None:
await navigation.app.select_session(WELCOME)
source = navigation.app.workspace_sessions.origin(WELCOME).require()
async def restore(self, navigation: SessionNavigation) -> SessionScope:
source = await navigation.app.prepare_session(WELCOME)
await source.prepare_history_origin()
return source


@dataclass(frozen=True)
Expand All @@ -38,6 +45,5 @@ class SessionHistoryOrigin(HistoryOrigin):
def mode_name(self) -> str:
return self.identity.mode_name

async def restore(self, navigation) -> None:
if navigation.get(self.mode_name) is None:
await self.identity.restore(navigation)
async def restore(self, navigation: SessionNavigation) -> SessionScope:
return await self.identity.history_source(navigation)
28 changes: 19 additions & 9 deletions src/toad/tab_identity/kinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from agent_comms.thread_identity import ThreadIncarnation

from toad.conversation_kind import ConversationKind
from toad.session_entry import PreviewSessionEntry
from toad.tab_identity import SessionTabIdentity, TabIdentity, TabTargetGone
from toad.tab_identity.history_origin import HistoryOrigin

Expand All @@ -17,7 +18,7 @@
class AcpTab(SessionTabIdentity):
key: int

async def restore(self, navigation) -> None:
async def entry(self, navigation):
database = await navigation.app.get_db()
saved = await database.session_get(self.key)
if saved is None:
Expand All @@ -26,21 +27,30 @@ async def restore(self, navigation) -> None:
project = Checkout.checked(saved.meta_json.cwd.require_present().path)
except ValueError as error:
raise TabTargetGone(self, f"Saved ACP session {self.key}: {error}") from error
await navigation.launch(
entry = await navigation.launch_entry(
saved.agent_identity,
agent_session_id=saved.agent_session_id,
session_pk=self.key,
project=project,
)
if entry is None:
raise TabTargetGone(self, f"Saved ACP agent {saved.agent_identity} is unavailable")
return entry


@dataclass(frozen=True)
class MailboxTab(SessionTabIdentity):
key: ThreadIncarnation
root: str

async def restore(self, navigation) -> None:
await navigation.restore_mailbox(self)
async def entry(self, navigation):
return await navigation.mailbox_entry(self)

async def history_source(self, navigation):
# Mailbox metadata already carries the original thread identity. Its
# history needs the owner shell, not an ACP connection or painted body.
entry = await self.restore(navigation)
return await navigation.app.prepare_session(entry.mode)


@dataclass(frozen=True)
Expand All @@ -49,16 +59,16 @@ class HistoryTab(TabIdentity):
owner: HistoryOrigin
root: str

async def restore(self, navigation) -> None:
await self.owner.restore(navigation)
await navigation.restore_history(self)
async def entry(self, navigation):
source = await self.owner.restore(navigation)
return await navigation.history_entry(self, source)


@dataclass(frozen=True)
class PreviewTab(TabIdentity):
key: Annotated[Path, PathText]

async def restore(self, navigation) -> None:
async def entry(self, navigation):
if not await navigation.app.preparation.run_thread(self.key.exists):
raise TabTargetGone(self, f"Preview file no longer exists: {self.key}")
await navigation.preview(self.key)
return PreviewSessionEntry.of(self, self.key, navigation.app.selected_mode)
1 change: 1 addition & 0 deletions src/toad/workspace_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def origin(self, identity: str) -> SessionOrigin:
async def prepare(self, identity: str) -> "SessionScope":
if view := self.views.get(identity):
return view
await self.app._init_mode("workspace")
view = (self.app.session_navigation.get(identity) or self.resident[identity])()
view.id = identity
view.display = False
Expand Down
Loading
Loading