feat: worktree session UI (dialog checkbox, confirm delete, list marker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:41:11 -04:00
parent 0cf147ee25
commit 9efa270422
6 changed files with 449 additions and 21 deletions
+259 -9
View File
@@ -291,7 +291,9 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
call_order: list[str] = []
async def fake_create(project_id, harness_name, nickname, model):
async def fake_create(
project_id, harness_name, nickname, model, worktree_branch=None
):
# Brief yield so that, if two separate workers were used, the list
# worker would be able to start and record "list" before this
# completes — exposing the regression.
@@ -318,10 +320,11 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
app.action_new_session()
await app.workers.wait_for_complete()
await pilot.pause()
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
app.screen.dismiss(("claude", "mynick", None))
app.screen.dismiss(("claude", "mynick", None, None))
await pilot.pause()
# Wait for the worker spawned by on_dismiss to finish deterministically.
@@ -390,9 +393,10 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure():
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
with patch.object(app, "notify", side_effect=capture_notify):
app.action_new_session()
await app.workers.wait_for_complete()
await pilot.pause()
app.screen.dismiss(("claude", "mynick", None))
app.screen.dismiss(("claude", "mynick", None, None))
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
@@ -460,9 +464,10 @@ async def test_new_session_on_dismiss_no_notify_on_success():
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
with patch.object(app, "notify", side_effect=capture_notify):
app.action_new_session()
await app.workers.wait_for_complete()
await pilot.pause()
app.screen.dismiss(("claude", "mynick", None))
app.screen.dismiss(("claude", "mynick", None, None))
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
@@ -493,7 +498,7 @@ async def test_attach_enter_binding_disabled_in_modal():
# On the main screen the attach binding is live.
assert app.check_action("attach_session", ()) is True
app.push_screen(NewSessionScreen(["claude", "codex"]))
app.push_screen(NewSessionScreen(["claude", "codex"], is_git_repo=True))
await pilot.pause()
# With a modal on top, the binding is disabled so Enter reaches the modal.
@@ -515,7 +520,7 @@ async def test_new_session_enter_selects_highlighted_harness():
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Select
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"]))
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"], is_git_repo=True))
await pilot.pause()
sel = app.screen.query_one("#harness-select", Select)
sel.focus()
@@ -540,7 +545,7 @@ async def test_new_session_enter_without_navigation_selects_first_harness():
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Select
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"]))
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"], is_git_repo=True))
await pilot.pause()
sel = app.screen.query_one("#harness-select", Select)
sel.focus()
@@ -565,7 +570,9 @@ async def test_new_session_enter_in_input_submits_dialog():
from textual.widgets import Input
results: list = []
app.push_screen(NewSessionScreen(["claude", "codex"]), results.append)
app.push_screen(
NewSessionScreen(["claude", "codex"], is_git_repo=True), results.append
)
await pilot.pause()
nick = app.screen.query_one("#nickname-input", Input)
@@ -575,7 +582,7 @@ async def test_new_session_enter_in_input_submits_dialog():
await pilot.press("enter")
await pilot.pause()
assert results == [("claude", "mywork", None)], f"got {results}"
assert results == [("claude", "mywork", None, None)], f"got {results}"
# ---------------------------------------------------------------------------
@@ -1221,3 +1228,246 @@ async def test_session_list_jk_navigation():
await pilot.press("k")
await pilot.pause()
assert sl.get_selected_session_id() == session_ids[0]
# ---------------------------------------------------------------------------
# Worktree session UI: NewSessionScreen worktree controls
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_session_worktree_checkbox_disabled_when_not_git_repo():
"""The worktree checkbox is disabled for non-git-repo projects."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Checkbox
app.push_screen(NewSessionScreen(["claude"], is_git_repo=False))
await pilot.pause()
cb = app.screen.query_one("#worktree-checkbox", Checkbox)
assert cb.disabled is True
assert "not a git repo" in str(cb.label)
@pytest.mark.asyncio
async def test_new_session_worktree_unchecked_returns_none_branch():
"""With the checkbox unchecked, worktree_branch is None."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Button, Input
results: list = []
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = "mywork"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("claude", "mywork", None, None)], f"got {results}"
@pytest.mark.asyncio
async def test_new_session_worktree_checked_uses_typed_branch():
"""Checked checkbox + typed branch returns that branch."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Button, Checkbox, Input
results: list = []
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = "My Work"
app.screen.query_one("#worktree-checkbox", Checkbox).value = True
await pilot.pause()
# Branch input is revealed and prefilled from the nickname slug.
branch = app.screen.query_one("#branch-input", Input)
assert branch.display is True
assert branch.value == "my-work"
# User overrides with their own branch name.
branch.value = "feature-x"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("claude", "My Work", None, "feature-x")], f"got {results}"
@pytest.mark.asyncio
async def test_new_session_worktree_checked_blank_branch_slugifies_nickname():
"""Checked checkbox + blank branch derives the branch from the nickname slug."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Button, Checkbox, Input
results: list = []
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = "Cool Feature!"
cb = app.screen.query_one("#worktree-checkbox", Checkbox)
cb.value = True
await pilot.pause()
# Clear the prefilled branch so _submit must re-derive it from nickname.
app.screen.query_one("#branch-input", Input).value = ""
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("claude", "Cool Feature!", None, "cool-feature")], (
f"got {results}"
)
@pytest.mark.asyncio
async def test_new_session_branch_input_hidden_until_checked():
"""The branch input starts hidden and toggles with the checkbox."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Checkbox, Input
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True))
await pilot.pause()
branch = app.screen.query_one("#branch-input", Input)
assert branch.display is False
cb = app.screen.query_one("#worktree-checkbox", Checkbox)
cb.value = True
await pilot.pause()
assert branch.display is True
cb.value = False
await pilot.pause()
assert branch.display is False
# ---------------------------------------------------------------------------
# Worktree session UI: ConfirmDeleteScreen
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_confirm_delete_checkbox_checked_by_default_when_no_warning():
"""No warning (clean worktree) → 'remove worktree' pre-checked → (True, True)."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from textual.widgets import Button, Checkbox
results: list = []
app.push_screen(ConfirmDeleteScreen("mysess"), results.append)
await pilot.pause()
assert app.screen.query_one("#remove-worktree-checkbox", Checkbox).value is True
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [(True, True)]
@pytest.mark.asyncio
async def test_confirm_delete_checkbox_unchecked_by_default_with_warning():
"""A warning (dirty/unmerged) → checkbox unchecked → (True, False) by default."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from textual.widgets import Button, Checkbox, Label
results: list = []
app.push_screen(
ConfirmDeleteScreen("mysess", warning="Work will be lost"),
results.append,
)
await pilot.pause()
assert (
app.screen.query_one("#remove-worktree-checkbox", Checkbox).value is False
)
warning = app.screen.query_one("#delete-warning", Label)
assert "Work will be lost" in str(warning.render())
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [(True, False)]
@pytest.mark.asyncio
async def test_confirm_delete_cancel_returns_none():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from textual.widgets import Button
results: list = []
app.push_screen(ConfirmDeleteScreen("mysess"), results.append)
await pilot.pause()
app.screen.query_one("#cancel-btn", Button).press()
await pilot.pause()
assert results == [None]
# ---------------------------------------------------------------------------
# Worktree session UI: list marker + non-worktree delete unchanged
# ---------------------------------------------------------------------------
def test_format_session_text_appends_worktree_branch_marker():
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("mywork", "claude", "idle", "", "feature-x")
assert "⎇ feature-x" in text
def test_format_session_text_no_marker_when_nickname_equals_branch():
"""Avoid duplication: when nickname already equals the branch, no marker."""
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("feature-x", "claude", "idle", "", "feature-x")
assert "" not in text
def test_format_session_text_no_marker_for_plain_session():
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("plain", "claude", "idle", "", None)
assert "" not in text
@pytest.mark.asyncio
async def test_delete_non_worktree_session_does_not_push_modal():
"""Plain (non-worktree) sessions delete immediately, with no confirm modal."""
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
seed_db = app._db_factory()
proj = Project(name="del-proj", path="/tmp/del-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-del",
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
await app.run_action("delete_session")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
# No confirm modal was pushed; the session is gone from the DB.
assert not isinstance(app.screen, ConfirmDeleteScreen)
verify_db = app._db_factory()
assert verify_db.get(Session, sess_id) is None
verify_db.close()