feat: drawer remaining v1 field edits (T98.5)

Audit of chat/state/manual_edit.py target_kind dispatch found two §6.4
fields without drawer affordances despite being already-projected text
columns: chat_state.narrative_anchor and chat_state.weather. Both land
via new manual_edit branches (target_kind chat_narrative_anchor and
chat_weather) plus paired drawer routes and Scene-section text inputs.

The container properties_json blob is intentionally deferred — bounded
JSON edits aren't wired through manual_edit and the drawer never
surfaces multiple containers at once, so v1 leaves it out.
This commit is contained in:
Joseph Doherty
2026-04-27 03:35:54 -04:00
parent c4fa11fe78
commit 4546bc0d9c
4 changed files with 191 additions and 0 deletions
+19
View File
@@ -38,6 +38,12 @@ T98.3 adds a hide-from-view toggle:
``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}``
and ``prior_value`` mirrors the shape so an inverse edit restores it.
T98.5 finishes the v1 drawer surface with two chat-scope text edits:
- ``chat_narrative_anchor`` and ``chat_weather`` — string overwrites of
the matching ``chat_state`` columns. ``target_id`` is the chat id
(``chats.id``); ``new_value`` is the new string and ``prior_value``
carries the previous content for §6.4 reversibility.
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.
@@ -157,5 +163,18 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
"UPDATE event_log SET hidden = ? WHERE id = ?",
(hidden_int, int(target_id)),
)
elif kind == "chat_narrative_anchor":
# T98.5: string overwrite of ``chat_state.narrative_anchor`` for
# the chat keyed by ``target_id``.
conn.execute(
"UPDATE chat_state SET narrative_anchor = ? WHERE chat_id = ?",
(str(new_value), str(target_id)),
)
elif kind == "chat_weather":
# T98.5: string overwrite of ``chat_state.weather``.
conn.execute(
"UPDATE chat_state SET weather = ? WHERE chat_id = ?",
(str(new_value), str(target_id)),
)
# Unknown target_kind: silently no-op for v1. Future kinds (activity
# fields, etc.) extend the dispatch above.
+20
View File
@@ -16,6 +16,26 @@
<p class="muted">No active container.</p>
{% endif %}
<p>Time: {{ chat.time }}</p>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/chat/narrative-anchor"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Narrative anchor:
<input type="text" name="new_value" maxlength="500"
value="{{ chat.narrative_anchor or '' }}">
</label>
<button type="submit">Save</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/chat/weather"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Weather:
<input type="text" name="new_value" maxlength="500"
value="{{ chat.weather or '' }}">
</label>
<button type="submit">Save</button>
</form>
{% if scene %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/scene/close"
+87
View File
@@ -1358,6 +1358,93 @@ async def hide_turn(
return await drawer(chat_id, request, conn)
# --- T98.5 chat narrative anchor + weather ----------------------------
#
# Audit (T98.5) found two §6.4 fields without drawer affordances despite
# both being prose strings stored on ``chat_state``: ``narrative_anchor``
# (the "Day 1" / "morning of the gala" hint above the chat clock) and
# ``weather``. Both land via the existing ``manual_edit`` projector with
# new branches added in :mod:`chat.state.manual_edit`. The container
# ``properties_json`` blob is more invasive — bounded JSON edits aren't
# wired through manual_edit and the drawer never surfaces multiple
# containers at once, so it stays out of v1.
CHAT_NARRATIVE_ANCHOR_MAX = 500
CHAT_WEATHER_MAX = 500
@router.post(
"/chats/{chat_id}/drawer/chat/narrative-anchor",
response_class=HTMLResponse,
)
async def edit_chat_narrative_anchor(
chat_id: str,
request: Request,
new_value: 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}")
if len(new_value) > CHAT_NARRATIVE_ANCHOR_MAX:
raise HTTPException(
status_code=400,
detail=(
f"narrative_anchor exceeds {CHAT_NARRATIVE_ANCHOR_MAX} chars "
f"(got {len(new_value)})"
),
)
prior = chat.get("narrative_anchor") or ""
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "chat_narrative_anchor",
"target_id": chat_id,
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/chat/weather",
response_class=HTMLResponse,
)
async def edit_chat_weather(
chat_id: str,
request: Request,
new_value: 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}")
if len(new_value) > CHAT_WEATHER_MAX:
raise HTTPException(
status_code=400,
detail=(
f"weather exceeds {CHAT_WEATHER_MAX} chars "
f"(got {len(new_value)})"
),
)
prior = chat.get("weather") or ""
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "chat_weather",
"target_id": chat_id,
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
response_class=HTMLResponse,
+65
View File
@@ -456,3 +456,68 @@ def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path):
"SELECT 1 FROM event_log WHERE id = ?", (ev_id,)
).fetchone()
assert row is None, f"event {ev_id} should have been deleted"
# ---------------------------------------------------------------------------
# T98.5 — remaining v1 edits (chat narrative anchor + weather).
# ---------------------------------------------------------------------------
def test_t98_5_edit_chat_narrative_anchor_emits_manual_edit(client, tmp_path):
db = tmp_path / "test.db"
_seed_chat(db)
response = client.post(
"/chats/chat_bot_a/drawer/chat/narrative-anchor",
data={"new_value": "Late evening, after dinner"},
)
assert response.status_code == 200
with open_db(db) as conn:
row = conn.execute(
"SELECT narrative_anchor FROM chat_state WHERE chat_id = ?",
("chat_bot_a",),
).fetchone()
assert row[0] == "Late evening, after dinner"
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"] == "chat_narrative_anchor"
assert payload["target_id"] == "chat_bot_a"
assert payload["prior_value"] == "Day 1"
assert payload["new_value"] == "Late evening, after dinner"
def test_t98_5_edit_chat_weather_emits_manual_edit(client, tmp_path):
db = tmp_path / "test.db"
_seed_chat(db)
response = client.post(
"/chats/chat_bot_a/drawer/chat/weather",
data={"new_value": "thunderstorm rolling in"},
)
assert response.status_code == 200
with open_db(db) as conn:
row = conn.execute(
"SELECT weather FROM chat_state WHERE chat_id = ?",
("chat_bot_a",),
).fetchone()
assert row[0] == "thunderstorm rolling in"
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"] == "chat_weather"
assert payload["target_id"] == "chat_bot_a"
assert payload["prior_value"] == ""
assert payload["new_value"] == "thunderstorm rolling in"