diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfe19439f..e8d633615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: suite: runs-on: ubuntu-latest # A hung test must turn red in minutes, not burn GitHub's 6-hour default. - timeout-minutes: 60 + timeout-minutes: 120 steps: - uses: actions/checkout@v4 with: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index d8d4004f1..22e5d3731 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -328,6 +328,13 @@ class SessionScope(NavigationOwner, ProjectPathOwner, ToadNode): footer_compact = False presentation: OperationalSessionPresentation + async def _pre_process(self) -> bool: + """Textual tears a failed start down; the workspace must not keep the dead tree.""" + started = await super()._pre_process() + if not started: + self.app.workspace_sessions.detach(self.id) + return started + def matches_launch(self, agent_identity: str, session_id: str) -> bool: return False diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index da7712683..f38f140fc 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -17,6 +17,7 @@ from textual._activation import PrimaryActivation from textual._callback import invoke from textual.keys import KeyDeclaration, KeyDeclarationType +from textual.navigation import SelectCursor from textual.widgets.text_area import EditingMode from textual.dom import DOMNode from toad.app_node import ToadNode @@ -331,6 +332,11 @@ class OpenSelectedAction(VisibleAction): title = "Open" keys = {EditingMode: ("enter",)} + @classmethod + def permits_key_overlap(cls, other: KeyDeclarationType) -> bool: + """Opening the row is what selecting the cursor means on a list that opens rows.""" + return issubclass(other, SelectCursor) + async def apply(self, context: OpensSelected) -> None: context.open_selected() diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 4f589477a..d6ddababe 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -165,6 +165,10 @@ async def retire(self): return self +class SessionStartFailed(RuntimeError): + """A session's own startup failed; the app has already shown that error.""" + + class WorkspaceSessions: def __init__(self, app: "ToadApp") -> None: self.app = app @@ -196,6 +200,8 @@ async def prepare(self, identity: str) -> "SessionScope": self.views[identity] = view host = self.app.workspace_screen.query_one("#workspace-session-host", Container) await host.mount(view) + if identity not in self.views: + raise SessionStartFailed(identity) return view async def select(self, identity: str) -> "SessionScope": diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index 6bcc9658d..9490b7dc8 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -102,7 +102,7 @@ async def request_permission(self, *, session_id, tool_call, options, **kwargs): previous = self.view presentation = self.session.presentation await presentation.retire(self.session) - await presentation.evict() + await presentation.evict(self.session) assert request.pending and not self.agent.controller.surface.owns(previous) assert not previous.is_mounted and presentation.sources.agent is self.agent await presentation.prepare(self.session) diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index fdf1135ff..92ac5d6c4 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -2,7 +2,6 @@ from agent_comms.repo import Checkout from collections import Counter -from pathlib import Path import sys from agent_comms.comms import Comms @@ -34,7 +33,7 @@ async def open_hidden_sessions(workspace, ui, owners): first = ui.selected_tab def source(owner): - return MainScreen.from_thread(Path(owner.worktree), str(workspace.comms.root), owner.incarnation) + return MainScreen.from_thread(owner.worktree, str(workspace.comms.root), owner.incarnation) modes = {} for owner in owners[1:]: diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 9f1151b00..b93029ad9 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -2,6 +2,7 @@ conversation widget being evicted and remounted, and keeps the editor's document and undo history.""" import asyncio +from agent_comms.repo import Checkout from collections import Counter @@ -41,7 +42,7 @@ def observed(self, event, subscription): async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_the_draft_and_undo_survive(workspace): async with workspace.app(size=(110, 35)) as ui: app, _pilot = ui.textual - owner = ObservedLaunchScreen(workspace.project, initial_prompt="INITIAL_LOCAL_UI_EVENT") + owner = ObservedLaunchScreen(Checkout(workspace.project), initial_prompt="INITIAL_LOCAL_UI_EVENT") await app.session_navigation.new(await saved_tab(app), lambda: owner) await owner.wait_content_ready() await asyncio.wait_for(owner.publication.wait(), 12) @@ -60,7 +61,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th owner.publication.clear() await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() - await owner.presentation.evict() + await owner.presentation.evict(owner) assert view._initial_prompt is None assert owner.presentation.state.initial_prompt == "NEWEST_PENDING_LOCAL_UI_EVENT" assert owner.presentation.widget is None @@ -84,7 +85,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th # The consumed value must stay absent on another eviction. await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() - await owner.presentation.evict() + await owner.presentation.evict(owner) assert owner.presentation.state.initial_prompt is None await ui.select_tab(owner.id) await ui.step() diff --git a/tests/textual/test_keymap_model.py b/tests/textual/test_keymap_model.py index 986a58025..1166d1969 100644 --- a/tests/textual/test_keymap_model.py +++ b/tests/textual/test_keymap_model.py @@ -12,17 +12,17 @@ def test_saved_remaps_keep_modes_independent_and_reset_to_the_declaration(): catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) ordinary = KeymapRow.of(KeymapAction(), Standard) normal = KeymapRow.of(KeymapAction(), VimNormal) - keymap = Keymap().changed(normal, ("f7",)) + keymap = Keymap().changed(normal, ("f18",)) applied = [] settings = SavedSettings(applied=applied.append) settings.set(SETTINGS.ui.keymap.bindings, keymap.bindings) loaded = SavedSettings(settings.document()).current.ui.keymap assert [setting.path for setting in applied] == [SETTINGS.ui.keymap.bindings.bind(settings.state).path] - assert catalog.effective(normal, loaded) == ("f7",) + assert catalog.effective(normal, loaded) == ("f18",) assert catalog.effective(ordinary, loaded) == catalog.defaults(ordinary) assert catalog.effective(normal, loaded.reset(normal)) == catalog.defaults(normal) with pytest.raises(ValueError, match="Commander.*Keymap|Keymap.*Commander"): - loaded.changed(KeymapRow.of(CommanderAction(), VimNormal), ("f7",)) + loaded.changed(KeymapRow.of(CommanderAction(), VimNormal), ("f18",)) def test_an_empty_override_unbinds_and_survives_saving_until_reset(): @@ -44,7 +44,7 @@ def test_defaults_allow_the_declared_alternatives_in_every_mode(): def test_reset_cannot_reintroduce_a_collision_and_duplicate_rows_are_refused(): catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) menu = KeymapRow.of(KeymapAction(), Standard) - keymap = Keymap().changed(menu, ("f7",)) + keymap = Keymap().changed(menu, ("f18",)) keymap = keymap.changed(KeymapRow.of(CommanderAction(), Standard), catalog.defaults(menu)) with pytest.raises(ValueError, match="Key conflict"): keymap.reset(menu) diff --git a/tests/textual/test_keymap_screen.py b/tests/textual/test_keymap_screen.py index 5b9572131..8aa31a66f 100644 --- a/tests/textual/test_keymap_screen.py +++ b/tests/textual/test_keymap_screen.py @@ -51,7 +51,7 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod table = app.screen.query_one(DataTable) assert table.size.height > table.header_height, "The command rows must have visible space" await choose(ui, KeymapAction(), mode) - app.screen.query_one("#keymap-keys", Input).value = "f7" + app.screen.query_one("#keymap-keys", Input).value = "f18" await ui.pilot.click("#keymap-apply") await ui.until(lambda: KeymapAction.binding_id(mode().binding_mode) in app._keymap, why="the saved menu remap") @@ -67,10 +67,10 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod await ui.until(lambda: app.screen is app.workspace_screen, why="the keymap menu closed") await ui.press("f1") assert app.screen is app.workspace_screen - await ui.press("f7") + await ui.press("f18") await ui.until(lambda: isinstance(app.screen, KeymapScreen), why="the remapped menu key") await choose(ui, CommanderAction(), mode) - app.screen.query_one("#keymap-keys", Input).value = "f7" + app.screen.query_one("#keymap-keys", Input).value = "f18" await ui.pilot.click("#keymap-apply") await ui.until(lambda: "Key conflict" in str(app.screen.query_one("#keymap-status", Static).render()), why="the colliding remap refusal") @@ -80,7 +80,7 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod async with workspace.app(size=size) as reopened: await reopened.press("f1") assert reopened.app.screen is reopened.app.workspace_screen - await reopened.press("f7") + await reopened.press("f18") await reopened.until(lambda: isinstance(reopened.app.screen, KeymapScreen), why="the saved menu remap after restarting the app") diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index 73124e037..11876a736 100644 --- a/tests/textual/test_plan_display.py +++ b/tests/textual/test_plan_display.py @@ -108,7 +108,7 @@ async def test_an_empty_plan_resets_the_view_and_a_retired_sidebar_shows_later_p advance(workspace, 0) await ui.until(lambda: plan.all_complete, why="the completed plan") advance(workspace, 1) - await sidebar.evict() + await sidebar.evict(ui.textual[0].selected_session) assert not sidebar.panels await ui.until(lambda: not plan.entries and not plan.all_complete, why="the plan to reset") await ui.until(lambda: painted_text(plan, "No plan yet"), why="the empty plan painted") diff --git a/tests/textual/test_session_start_failure.py b/tests/textual/test_session_start_failure.py new file mode 100644 index 000000000..95f5e3ae3 --- /dev/null +++ b/tests/textual/test_session_start_failure.py @@ -0,0 +1,24 @@ +"""Textual-facing: a session whose mount fails leaves the workspace, instead of crashing later as a missing sidebar.""" + +from agent_comms.repo import Checkout +import pytest + +from support.ui import saved_tab + +from toad.screens.main import MainScreen +from toad.workspace_sessions import SessionStartFailed + + +class FailsToStart(MainScreen): + def on_mount(self) -> None: + raise ValueError("START_FAILURE") + + +async def test_a_failed_mount_names_the_session_and_leaves_no_dead_view(workspace): + async with workspace.app(size=(110, 35)) as ui: + app, _ = ui.textual + before = set(app.workspace_sessions.views) + with pytest.raises(SessionStartFailed): + await app.session_navigation.new( + await saved_tab(app), lambda: FailsToStart(Checkout(workspace.project))) + assert set(app.workspace_sessions.views) == before diff --git a/tests/textual/test_sidebar_retirement.py b/tests/textual/test_sidebar_retirement.py index 96b981f39..bf63a99da 100644 --- a/tests/textual/test_sidebar_retirement.py +++ b/tests/textual/test_sidebar_retirement.py @@ -140,7 +140,7 @@ async def exercise(ui, screen, root): assert tree_ref() is sidebar.query_one(ProjectDirectoryTree) await sidebar.prepare_presentation() assert tree_ref() is sidebar.query_one(ProjectDirectoryTree) - await sidebar.evict() + await sidebar.evict(screen) assert not sidebar.panels and not sidebar._panels_loaded assert screen._project_panel is None assert not tuple(viewport.children) @@ -214,7 +214,7 @@ async def test_panels_rebuild_after_eviction_while_collapsed(workspace): async with workspace.app(size=(130, 44), application=InstalledApp) as ui: screen = await started(workspace, ui) sidebar = screen.query_one(SessionThreadSidebar) - await sidebar.evict() + await sidebar.evict(screen) sidebar.reveal() sidebar.collapsed = True await ui.step(0.05) @@ -231,7 +231,7 @@ async def test_panels_show_the_current_project_after_the_source_project_changes_ async with workspace.app(size=(130, 44), application=InstalledApp) as ui: screen = await started(workspace, ui) sidebar = await reveal(ui, screen) - await sidebar.evict() + await sidebar.evict(screen) replacement = workspace.project.parent / "replacement-project" replacement.mkdir() (replacement / "new-source.txt").write_text("Current filesystem") @@ -254,7 +254,7 @@ async def test_leaving_a_view_stops_its_sidebar_hydration(workspace): 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() + await sidebar.evict(screen) sidebar._start_hydration() assert sidebar._panels_loading workers = tuple( diff --git a/tests/textual/test_sidebar_state_across_views.py b/tests/textual/test_sidebar_state_across_views.py index dd8d10d3a..7f15805c5 100644 --- a/tests/textual/test_sidebar_state_across_views.py +++ b/tests/textual/test_sidebar_state_across_views.py @@ -1,5 +1,6 @@ """Textual-facing: the channels sidebar's expansion, scroll position and range anchor survive switching between views, painted identically from its first ready frame.""" +from agent_comms.repo import Checkout from support.stylesheets import INSTALLED from support.textual_steps import channel_roster @@ -75,7 +76,7 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac worker = await app.session_navigation.new( MailboxTab(workspace.comms.registry.require("worker").incarnation, str(workspace.comms.root)), lambda: MainScreen.from_thread( - workspace.project, + Checkout(workspace.project), str(workspace.comms.root), workspace.comms.registry.require("worker").incarnation, ) diff --git a/tests/textual/test_tab_return_keeps_blocks.py b/tests/textual/test_tab_return_keeps_blocks.py index 8d29fcc1e..d7f1c5b50 100644 --- a/tests/textual/test_tab_return_keeps_blocks.py +++ b/tests/textual/test_tab_return_keeps_blocks.py @@ -47,7 +47,7 @@ async def test_evicted_saved_tab_restores_before_its_first_published_frame(works document, undo = editor.document, editor.history old_view = ui.conversation other = await ui.new_session() - await owner.presentation.evict() + await owner.presentation.evict(owner) assert owner.presentation.widget is None assert owner.presentation.transcript.documents