From 461d4410780639d4ff3e76181dbdff03cf8bbae0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 03:27:59 -0400 Subject: [PATCH] feat: drawer hide-from-view toggle + turn_hidden manual_edit branch (T98.3) --- chat/state/manual_edit.py | 19 +++++++ chat/templates/_drawer.html | 27 ++++++++++ chat/web/drawer.py | 98 ++++++++++++++++++++++++++++++++++ tests/test_drawer_phase4.py | 103 ++++++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+) diff --git a/chat/state/manual_edit.py b/chat/state/manual_edit.py index 3bfff79..fdcc723 100644 --- a/chat/state/manual_edit.py +++ b/chat/state/manual_edit.py @@ -30,6 +30,14 @@ T72.3 adds a per-flag witness toggle: ``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value`` mirrors the same shape so an inverse edit can restore the flag. +T98.3 adds a hide-from-view toggle: +- ``turn_hidden`` — flip ``event_log.hidden`` on a single turn row. + Hidden turns are filtered by ``read_recent_dialogue`` (see + :mod:`chat.services.turn_common`) so they vanish from the prompt + without being deleted from the log. ``target_id`` is the integer + ``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}`` + and ``prior_value`` mirrors the shape so an inverse edit restores it. + Pin toggles intentionally use the existing ``memory_pin_changed`` event (registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so the projection writes both ``pinned`` and ``auto_pinned`` atomically. @@ -138,5 +146,16 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None: f"UPDATE memories SET witness_{flag} = ? WHERE id = ?", (1 if int(new_value["value"]) else 0, int(target_id)), ) + elif kind == "turn_hidden": + # T98.3: hide-from-view toggle on a turn (event_log row). Sets + # ``event_log.hidden`` so :func:`read_recent_dialogue` (which + # filters ``hidden = 0``) drops the row from the prompt window + # without deleting it from the log. ``new_value`` is + # ``{"hidden": 0|1}``. + hidden_int = 1 if int(new_value.get("hidden", 0)) else 0 + conn.execute( + "UPDATE event_log SET hidden = ? WHERE id = ?", + (hidden_int, int(target_id)), + ) # Unknown target_kind: silently no-op for v1. Future kinds (activity # fields, etc.) extend the dispatch above. diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 6c1b2c1..8614a80 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -456,6 +456,33 @@ +
+

Recent turns

+ {% if recent_turns %} + + {% else %} +

No turns yet.

+ {% endif %} +
+

Significance review

