feat: worktree session UI (dialog checkbox, confirm delete, list marker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+259
-9
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user