From d39d31479dbfa9899515e6930fbc5447cf029d26 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 03:24:02 -0400 Subject: [PATCH] feat: drawer branching UI (T98.1) --- chat/templates/_drawer.html | 42 ++++++++ chat/web/drawer.py | 108 +++++++++++++++++++++ tests/test_drawer_phase4.py | 189 ++++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+) create mode 100644 tests/test_drawer_phase4.py 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 %} + + {% 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