From 138e4e04bce8477c10579c04fe764e46fdfcc1ee Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Thu, 11 Jun 2026 12:02:45 -0400 Subject: [PATCH] docs: finalize TUI nav + archiving spec and implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-11-tui-nav-archiving.md | 747 ++++++++++++++++++ .../2026-06-11-tui-nav-archiving-design.md | 8 +- 2 files changed, 752 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-11-tui-nav-archiving.md diff --git a/docs/superpowers/plans/2026-06-11-tui-nav-archiving.md b/docs/superpowers/plans/2026-06-11-tui-nav-archiving.md new file mode 100644 index 0000000..df1901f --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-tui-nav-archiving.md @@ -0,0 +1,747 @@ +# TUI Column Navigation & Session Archiving Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add vim-style `h`/`l` focus movement between the Projects and Sessions columns, and a recoverable "archive" for sessions (hide-and-tear-down without deleting) with a per-project view toggle. + +**Architecture:** Two independent slices. (1) Navigation: two app-level bindings that focus a specific list widget by id. (2) Archiving: the `archived` column already exists and read paths already filter it, so the work is a parameterized read (`list_sessions(archived=...)`), two new service mutators (`archive_session`/`unarchive_session`), and the TUI actions/keys/toggle that drive them. Archive is non-destructive (keeps the DB row + `harness_session_id` + worktree; only kills the resumable tmux window), so it is instant — no confirmation dialog. + +**Tech Stack:** Python 3.14, async/await, SQLAlchemy ORM, Textual (App/Widget/Binding/ListView), tmux CLI, pytest + pytest-asyncio + unittest.mock. Runner: `uv run pytest`. Gates: `uv run ruff check .` and `uv run ty check`. + +**Reference spec:** `docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md` + +--- + +## File Structure + +- `src/hqt/sessions/service.py` — add `archived` param to `list_sessions`; add `archive_session` and `unarchive_session`. +- `src/hqt/tui/widgets/session_list.py` — add `set_mode(archived)` to swap the "Sessions" / "Sessions (archived)" header. +- `src/hqt/tui/app.py` — new bindings (`h`, `l`, `x`, `A`, `u`) and actions; `_show_archived` flag; thread the flag through `_refresh_sessions`; guard `_poll_sessions`; gate `x`/`u` in `check_action`. +- `tests/test_sessions.py` — service-layer tests. +- `tests/test_tui.py` — TUI binding/action/toggle/gating tests. + +No DB migration: the `archived` column exists at baseline schema v1. + +--- + +## Task 1: Service — `list_sessions` archived filter + +**Files:** +- Modify: `src/hqt/sessions/service.py:883-911` (`list_sessions`) +- Test: `tests/test_sessions.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_sessions.py`: + +```python +@pytest.mark.asyncio +async def test_list_sessions_archived_filter(service, db, tmux): + h = db.query(Harness).filter_by(name="claude-code").first() + active = Session( + project_id=1, harness_id=h.id, tmux_session_name="hqt-active", archived=False + ) + arch = Session( + project_id=1, harness_id=h.id, tmux_session_name="hqt-arch", archived=True + ) + db.add_all([active, arch]) + db.commit() + active_id, arch_id = active.id, arch.id + + default = await service.list_sessions(project_id=1) + assert [i.session.id for i in default] == [active_id] + + archived = await service.list_sessions(project_id=1, archived=True) + assert [i.session.id for i in archived] == [arch_id] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_sessions.py::test_list_sessions_archived_filter -v` +Expected: FAIL with `TypeError: list_sessions() got an unexpected keyword argument 'archived'` + +- [ ] **Step 3: Add the parameter** + +In `src/hqt/sessions/service.py`, change the `list_sessions` signature and filter. Keep `now` as the last parameter so existing positional callers are unaffected: + +```python + async def list_sessions( + self, project_id: int, archived: bool = False, now: float | None = None + ) -> list[SessionInfo]: + if now is None: + now = time.time() + with self.factory() as db: + sessions = ( + db.query(Session) + .options(selectinload(Session.harness)) + .filter_by(project_id=project_id, archived=archived) + .all() + ) +``` + +(The rest of the method body is unchanged.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_sessions.py::test_list_sessions_archived_filter -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/sessions/service.py tests/test_sessions.py +git commit -m "feat: parameterize list_sessions with archived filter" +``` + +--- + +## Task 2: Service — `archive_session` and `unarchive_session` + +**Files:** +- Modify: `src/hqt/sessions/service.py` (add two methods after `delete_session`, around line 827) +- Test: `tests/test_sessions.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_sessions.py`: + +```python +@pytest.mark.asyncio +async def test_archive_session_kills_window_and_keeps_row(service, db, tmux): + await service.create_session(project_id=1, harness_name="claude-code") + await service.archive_session(1) + + tmux.kill.assert_called_once_with("hqt-1") + db.expire_all() + row = db.get(Session, 1) + assert row is not None + assert row.archived is True + # Excluded from the default (active) listing. + assert await service.list_sessions(project_id=1) == [] + + +@pytest.mark.asyncio +async def test_archive_session_no_kill_when_window_missing(service, db, tmux): + await service.create_session(project_id=1, harness_name="claude-code") + tmux.window_exists = AsyncMock(return_value=False) + tmux.kill.reset_mock() + + await service.archive_session(1) + + tmux.kill.assert_not_called() + db.expire_all() + assert db.get(Session, 1).archived is True + + +@pytest.mark.asyncio +async def test_unarchive_session_restores_to_active_list(service, db, tmux): + await service.create_session(project_id=1, harness_name="claude-code") + await service.archive_session(1) + + await service.unarchive_session(1) + + db.expire_all() + assert db.get(Session, 1).archived is False + active = await service.list_sessions(project_id=1) + assert [i.session.id for i in active] == [1] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_sessions.py -k "archive_session or unarchive_session" -v` +Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'archive_session'` + +- [ ] **Step 3: Implement both methods** + +In `src/hqt/sessions/service.py`, immediately after the `delete_session` method (after line 827), add: + +```python + async def archive_session(self, session_id: int) -> None: + """Archive a session: kill its tmux window but keep the DB row. + + A recoverable "remove from view" — the window/harness process is torn + down (freeing resources) but the row, ``harness_session_id``, and any + worktree are preserved so the session can be restored or referenced + later. No-op if the session does not exist. + """ + with self.factory() as db: + sess = db.get(Session, session_id) + if sess is None: + return + if await self.tmux.window_exists(sess.tmux_session_name): + await self.tmux.kill(sess.tmux_session_name) + sess.archived = True + db.commit() + + async def unarchive_session(self, session_id: int) -> None: + """Restore an archived session by clearing its archived flag. + + The tmux window stays gone (the session reads as dead until resumed); + this only makes the row visible in the active list again. No-op if the + session does not exist. + """ + with self.factory() as db: + sess = db.get(Session, session_id) + if sess is None: + return + sess.archived = False + db.commit() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_sessions.py -k "archive_session or unarchive_session" -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/sessions/service.py tests/test_sessions.py +git commit -m "feat: add archive_session and unarchive_session to SessionService" +``` + +--- + +## Task 3: TUI — `h`/`l` column focus navigation + +**Files:** +- Modify: `src/hqt/tui/app.py:67-79` (BINDINGS) and add two actions near `action_toggle_focus` (line 213) +- Test: `tests/test_tui.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_tui.py`: + +```python +@pytest.mark.asyncio +async def test_hl_focus_bindings_exist(): + bindings = {b.key: b for b in HqtApp.BINDINGS} + assert bindings["h"].action == "focus_projects" + assert bindings["l"].action == "focus_sessions" + + +@pytest.mark.asyncio +async def test_h_focuses_projects_l_focuses_sessions(tmp_path): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + d = tmp_path / "hl-proj" + d.mkdir() + app._project_service.create("hl", str(d)) + app._load_projects() + await app.workers.wait_for_complete() + await pilot.pause() + + await pilot.press("l") + await pilot.pause() + assert app.focused is app.query_one("#session-list") + + await pilot.press("h") + await pilot.pause() + assert app.focused is app.query_one("#project-list") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_tui.py -k "hl_focus_bindings or h_focuses_projects" -v` +Expected: FAIL — `KeyError: 'h'` (binding test) and focus assertion error (action test) + +- [ ] **Step 3: Add bindings and actions** + +In `src/hqt/tui/app.py`, add two bindings to the `BINDINGS` list (after the `tab` binding on line 72): + +```python + Binding("tab", "toggle_focus", "Switch Panel"), + Binding("h", "focus_projects", "Projects"), + Binding("l", "focus_sessions", "Sessions"), +``` + +Then add two actions immediately after `action_toggle_focus` (after line 214): + +```python + def action_focus_projects(self) -> None: + self.query_one("#project-list").focus() + + def action_focus_sessions(self) -> None: + self.query_one("#session-list").focus() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui.py -k "hl_focus_bindings or h_focuses_projects" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/tui/app.py tests/test_tui.py +git commit -m "feat: h/l focus navigation between project and session columns" +``` + +--- + +## Task 4: Widget — `SessionList.set_mode` header toggle + +**Files:** +- Modify: `src/hqt/tui/widgets/session_list.py:70-73` (add a method to `SessionList`) +- Test: `tests/test_tui.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_tui.py`: + +```python +@pytest.mark.asyncio +async def test_session_list_header_reflects_archived_mode(): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.widgets.session_list import SessionList + from textual.widgets import Label + + sl = app.query_one(SessionList) + header = sl.query_one("#session-header", Label) + assert str(header.render()) == "Sessions" + + sl.set_mode(archived=True) + await pilot.pause() + assert "archived" in str(header.render()).lower() + + sl.set_mode(archived=False) + await pilot.pause() + assert str(header.render()) == "Sessions" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_tui.py::test_session_list_header_reflects_archived_mode -v` +Expected: FAIL with `AttributeError: 'SessionList' object has no attribute 'set_mode'` + +- [ ] **Step 3: Implement `set_mode`** + +In `src/hqt/tui/widgets/session_list.py`, add this method to the `SessionList` class (after `compose`, before `refresh_sessions`): + +```python + def set_mode(self, archived: bool) -> None: + """Swap the column header to reflect whether archived sessions are shown.""" + header = self.query_one("#session-header", Label) + header.update("Sessions (archived)" if archived else "Sessions") +``` + +(`Label` is already imported at the top of this file.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_tui.py::test_session_list_header_reflects_archived_mode -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/tui/widgets/session_list.py tests/test_tui.py +git commit -m "feat: SessionList.set_mode swaps header for archived view" +``` + +--- + +## Task 5: TUI — archive action (`x`) + +**Files:** +- Modify: `src/hqt/tui/app.py` — add `x` binding (after the new `l` binding) and `action_archive_session` (after `action_stop_session`, around line 429) +- Test: `tests/test_tui.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_tui.py`: + +```python +@pytest.mark.asyncio +async def test_archive_session_binding_exists(): + bindings = {b.key: b for b in HqtApp.BINDINGS} + assert bindings["x"].action == "archive_session" + + +@pytest.mark.asyncio +async def test_archive_session_action_calls_service_and_refreshes(): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.db.models import Project, Harness, Session + from hqt.tui.widgets.session_list import SessionList + + seed_db = app._db_factory() + proj = Project(name="arch-proj", path="/tmp/arch-proj") + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() + sess = Session( + project_id=proj.id, + harness_id=harness.id, + tmux_session_name="hqt-arch-action", + archived=False, + ) + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + sess_id = sess.id + seed_db.close() + + app._selected_project_id = proj_id + await app._refresh_sessions() + await pilot.pause() + + sl = app.query_one(SessionList) + sl.query_one("#session-list").focus() + await pilot.press("down") + await pilot.pause() + assert sl.get_selected_session_id() == sess_id + + mock_service = MagicMock() + mock_service.archive_session = AsyncMock() + mock_service.list_sessions = AsyncMock(return_value=[]) + mock_service.sync_window_labels = AsyncMock(return_value=[]) + app._session_service = mock_service + + await app.run_action("archive_session") + await pilot.pause() + + mock_service.archive_session.assert_awaited_once_with(sess_id) + mock_service.list_sessions.assert_awaited() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_tui.py -k "archive_session_binding or archive_session_action" -v` +Expected: FAIL — `KeyError: 'x'` and `ActionError` (no `action_archive_session`) + +- [ ] **Step 3: Add binding and action** + +In `src/hqt/tui/app.py`, add the `x` binding after the `l` binding from Task 3: + +```python + Binding("l", "focus_sessions", "Sessions"), + Binding("x", "archive_session", "Archive"), +``` + +Then add the action after `action_stop_session` (after line 429): + +```python + def action_archive_session(self) -> None: + sid = self.query_one(SessionList).get_selected_session_id() + if not sid: + return + + async def _do() -> None: + await self._sessions().archive_session(sid) + await self._refresh_sessions() + + self._run_service_worker(_do()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui.py -k "archive_session_binding or archive_session_action" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/tui/app.py tests/test_tui.py +git commit -m "feat: archive session action bound to x" +``` + +--- + +## Task 6: TUI — archived view toggle (`A`) + +**Files:** +- Modify: `src/hqt/tui/app.py` — add `A` binding; add `self._show_archived` in `__init__` (after line 90); thread the flag through `_refresh_sessions` (line 190); guard `_poll_sessions` (line 181); add `action_toggle_archived` +- Test: `tests/test_tui.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_tui.py`: + +```python +@pytest.mark.asyncio +async def test_toggle_archived_binding_exists(): + bindings = {b.key: b for b in HqtApp.BINDINGS} + assert bindings["A"].action == "toggle_archived" + + +@pytest.mark.asyncio +async def test_toggle_archived_switches_list_and_header(): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.widgets.session_list import SessionList + from textual.widgets import Label + + proj_id = 42 + app._selected_project_id = proj_id + + mock_service = MagicMock() + mock_service.list_sessions = AsyncMock(return_value=[]) + mock_service.sync_window_labels = AsyncMock(return_value=[]) + app._session_service = mock_service + + assert app._show_archived is False + + await app.run_action("toggle_archived") + await pilot.pause() + + assert app._show_archived is True + mock_service.list_sessions.assert_awaited_with(proj_id, archived=True) + header = app.query_one(SessionList).query_one("#session-header", Label) + assert "archived" in str(header.render()).lower() + + await app.run_action("toggle_archived") + await pilot.pause() + + assert app._show_archived is False + mock_service.list_sessions.assert_awaited_with(proj_id, archived=False) + assert str(header.render()) == "Sessions" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_tui.py -k "toggle_archived" -v` +Expected: FAIL — `KeyError: 'A'` and `AttributeError: 'HqtApp' object has no attribute '_show_archived'` + +- [ ] **Step 3: Add the flag, binding, action, and thread it through** + +In `src/hqt/tui/app.py`: + +**(a)** Add the `A` binding after the `x` binding from Task 5: + +```python + Binding("x", "archive_session", "Archive"), + Binding("A", "toggle_archived", "Show Archived"), +``` + +**(b)** Initialize the flag in `__init__`, after line 90 (`self._selected_project_id: int | None = None`): + +```python + self._selected_project_id: int | None = None + # When True, the Sessions column shows this project's archived sessions + # instead of its active ones. Persists across project switches. + self._show_archived = False +``` + +**(c)** Thread the flag into `_refresh_sessions` — change the `list_sessions` call (line 190): + +```python + infos = await self._sessions().list_sessions( + self._selected_project_id, archived=self._show_archived + ) +``` + +**(d)** Guard the UI-update block in `_poll_sessions` (lines 181-185) so a poll does not overwrite the archived view with active sessions: + +```python + if self._selected_project_id is not None and not self._show_archived: + subset = [ + i for i in infos if i.session.project_id == self._selected_project_id + ] + await self.query_one(SessionList).refresh_sessions(subset) +``` + +**(e)** Add the action after `action_archive_session` (from Task 5): + +```python + def action_toggle_archived(self) -> None: + self._show_archived = not self._show_archived + self.query_one(SessionList).set_mode(archived=self._show_archived) + self._run_service_worker(self._refresh_sessions(restore_selection=True)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui.py -k "toggle_archived" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/tui/app.py tests/test_tui.py +git commit -m "feat: A toggles the sessions column between active and archived" +``` + +--- + +## Task 7: TUI — restore action (`u`) and view-aware gating + +**Files:** +- Modify: `src/hqt/tui/app.py` — add `u` binding; add `action_unarchive_session`; extend `check_action` (lines 328-337) +- Test: `tests/test_tui.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_tui.py`: + +```python +@pytest.mark.asyncio +async def test_unarchive_binding_exists(): + bindings = {b.key: b for b in HqtApp.BINDINGS} + assert bindings["u"].action == "unarchive_session" + + +@pytest.mark.asyncio +async def test_unarchive_action_calls_service_and_refreshes(): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.db.models import Project, Harness, Session + from hqt.tui.widgets.session_list import SessionList + + seed_db = app._db_factory() + proj = Project(name="restore-proj", path="/tmp/restore-proj") + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() + sess = Session( + project_id=proj.id, + harness_id=harness.id, + tmux_session_name="hqt-restore", + archived=True, + ) + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + sess_id = sess.id + seed_db.close() + + app._selected_project_id = proj_id + app._show_archived = True + await app._refresh_sessions() + await pilot.pause() + + sl = app.query_one(SessionList) + sl.query_one("#session-list").focus() + await pilot.press("down") + await pilot.pause() + assert sl.get_selected_session_id() == sess_id + + mock_service = MagicMock() + mock_service.unarchive_session = AsyncMock() + mock_service.list_sessions = AsyncMock(return_value=[]) + mock_service.sync_window_labels = AsyncMock(return_value=[]) + app._session_service = mock_service + + await app.run_action("unarchive_session") + await pilot.pause() + + mock_service.unarchive_session.assert_awaited_once_with(sess_id) + mock_service.list_sessions.assert_awaited() + + +@pytest.mark.asyncio +async def test_archive_restore_actions_gated_by_view(): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + # Active view (default): archive offered, restore hidden. + assert app.check_action("archive_session", ()) is True + assert app.check_action("unarchive_session", ()) is None + + # Archived view: restore offered, archive hidden. + app._show_archived = True + assert app.check_action("archive_session", ()) is None + assert app.check_action("unarchive_session", ()) is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_tui.py -k "unarchive or archive_restore_actions_gated" -v` +Expected: FAIL — `KeyError: 'u'`, `ActionError` (no `action_unarchive_session`), and the gating assertions (`check_action` returns `True` for both) + +- [ ] **Step 3: Add binding, action, and gating** + +In `src/hqt/tui/app.py`: + +**(a)** Add the `u` binding after the `A` binding from Task 6: + +```python + Binding("A", "toggle_archived", "Show Archived"), + Binding("u", "unarchive_session", "Restore"), +``` + +**(b)** Extend `check_action` (currently lines 328-337) so `x` is hidden in the archived view and `u` is hidden in the active view. Returning `None` hides the binding from the footer entirely: + +```python + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + # `Binding("enter", "attach_session", priority=True)` is app-global, so + # without this it would fire inside modal dialogs (New Session, Project + # Form) and swallow Enter before the dialog's own widgets could see it — + # making Enter useless for selecting/confirming in those dialogs. + # Disabling the action while a modal is active lets Enter fall through to + # the focused dialog widget. + if action == "attach_session" and isinstance(self.screen, ModalScreen): + return False + # Archive applies only to active sessions; restore only to archived ones. + # Hide the inapplicable one so the footer never advertises it. + if action == "archive_session" and self._show_archived: + return None + if action == "unarchive_session" and not self._show_archived: + return None + return True +``` + +**(c)** Add the action after `action_toggle_archived` (from Task 6): + +```python + def action_unarchive_session(self) -> None: + sid = self.query_one(SessionList).get_selected_session_id() + if not sid: + return + + async def _do() -> None: + await self._sessions().unarchive_session(sid) + await self._refresh_sessions() + + self._run_service_worker(_do()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui.py -k "unarchive or archive_restore_actions_gated" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hqt/tui/app.py tests/test_tui.py +git commit -m "feat: u restores archived sessions; x/u gated by current view" +``` + +--- + +## Task 8: Full-suite verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the whole test suite** + +Run: `uv run pytest -q` +Expected: PASS (all existing tests plus the new navigation/archiving tests) + +- [ ] **Step 2: Run the quality gates** + +Run: `uv run ruff check .` +Expected: `All checks passed!` + +Run: `uv run ty check` +Expected: `All checks passed!` + +- [ ] **Step 3: Manual smoke test (optional, requires a terminal)** + +Run: `uv run hqt` (or the project's TUI entrypoint), then: +- Press `l`/`h` to move focus between the Sessions and Projects columns. +- Select a session and press `x` — it disappears from the list (window closes). +- Press `A` — the header reads "Sessions (archived)" and the archived session appears. +- Select it and press `u` — it returns to the active list when you press `A` again. + +- [ ] **Step 4: Commit any fixes** + +If steps 1-2 surfaced issues, fix them and commit: + +```bash +git add -A +git commit -m "fix: address verification findings for nav/archiving" +``` +``` + diff --git a/docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md b/docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md index c867ccf..f614402 100644 --- a/docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md +++ b/docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md @@ -48,11 +48,13 @@ Archiving is a **recoverable delete**: it kills the session's tmux window/harnes | Key | View | Action | |-----|-----------|----------------------------------------------------------------| -| `x` | active | Archive selected session — gated behind the same confirm dialog as delete | +| `x` | active | Archive selected session — **instant, no confirmation** (non-destructive: row + worktree kept, harness resumable) | | `A` | both | Toggle the Sessions column between **active** and **archived** | | `u` | archived | Restore (unarchive) the selected session | | `d` | both | Hard delete (unchanged; in archived view the window is already gone) | +Archive is instant because it loses nothing: it kills the live tmux window (a resumable process) but keeps the DB row, `harness_session_id`, and any worktree on disk. Restore is equally instant. Hard `delete` retains its existing confirmation flow (worktree sessions still prompt via `ConfirmDeleteScreen`). + ### View toggle - A `_show_archived: bool` flag on the app. `action_toggle_archived` flips it and refreshes the Sessions list for the currently-selected project. @@ -77,7 +79,7 @@ Archived sessions always render as `dead` (their window was killed). No pane cap - `unarchive_session` makes the row reappear in the active list. - `list_sessions(project_id, archived=True)` returns only archived rows. -**TUI tests (`tests/test_tui*.py`, following existing patterns):** -- `x` triggers archive (with confirm), `A` toggles the view and header text, `u` restores. +**TUI tests (`tests/test_tui.py`, following existing patterns):** +- `x` triggers archive (instant, no dialog), `A` toggles the view and header text, `u` restores. - `h`/`l` move focus to the projects/sessions lists respectively. - Footer/`check_action` adaptation: `x` hidden in archived view, `u` hidden in active view.