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 %}
+
+ {% for t in recent_turns %}
+ -
+ #{{ t.event_id }} {{ t.kind }}
+ {{ t.speaker }}:
+ {{ t.excerpt }}{% if t.excerpt|length >= 120 %}…{% endif %}
+
+
+ {% endfor %}
+
+ {% 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