feat: drawer branching UI (T98.1)
This commit is contained in:
@@ -414,6 +414,48 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Branches</h3>
|
||||||
|
{% if branches %}
|
||||||
|
<ul class="branch-list">
|
||||||
|
{% for b in branches %}
|
||||||
|
<li class="branch-row{% if b.is_active %} branch-active{% endif %}">
|
||||||
|
<strong>{{ b.name }}</strong>
|
||||||
|
{% if b.is_active %}<span class="muted"> (active)</span>{% endif %}
|
||||||
|
<span class="muted"> · {{ b.event_count }} events</span>
|
||||||
|
{% if not b.is_active %}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/branch/switch"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="name" value="{{ b.name }}">
|
||||||
|
<button type="submit">Switch</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No branches yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
<details>
|
||||||
|
<summary>Create branch</summary>
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/branch/create"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<label>
|
||||||
|
Name:
|
||||||
|
<input type="text" name="name" required
|
||||||
|
placeholder="e.g. experiment_a">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Origin event id:
|
||||||
|
<input type="number" name="origin_event_id" required min="0">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section">
|
<section class="drawer-section">
|
||||||
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
|
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
|
||||||
{% if pinned %}
|
{% if pinned %}
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from chat.eventlog.log import append_and_apply
|
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.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.services.scene_summarize import apply_scene_close_summary
|
||||||
from chat.state.edges import get_edge
|
from chat.state.edges import get_edge
|
||||||
from chat.state.entities import get_bot, get_you, list_bots
|
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)
|
active_events = list_active_events(conn, chat_id)
|
||||||
open_threads = list_open_threads(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(
|
return TEMPLATES.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"_drawer.html",
|
"_drawer.html",
|
||||||
@@ -196,6 +208,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"pin_cap": PIN_CAP,
|
"pin_cap": PIN_CAP,
|
||||||
"active_events": active_events,
|
"active_events": active_events,
|
||||||
"open_threads": open_threads,
|
"open_threads": open_threads,
|
||||||
|
"branches": branches,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1080,3 +1093,98 @@ async def close_thread(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
return await drawer(chat_id, request, conn)
|
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-<event_id>"`` 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)
|
||||||
|
|||||||
@@ -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 "<h3>Branches</h3>" 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
|
||||||
Reference in New Issue
Block a user