{% set total_mem = significance_distribution.values()|sum %} diff --git a/chat/web/drawer.py b/chat/web/drawer.py index 251e2ab..3a8a6d0 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -176,6 +176,43 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): active_events = list_active_events(conn, chat_id) open_threads = list_open_threads(conn, chat_id) + # T98.3: recent turns (user_turn / assistant_turn) for the hide-from-view + # panel. Includes ``hidden`` rows so the user can un-hide them — the + # filter on the read side (read_recent_dialogue) is what drops hidden + # rows from the prompt; the drawer panel always shows everything. + turn_rows = conn.execute( + """ + SELECT id, kind, payload_json, hidden + FROM event_log + WHERE kind IN ('user_turn', 'assistant_turn', 'user_turn_edit') + AND superseded_by IS NULL + ORDER BY id DESC + LIMIT ? + """, + (RECENT_LIMIT,), + ).fetchall() + recent_turns: list[dict] = [] + for row in turn_rows: + try: + payload = json.loads(row[2]) if row[2] else {} + except (json.JSONDecodeError, TypeError): + payload = {} + if payload.get("chat_id") != chat_id: + continue + text = payload.get("prose") or payload.get("text") or "" + speaker = payload.get("speaker_id") or ( + "you" if row[1].startswith("user") else "?" + ) + recent_turns.append( + { + "event_id": int(row[0]), + "kind": row[1], + "speaker": speaker, + "excerpt": (text or "").replace("\n", " ")[:120], + "hidden": bool(row[3]), + } + ) + # T98.1: branch metadata (every chat sees the global branch list — branches # may be chat-scoped or global, so :func:`list_branches_with_metadata` # returns both flavours and the template highlights the active one). @@ -225,6 +262,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): "open_threads": open_threads, "branches": branches, "significance_distribution": significance_distribution, + "recent_turns": recent_turns, }, ) @@ -1173,6 +1211,66 @@ async def switch_branch( return await drawer(chat_id, request, conn) +@router.post( + "/chats/{chat_id}/drawer/turn/hide/{event_id}", + response_class=HTMLResponse, +) +async def hide_turn( + chat_id: str, + event_id: int, + request: Request, + hidden: int = Form(...), + conn=Depends(get_conn), +): + """Toggle ``event_log.hidden`` on a turn via the ``turn_hidden`` + ``manual_edit`` projector branch. + + The route validates the target is an actual turn-shaped row in this + chat (so a stray click on the chat panel can't hide a system event) + and snapshots the prior ``hidden`` value for §6.4 reversibility. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + row = conn.execute( + "SELECT kind, payload_json, hidden FROM event_log WHERE id = ?", + (int(event_id),), + ).fetchone() + if row is None: + raise HTTPException( + status_code=404, detail=f"event not found: {event_id}" + ) + if row[0] not in ("user_turn", "assistant_turn", "user_turn_edit"): + raise HTTPException( + status_code=400, + detail=f"event {event_id} is not a turn (kind={row[0]})", + ) + try: + payload = json.loads(row[1]) if row[1] else {} + except (json.JSONDecodeError, TypeError): + payload = {} + if payload.get("chat_id") != chat_id: + raise HTTPException( + status_code=404, + detail=f"event {event_id} not in chat {chat_id}", + ) + + prior_hidden = 1 if int(row[2]) else 0 + new_hidden = 1 if int(hidden) else 0 + append_and_apply( + conn, + kind="manual_edit", + payload={ + "target_kind": "turn_hidden", + "target_id": int(event_id), + "prior_value": {"hidden": prior_hidden}, + "new_value": {"hidden": new_hidden}, + }, + ) + return await drawer(chat_id, request, conn) + + @router.post( "/chats/{chat_id}/drawer/branch/from-turn/{event_id}", response_class=HTMLResponse, diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py index e20f01d..30f3336 100644 --- a/tests/test_drawer_phase4.py +++ b/tests/test_drawer_phase4.py @@ -280,3 +280,106 @@ def test_t98_2_edit_significance_via_existing_route_lands_manual_edit( assert int(payload["target_id"]) == target_id assert payload["prior_value"] == 0 assert payload["new_value"] == 3 + + +# --------------------------------------------------------------------------- +# T98.3 — hide-from-view toggle. +# --------------------------------------------------------------------------- + + +def _seed_turns(db: Path) -> tuple[int, int]: + """Append one user_turn + one assistant_turn; return their event ids.""" + with open_db(db) as conn: + user_id = append_and_apply( + conn, + kind="user_turn", + payload={ + "chat_id": "chat_bot_a", + "prose": "How are you doing today?", + "segments": [], + }, + ) + bot_id = append_and_apply( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_a", + "text": "Quite well, thanks for asking!", + "truncated": False, + "user_turn_id": user_id, + }, + ) + return user_id, bot_id + + +def test_t98_3_hide_turn_flips_event_log_hidden_via_manual_edit( + client, tmp_path +): + db = tmp_path / "test.db" + _seed_chat(db) + user_id, bot_id = _seed_turns(db) + + response = client.post( + f"/chats/chat_bot_a/drawer/turn/hide/{user_id}", + data={"hidden": "1"}, + ) + assert response.status_code == 200 + + with open_db(db) as conn: + # event_log.hidden flipped to 1. + row = conn.execute( + "SELECT hidden FROM event_log WHERE id = ?", (user_id,) + ).fetchone() + assert int(row[0]) == 1 + + # manual_edit landed with the prior snapshot. + import json as _json + + log = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1" + ).fetchone() + payload = _json.loads(log[0]) + assert payload["target_kind"] == "turn_hidden" + assert int(payload["target_id"]) == user_id + assert payload["prior_value"] == {"hidden": 0} + assert payload["new_value"] == {"hidden": 1} + + +def test_t98_3_hidden_turn_disappears_from_read_recent_dialogue( + client, tmp_path +): + """Hiding a turn must drop it from the prompt-window read. + + ``read_recent_dialogue`` (chat.services.turn_common) filters + ``hidden = 0`` server-side, so flipping the flag via the drawer + route must surface immediately. + """ + db = tmp_path / "test.db" + _seed_chat(db) + user_id, bot_id = _seed_turns(db) + + # Sanity baseline — both turns visible before the hide. + with open_db(db) as conn: + from chat.services.turn_common import read_recent_dialogue + + before = read_recent_dialogue(conn, "chat_bot_a", limit=10) + before_ids = [t["event_id"] for t in before] + assert user_id in before_ids + assert bot_id in before_ids + + # Hide the user turn via the drawer route. + response = client.post( + f"/chats/chat_bot_a/drawer/turn/hide/{user_id}", + data={"hidden": "1"}, + ) + assert response.status_code == 200 + + with open_db(db) as conn: + from chat.services.turn_common import read_recent_dialogue + + after = read_recent_dialogue(conn, "chat_bot_a", limit=10) + 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