diff --git a/chat/web/drawer.py b/chat/web/drawer.py index 3a8a6d0..9ac1ab8 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -1211,6 +1211,93 @@ async def switch_branch( return await drawer(chat_id, request, conn) +@router.get( + "/chats/{chat_id}/drawer/turn/delete-preview/{event_id}", + response_class=HTMLResponse, +) +async def delete_preview( + chat_id: str, + event_id: int, + request: Request, + conn=Depends(get_conn), +): + """Render an :class:`ImpactReport` for ``event_id`` as a small modal. + + Read-only — :func:`compute_delete_impact` does not mutate the + database. The modal contains a confirmation form posting to + :func:`delete_turn` below; HTMX swaps the fragment into a modal + target on the chat page. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + report = compute_delete_impact(conn, target_event_id=int(event_id)) + + # Build the modal HTML directly — the impact report is small and + # reusing the drawer template would require a fragment include just + # for this surface. Mirrors the rewind-preview style in + # :func:`chat.web.turns.rewind_preview`. + items_html = "".join( + f"
  • {item.kind}: {item.description}
  • " + for item in report.cascading + ) + notes_html = "".join(f"
  • {note}
  • " for note in report.notes) + body = ( + "
    " + f"

    Delete event {report.target_event_id}?

    " + f"

    This will discard {len(report.cascading)} events. Cascade:

    " + f"" + f"" + f"
    " + "" + "
    " + "
    " + ) + return HTMLResponse(body) + + +@router.post( + "/chats/{chat_id}/drawer/turn/delete/{event_id}", + response_class=HTMLResponse, +) +async def delete_turn( + chat_id: str, + event_id: int, + request: Request, + conn=Depends(get_conn), +): + """Delete a turn (and everything after) by invoking the existing rewind path. + + The :func:`chat.services.rewind.execute_rewind` API takes + ``after_event_id``: it removes events with id strictly greater than + that argument. To make ``event_id`` itself disappear we pass + ``after_event_id = event_id - 1`` — a thin adapter, not a + re-implementation of rewind. + + A snapshot is taken before truncation (inside ``execute_rewind``) + so the user can recover via the snapshot index. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + settings = request.app.state.settings + execute_rewind( + db_path=settings.db_path, + data_dir=settings.data_dir, + after_event_id=int(event_id) - 1, + ) + # ``conn`` is now stale (the rewind opened its own connection and + # truncated/reprojected). Re-render the drawer through a fresh open + # so the partial reflects the truncated state. + from chat.db.connection import open_db + + with open_db(settings.db_path) as fresh: + return await drawer(chat_id, request, fresh) + + @router.post( "/chats/{chat_id}/drawer/turn/hide/{event_id}", response_class=HTMLResponse, diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py index 30f3336..9ec4a66 100644 --- a/tests/test_drawer_phase4.py +++ b/tests/test_drawer_phase4.py @@ -383,3 +383,76 @@ def test_t98_3_hidden_turn_disappears_from_read_recent_dialogue( after_ids = [t["event_id"] for t in after] assert user_id not in after_ids assert bot_id in after_ids # the unhidden bot turn still surfaces + + +# --------------------------------------------------------------------------- +# T98.4 — surgical delete with cascade preview. +# --------------------------------------------------------------------------- + + +def test_t98_4_delete_preview_returns_impact_report_html(client, tmp_path): + db = tmp_path / "test.db" + _seed_chat(db) + user_id, bot_id = _seed_turns(db) + + response = client.get( + f"/chats/chat_bot_a/drawer/turn/delete-preview/{user_id}" + ) + assert response.status_code == 200 + body = response.text + + # Modal markup with the event id and the cascade list. + assert "delete-impact-modal" in body + assert f"Delete event {user_id}?" in body + assert "delete-impact-cascade" in body + # Both turns ride along in the cascade — user_turn at user_id, then + # the assistant_turn at bot_id (>= user_id). + assert "user_turn" in body + assert "assistant_turn" in body + # Confirm-form posts to the delete route. + assert f"/drawer/turn/delete/{user_id}" in body + + +def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path): + db = tmp_path / "test.db" + _seed_chat(db) + user_id, bot_id = _seed_turns(db) + + # Append a third turn after the assistant_turn so we can verify the + # cascade catches everything from user_id forward. + with open_db(db) as conn: + extra_id = append_and_apply( + conn, + kind="user_turn", + payload={ + "chat_id": "chat_bot_a", + "prose": "follow-up", + "segments": [], + }, + ) + + # Sanity: all three turn rows exist. + with open_db(db) as conn: + turn_count = conn.execute( + "SELECT COUNT(*) FROM event_log " + "WHERE kind IN ('user_turn', 'assistant_turn')" + ).fetchone()[0] + assert turn_count == 3 + + # Delete from user_id forward. + response = client.post(f"/chats/chat_bot_a/drawer/turn/delete/{user_id}") + assert response.status_code == 200 + + # All three turns are gone — the rewind truncated the log past + # user_id - 1, removing user_id, bot_id, and extra_id. + with open_db(db) as conn: + turn_count = conn.execute( + "SELECT COUNT(*) FROM event_log " + "WHERE kind IN ('user_turn', 'assistant_turn')" + ).fetchone()[0] + assert turn_count == 0 + for ev_id in (user_id, bot_id, extra_id): + row = conn.execute( + "SELECT 1 FROM event_log WHERE id = ?", (ev_id,) + ).fetchone() + assert row is None, f"event {ev_id} should have been deleted"