diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html
index 43a659a..621d4af 100644
--- a/chat/templates/_drawer.html
+++ b/chat/templates/_drawer.html
@@ -414,6 +414,48 @@
{% endif %}
+
+ Branches
+ {% if branches %}
+
+ {% for b in branches %}
+ -
+ {{ b.name }}
+ {% if b.is_active %} (active){% endif %}
+ · {{ b.event_count }} events
+ {% if not b.is_active %}
+
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ No branches yet.
+ {% endif %}
+
+ Create branch
+
+
+
+
Pinned memories ({{ pinned|length }} / {{ pin_cap }})
{% if pinned %}
diff --git a/chat/web/drawer.py b/chat/web/drawer.py
index 97f03cf..93c017d 100644
--- a/chat/web/drawer.py
+++ b/chat/web/drawer.py
@@ -36,7 +36,14 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
+from chat.services.branching import (
+ branch_from_event,
+ list_branches_with_metadata,
+ switch_active_branch,
+)
+from chat.services.delete_impact import compute_delete_impact
from chat.services.relationship_seed import seed_inter_bot_edges
+from chat.services.rewind import execute_rewind
from chat.services.scene_summarize import apply_scene_close_summary
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you, list_bots
@@ -169,6 +176,11 @@ 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.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).
+ branches = list_branches_with_metadata(conn, chat_id)
+
return TEMPLATES.TemplateResponse(
request,
"_drawer.html",
@@ -196,6 +208,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
"pin_cap": PIN_CAP,
"active_events": active_events,
"open_threads": open_threads,
+ "branches": branches,
},
)
@@ -1080,3 +1093,98 @@ async def close_thread(
},
)
return await drawer(chat_id, request, conn)
+
+
+# --- T98.1 branching UI --------------------------------------------------
+#
+# Three POST endpoints wired to the Phase 4 :mod:`chat.services.branching`
+# helpers. The drawer's "Branches" panel exposes:
+#
+# * Create from a free-form ``origin_event_id``.
+# * Switch the active branch by name.
+# * Convenience "branch from this turn" against a per-turn event_id (the
+# chat surface stamps ``id="turn-"`` on every turn so users can
+# pick the right one without copying ids by hand).
+#
+# All three return the refreshed drawer partial; failures from the service
+# layer (duplicate name, unknown branch, invalid origin) surface as 400 so
+# HTMX displays the inline error.
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/branch/create",
+ response_class=HTMLResponse,
+)
+async def create_branch(
+ chat_id: str,
+ request: Request,
+ name: str = Form(...),
+ origin_event_id: int = Form(...),
+ conn=Depends(get_conn),
+):
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+ try:
+ branch_from_event(
+ conn,
+ name=name,
+ origin_event_id=int(origin_event_id),
+ chat_id=chat_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ return await drawer(chat_id, request, conn)
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/branch/switch",
+ response_class=HTMLResponse,
+)
+async def switch_branch(
+ chat_id: str,
+ request: Request,
+ name: str = Form(...),
+ conn=Depends(get_conn),
+):
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+ try:
+ switch_active_branch(conn, name=name)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ return await drawer(chat_id, request, conn)
+
+
+@router.post(
+ "/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
+ response_class=HTMLResponse,
+)
+async def branch_from_turn(
+ chat_id: str,
+ event_id: int,
+ request: Request,
+ name: str = Form(...),
+ conn=Depends(get_conn),
+):
+ """Convenience: branch from a specific turn event.
+
+ Identical to :func:`create_branch` except ``origin_event_id`` is
+ encoded in the URL — the chat surface renders one such form per turn
+ so users can fork mid-conversation without authoring an event id by
+ hand.
+ """
+ chat = get_chat(conn, chat_id)
+ if chat is None:
+ raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
+ try:
+ branch_from_event(
+ conn,
+ name=name,
+ origin_event_id=int(event_id),
+ chat_id=chat_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ return await drawer(chat_id, request, conn)
diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py
new file mode 100644
index 0000000..3e0f875
--- /dev/null
+++ b/tests/test_drawer_phase4.py
@@ -0,0 +1,189 @@
+"""T98 (Phase 4): drawer phase-4 bundle.
+
+Five sub-features extending the chat drawer:
+
+* T98.1 — branching UI (create / switch / from-turn).
+* T98.2 — significance-review panel (distribution + significance edits).
+* T98.3 — hide-from-view toggle (per-turn, via ``manual_edit`` projector
+ branch ``turn_hidden``).
+* T98.4 — surgical delete with cascade preview (preview modal +
+ rewind execution against a target turn).
+* T98.5 — remaining v1 edits (chat narrative_anchor + weather).
+
+Tests follow the T59 pattern in ``tests/test_drawer_events_threads_skip.py``
+— a TestClient against the real FastAPI app with a per-test temp DB.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+
+from chat.app import app
+from chat.db.connection import open_db
+from chat.eventlog.log import append_and_apply, append_event
+from chat.eventlog.projector import project
+
+
+@pytest.fixture
+def client(tmp_path, monkeypatch):
+ cfg = tmp_path / "config.toml"
+ cfg.write_text('featherless_api_key = "test"\n')
+ monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
+ db = tmp_path / "test.db"
+ monkeypatch.setenv("CHAT_DB_PATH", str(db))
+ with TestClient(app) as c:
+ if hasattr(app.state, "background_worker"):
+ app.state.background_worker.enabled = False
+ yield c
+
+
+def _bot_payload(bot_id: str, name: str) -> dict:
+ return {
+ "id": bot_id,
+ "name": name,
+ "persona": "...",
+ "voice_samples": [],
+ "traits": [],
+ "backstory": "",
+ "initial_relationship_to_you": "",
+ "kickoff_prose": "",
+ }
+
+
+def _seed_chat(db: Path, *, with_scene: bool = True) -> int:
+ """Seed a chat hosted by ``bot_a``; return the latest event id (chat_created)."""
+ with open_db(db) as conn:
+ append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
+ append_event(
+ conn,
+ kind="you_authored",
+ payload={"name": "Me", "pronouns": "they/them", "persona": ""},
+ )
+ chat_event_id = append_event(
+ conn,
+ kind="chat_created",
+ payload={
+ "id": "chat_bot_a",
+ "host_bot_id": "bot_a",
+ "initial_time": "2026-04-26T20:00:00+00:00",
+ "narrative_anchor": "Day 1",
+ "weather": "",
+ },
+ )
+ if with_scene:
+ append_event(
+ conn,
+ kind="scene_opened",
+ payload={
+ "chat_id": "chat_bot_a",
+ "container_id": None,
+ "started_at": "2026-04-26T20:00:00+00:00",
+ "participants": ["you", "bot_a"],
+ },
+ )
+ project(conn)
+ return chat_event_id
+
+
+# ---------------------------------------------------------------------------
+# T98.1 — branching UI.
+# ---------------------------------------------------------------------------
+
+
+def test_t98_1_create_branch_emits_branch_created_and_renders(client, tmp_path):
+ db = tmp_path / "test.db"
+ seed_id = _seed_chat(db)
+
+ response = client.post(
+ "/chats/chat_bot_a/drawer/branch/create",
+ data={"name": "experiment_a", "origin_event_id": str(seed_id)},
+ )
+ assert response.status_code == 200
+
+ with open_db(db) as conn:
+ rows = conn.execute(
+ "SELECT COUNT(*) FROM event_log WHERE kind = 'branch_created'"
+ ).fetchone()
+ assert rows[0] == 1
+ from chat.state.branches import get_branch
+
+ b = get_branch(conn, "experiment_a")
+ assert b is not None
+ assert b["origin_event_id"] == seed_id
+ assert b["chat_id"] == "chat_bot_a"
+
+ # Drawer partial lists the new branch.
+ body = response.text
+ assert "Branches
" in body
+ assert "experiment_a" in body
+
+
+def test_t98_1_switch_branch_marks_active_and_unknown_400s(client, tmp_path):
+ db = tmp_path / "test.db"
+ seed_id = _seed_chat(db)
+
+ # Create branch directly via the service so this test focuses on switch.
+ with open_db(db) as conn:
+ from chat.services.branching import branch_from_event
+
+ branch_from_event(
+ conn, name="experiment_b", origin_event_id=seed_id, chat_id="chat_bot_a"
+ )
+
+ response = client.post(
+ "/chats/chat_bot_a/drawer/branch/switch",
+ data={"name": "experiment_b"},
+ )
+ assert response.status_code == 200
+
+ with open_db(db) as conn:
+ from chat.state.branches import active_branch
+
+ active = active_branch(conn)
+ assert active is not None
+ assert active["name"] == "experiment_b"
+
+ # Unknown branch -> 400.
+ bad = client.post(
+ "/chats/chat_bot_a/drawer/branch/switch",
+ data={"name": "ghost_branch"},
+ )
+ assert bad.status_code == 400
+
+
+def test_t98_1_branch_from_turn_emits_branch_created(client, tmp_path):
+ db = tmp_path / "test.db"
+ seed_id = _seed_chat(db)
+
+ # Append an extra turn so we can branch from it specifically.
+ with open_db(db) as conn:
+ turn_id = append_event(
+ conn,
+ kind="user_turn",
+ payload={"chat_id": "chat_bot_a", "prose": "hi", "segments": []},
+ )
+
+ response = client.post(
+ f"/chats/chat_bot_a/drawer/branch/from-turn/{turn_id}",
+ data={"name": "fork_at_turn"},
+ )
+ assert response.status_code == 200
+
+ with open_db(db) as conn:
+ from chat.state.branches import get_branch
+
+ b = get_branch(conn, "fork_at_turn")
+ assert b is not None
+ assert b["origin_event_id"] == turn_id
+ assert b["chat_id"] == "chat_bot_a"
+
+ # Duplicate name -> 400 from service ValueError.
+ dup = client.post(
+ f"/chats/chat_bot_a/drawer/branch/from-turn/{turn_id}",
+ data={"name": "fork_at_turn"},
+ )
+ assert dup.status_code == 400
+ assert seed_id < turn_id # sanity: turn is after chat_created