mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 06:14:48 -06:00
feat(ui): structured watch-result card + system-nudge marker on replay
User-visible slice of the watch-card UX workstream — combines the
replay-path widening, both frontend renderers, the CSS, and the
cross-cutting Python tests.
server._build_history widens the reminder filter from {type, text} to
project on a known set of optional fields (watch_name, command,
poll_count, max_polls, is_final) and surfaces _source as
entry["source"] when set. The known-key filter narrows the blast
radius if a future producer accidentally stuffs sensitive fields
into the dict.
SessionUIBase.on_user_reminder takes a new source: str | None kwarg
that rides on the SSE event when set. _attach_pending_user_reminders
forwards user_msg["_source"] so non-originating tabs see the wake's
"system_nudge" tag and render the thin marker. Protocol + cli + eval
implementations widen accordingly.
Frontend (coordinator.js + app.js — touched in lockstep per project
memory's "logic that lands in BOTH UIs must touch both files"):
* Branch on r.type === "watch_triggered" for a structured
.msg.watch-result card with header / $ command / <pre> body /
poll N/M [· final] footer.
* New addSystemNudgeMarker (interactive) + appendSystemNudgeMarker
(coord) renders a thin .msg.user.system-nudge anchor for
wake-driven reminders, both live (source === "system_nudge" on the
SSE event) and replay (msg.source === "system_nudge").
* Default .msg.user-reminder rendering preserved for every other
metacog nudge type.
CSS (shared_static/chat.css):
* New .msg.watch-result rules — full-width treatment, cyan accent,
monospace body with word-break: break-word for mobile.
* New .msg.user.system-nudge rule — thin yellow marker.
* Bonus newline-collapse fix: .msg.user-reminder .msg-body now sets
white-space: pre-wrap so multi-line shell output / bulleted lists
stay readable inside the advisory bubble.
Plan reference: docs/design/watch-card-ux.md §4 Steps 9-12 + bonus
CSS §11 (Commit 4).
(cherry picked from commit 6ae6877acc)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Tests for ``turnstone.server._build_history`` reminder + source surfacing.
|
||||
|
||||
The replay path (``_build_history``) projects the ``_source`` and
|
||||
``_reminders`` side-channels onto the wire entry the frontend
|
||||
consumes. Persisted via migration 050 (Commit 1) so multi-tab /
|
||||
multi-device replay sees the same metacognitive bubble shape the
|
||||
originating tab saw live.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.server import _build_history
|
||||
|
||||
|
||||
def _make_stub_session(messages: list[dict[str, Any]]) -> Any:
|
||||
"""Minimal ChatSession-shaped stub. ``_build_history`` only reads
|
||||
``session.messages`` plus calls ``_load_verdict_indexes(ws_id)`` —
|
||||
the latter we patch out below.
|
||||
"""
|
||||
return SimpleNamespace(messages=messages, _ws_id="ws-test")
|
||||
|
||||
|
||||
def _build(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Run ``_build_history`` against a stub session, bypassing the
|
||||
verdicts / output-assessment storage round-trip (no tool_calls in
|
||||
these tests, so the indexes are unused anyway).
|
||||
"""
|
||||
session = _make_stub_session(messages)
|
||||
with patch(
|
||||
"turnstone.server._load_verdict_indexes",
|
||||
return_value=({}, {}),
|
||||
):
|
||||
return _build_history(session)
|
||||
|
||||
|
||||
class TestSourceSurfacing:
|
||||
def test_source_surfaces_when_set(self) -> None:
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "",
|
||||
"_source": "system_nudge",
|
||||
}
|
||||
history = _build([msg])
|
||||
assert len(history) == 1
|
||||
assert history[0]["source"] == "system_nudge"
|
||||
|
||||
def test_source_absent_when_unset(self) -> None:
|
||||
msg = {"role": "user", "content": "hello"}
|
||||
history = _build([msg])
|
||||
assert "source" not in history[0]
|
||||
|
||||
|
||||
class TestRemindersWidening:
|
||||
def test_watch_triggered_optional_fields_propagate(self) -> None:
|
||||
"""The widened payload (Commit 2) carries watch_name / command /
|
||||
poll_count / max_polls / is_final on each ``watch_triggered``
|
||||
reminder so the frontend renders ``.msg.watch-result``.
|
||||
"""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "",
|
||||
"_source": "system_nudge",
|
||||
"_reminders": [
|
||||
{
|
||||
"type": "watch_triggered",
|
||||
"text": "$ ls\nfile.txt",
|
||||
"watch_name": "w1",
|
||||
"command": "ls",
|
||||
"poll_count": 2,
|
||||
"max_polls": 100,
|
||||
"is_final": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
history = _build([msg])
|
||||
assert history[0]["source"] == "system_nudge"
|
||||
assert history[0]["reminders"] == [
|
||||
{
|
||||
"type": "watch_triggered",
|
||||
"text": "$ ls\nfile.txt",
|
||||
"watch_name": "w1",
|
||||
"command": "ls",
|
||||
"poll_count": 2,
|
||||
"max_polls": 100,
|
||||
"is_final": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_legacy_two_field_reminders_still_work(self) -> None:
|
||||
"""Producers without optional fields (correction / denial /
|
||||
idle_children) keep the legacy ``{type, text}`` shape — the
|
||||
widened filter just doesn't add anything beyond that."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "noted",
|
||||
"_reminders": [{"type": "correction", "text": "watch out"}],
|
||||
}
|
||||
history = _build([msg])
|
||||
assert history[0]["reminders"] == [{"type": "correction", "text": "watch out"}]
|
||||
|
||||
def test_unknown_keys_are_dropped(self) -> None:
|
||||
"""The wire-layer filter projects on a known set of keys so a
|
||||
future producer accidentally stuffing arbitrary fields can't
|
||||
leak them through replay.
|
||||
"""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "x",
|
||||
"_reminders": [
|
||||
{
|
||||
"type": "correction",
|
||||
"text": "hi",
|
||||
"secret": "leak-me",
|
||||
"internal_id": 42,
|
||||
}
|
||||
],
|
||||
}
|
||||
history = _build([msg])
|
||||
clean = history[0]["reminders"][0]
|
||||
assert "secret" not in clean
|
||||
assert "internal_id" not in clean
|
||||
assert clean == {"type": "correction", "text": "hi"}
|
||||
|
||||
def test_malformed_reminder_skipped(self) -> None:
|
||||
"""A non-dict / empty entry is filtered out instead of breaking
|
||||
the rest of the list (mirrors the defensive filter in
|
||||
``_apply_reminders_for_provider``).
|
||||
"""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "x",
|
||||
"_reminders": [
|
||||
"garbage string",
|
||||
{"type": "", "text": ""}, # empty type + text → drop
|
||||
{"type": "denial", "text": "ok"},
|
||||
],
|
||||
}
|
||||
history = _build([msg])
|
||||
assert history[0]["reminders"] == [{"type": "denial", "text": "ok"}]
|
||||
@@ -64,8 +64,8 @@ class _FakeUI:
|
||||
def on_state_change(self, state: str) -> None:
|
||||
self.events.append(("state", state))
|
||||
|
||||
def on_user_reminder(self, reminders: Any) -> None:
|
||||
self.events.append(("user_reminder", reminders))
|
||||
def on_user_reminder(self, reminders: Any, source: str | None = None) -> None:
|
||||
self.events.append(("user_reminder", reminders, source))
|
||||
|
||||
def on_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
+34
-3
@@ -50,7 +50,7 @@ class NullUI:
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_user_reminder(self, reminders):
|
||||
def on_user_reminder(self, reminders, source=None):
|
||||
pass
|
||||
|
||||
def on_tool_reminder(self, reminders, tool_call_id):
|
||||
@@ -2240,10 +2240,13 @@ class TestMetacognitiveBuffers:
|
||||
msg = {"role": "user", "content": "noted"}
|
||||
session._attach_pending_user_reminders(msg)
|
||||
# on_user_reminder called with the same shape as _build_history
|
||||
# surfaces — list of {type, text} dicts.
|
||||
# surfaces — list of {type, text} dicts. ``source`` rides as
|
||||
# a kwarg (None for non-wake correction nudges); inspect via
|
||||
# ``call_args.args`` for the positional reminders payload only.
|
||||
assert session.ui.on_user_reminder.call_count == 1
|
||||
(reminders_arg,) = session.ui.on_user_reminder.call_args.args
|
||||
reminders_arg = session.ui.on_user_reminder.call_args.args[0]
|
||||
assert reminders_arg == [{"type": "correction", "text": "watch out"}]
|
||||
assert session.ui.on_user_reminder.call_args.kwargs.get("source") is None
|
||||
|
||||
def test_attach_swallows_on_user_reminder_failure(self, tmp_db):
|
||||
"""A UI hook implementation that raises (queue full, unexpected
|
||||
@@ -3328,8 +3331,36 @@ class TestSessionUIBaseUserReminderHook:
|
||||
ui = _RecordingUI()
|
||||
reminders = [{"type": "correction", "text": "watch out"}]
|
||||
ui.on_user_reminder(reminders)
|
||||
# ``source`` omitted from the payload when not provided —
|
||||
# absent vs. None on the wire should mean the same thing.
|
||||
assert ui.events == [{"type": "user_reminder", "reminders": reminders}]
|
||||
|
||||
def test_on_user_reminder_carries_source_when_set(self):
|
||||
"""Wake-driven reminders fire with ``source="system_nudge"`` so
|
||||
non-originating SSE consumers can render the thin
|
||||
``.msg.user.system-nudge`` marker before the reminder bubble.
|
||||
"""
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
|
||||
class _RecordingUI(SessionUIBase):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.events: list[dict] = []
|
||||
|
||||
def _enqueue(self, data: dict) -> None: # type: ignore[override]
|
||||
self.events.append(data)
|
||||
|
||||
ui = _RecordingUI()
|
||||
reminders = [{"type": "idle_children", "text": "kids"}]
|
||||
ui.on_user_reminder(reminders, source="system_nudge")
|
||||
assert ui.events == [
|
||||
{
|
||||
"type": "user_reminder",
|
||||
"reminders": reminders,
|
||||
"source": "system_nudge",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestSessionUIBaseToolReminderHook:
|
||||
"""Parallel to ``on_user_reminder`` but on the tool channel —
|
||||
|
||||
+4
-1
@@ -326,7 +326,10 @@ class TerminalUI(SessionUI):
|
||||
sys.stdout.write(f"{YELLOW}[{label}]{RESET} {text}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]]) -> None:
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]], source: str | None = None) -> None:
|
||||
# ``source`` ignored — the CLI doesn't render a wake marker
|
||||
# (terminal output is anchored by sequence, not anchor element).
|
||||
del source
|
||||
self._print_reminder(reminders)
|
||||
|
||||
def on_tool_reminder(self, reminders: list[dict[str, Any]], tool_call_id: str) -> None:
|
||||
|
||||
@@ -368,34 +368,89 @@
|
||||
return el;
|
||||
}
|
||||
|
||||
// Build a structured ``.msg.watch-result`` card for a
|
||||
// ``watch_triggered`` reminder — full-width treatment with
|
||||
// command preview header + shell output body + poll counter footer.
|
||||
// First-pass functional rendering; bespoke design polish lives in a
|
||||
// future workstream. All text goes through ``textContent`` so shell
|
||||
// output containing angle brackets / scripts / steering bytes
|
||||
// renders inertly.
|
||||
function buildWatchResultBubble(r) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg watch-result";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "watch");
|
||||
el.setAttribute("aria-label", "watch");
|
||||
const header = document.createElement("div");
|
||||
header.className = "msg-watch-header";
|
||||
header.textContent =
|
||||
"watch" + (r.watch_name ? " · " + String(r.watch_name) : "");
|
||||
el.appendChild(header);
|
||||
if (r.command) {
|
||||
const cmd = document.createElement("div");
|
||||
cmd.className = "msg-watch-cmd";
|
||||
cmd.textContent = "$ " + String(r.command);
|
||||
el.appendChild(cmd);
|
||||
}
|
||||
const body = document.createElement("pre");
|
||||
body.className = "msg-watch-body";
|
||||
body.textContent = r.text || "";
|
||||
el.appendChild(body);
|
||||
if (r.poll_count != null && r.max_polls != null) {
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "msg-watch-footer";
|
||||
const finalSuffix = r.is_final ? " · final" : "";
|
||||
footer.textContent =
|
||||
"poll " +
|
||||
String(r.poll_count) +
|
||||
"/" +
|
||||
String(r.max_polls) +
|
||||
finalSuffix;
|
||||
el.appendChild(footer);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Default ``.msg.user-reminder`` bubble — yellow themed advisory used
|
||||
// for every metacog nudge other than ``watch_triggered``.
|
||||
function buildDefaultReminderBubble(r) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg user-reminder";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "metacognition");
|
||||
el.setAttribute("aria-label", "metacognition");
|
||||
const body = document.createElement("div");
|
||||
body.className = "msg-body";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
const textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
body.appendChild(labelEl);
|
||||
body.appendChild(textEl);
|
||||
el.appendChild(body);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Metacognitive reminder bubble (user-channel correction / denial /
|
||||
// resume / start / completion AND tool-channel tool_error / repeat).
|
||||
// Mirrors Pane.prototype.addUserReminder / addToolReminder in the
|
||||
// interactive UI — yellow themed bubble slotted directly below the
|
||||
// message it advises. ``anchor`` is the DOM element to anchor below;
|
||||
// when null, append at the bottom of messagesEl.
|
||||
// message it advises. ``watch_triggered`` reminders branch off into
|
||||
// the structured ``.msg.watch-result`` card. ``anchor`` is the DOM
|
||||
// element to anchor below; when null, append at the bottom of
|
||||
// messagesEl.
|
||||
function appendReminderBubble(reminders, anchor) {
|
||||
if (!Array.isArray(reminders) || !reminders.length) return;
|
||||
let cursor = anchor;
|
||||
for (let i = 0; i < reminders.length; i++) {
|
||||
const r = reminders[i] || {};
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg user-reminder";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "metacognition");
|
||||
el.setAttribute("aria-label", "metacognition");
|
||||
const body = document.createElement("div");
|
||||
body.className = "msg-body";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
const textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
body.appendChild(labelEl);
|
||||
body.appendChild(textEl);
|
||||
el.appendChild(body);
|
||||
const el =
|
||||
r.type === "watch_triggered"
|
||||
? buildWatchResultBubble(r)
|
||||
: buildDefaultReminderBubble(r);
|
||||
if (cursor) {
|
||||
cursor.insertAdjacentElement("afterend", el);
|
||||
cursor = el;
|
||||
@@ -406,11 +461,37 @@
|
||||
_scheduleScroll();
|
||||
}
|
||||
|
||||
// Thin ``.msg.user.system-nudge`` marker rendered as the anchor for
|
||||
// wake-driven reminder bubbles. Replaces the previously-invisible
|
||||
// synthetic empty user turn with a visible-but-subtle DOM element so
|
||||
// the bubble below it lands in the right place even when the wake
|
||||
// fires long after the user's last real message.
|
||||
function appendSystemNudgeMarker() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg user system-nudge";
|
||||
el.setAttribute("data-source", "system_nudge");
|
||||
el.setAttribute("aria-label", "system nudge");
|
||||
el.textContent = "system nudge";
|
||||
messagesEl.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Live SSE for user-channel reminders — anchors below the most
|
||||
// recent user message. On a non-originating tab there may be no
|
||||
// user message rendered yet; we append and the next /history reload
|
||||
// corrects. (Same caveat as the interactive UI; tracked there.)
|
||||
function appendUserReminderLive(reminders) {
|
||||
//
|
||||
// ``source`` widens the live SSE event to carry the wake's
|
||||
// ``"system_nudge"`` tag so the marker renders on every connected
|
||||
// tab — without this, only the originating tab (which sees the
|
||||
// synthesised empty user turn live) would render the wake bubble in
|
||||
// the right place.
|
||||
function appendUserReminderLive(reminders, source) {
|
||||
if (source === "system_nudge") {
|
||||
const marker = appendSystemNudgeMarker();
|
||||
appendReminderBubble(reminders, marker);
|
||||
return;
|
||||
}
|
||||
const userMsgs = messagesEl.querySelectorAll(".msg.user");
|
||||
const anchor = userMsgs.length ? userMsgs[userMsgs.length - 1] : null;
|
||||
appendReminderBubble(reminders, anchor);
|
||||
@@ -2028,9 +2109,11 @@
|
||||
case "user_reminder":
|
||||
// Metacognitive user-channel nudge — render below the most
|
||||
// recent user message as a yellow themed bubble. Same shape
|
||||
// as the interactive UI's case.
|
||||
// as the interactive UI's case. When ``source === "system_nudge"``
|
||||
// (wake-driven), render the thin .msg.user.system-nudge
|
||||
// marker first so the bubble anchors below it.
|
||||
if (Array.isArray(ev.reminders) && ev.reminders.length) {
|
||||
appendUserReminderLive(ev.reminders);
|
||||
appendUserReminderLive(ev.reminders, ev.source || "");
|
||||
}
|
||||
break;
|
||||
case "tool_reminder":
|
||||
@@ -4053,6 +4136,17 @@
|
||||
// text when the message carried attachments — even when the
|
||||
// text portion is empty (image-only sends).
|
||||
if (role === "user") {
|
||||
const isSystemNudge = m.source === "system_nudge";
|
||||
if (isSystemNudge) {
|
||||
// Wake-driven empty user turn: render the thin marker
|
||||
// (replaces the previously-skipped synthetic empty
|
||||
// bubble) and anchor reminder bubbles below it.
|
||||
const marker = appendSystemNudgeMarker();
|
||||
if (Array.isArray(m.reminders) && m.reminders.length) {
|
||||
appendReminderBubble(m.reminders, marker);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!content && userAttachments.length === 0) return;
|
||||
appendUserMessageWithAttachments(content, userAttachments, {
|
||||
label: role,
|
||||
|
||||
@@ -591,7 +591,9 @@ class SessionUI(Protocol):
|
||||
def on_plan_review(self, content: str) -> str: ...
|
||||
def on_info(self, message: str) -> None: ...
|
||||
def on_error(self, message: str) -> None: ...
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]]) -> None: ...
|
||||
def on_user_reminder(
|
||||
self, reminders: list[dict[str, Any]], source: str | None = None
|
||||
) -> None: ...
|
||||
def on_tool_reminder(self, reminders: list[dict[str, Any]], tool_call_id: str) -> None: ...
|
||||
def on_state_change(self, state: str) -> None: ...
|
||||
def on_rename(self, name: str) -> None: ...
|
||||
@@ -6032,8 +6034,15 @@ class ChatSession:
|
||||
return
|
||||
user_msg["_reminders"] = reminders
|
||||
|
||||
# Forward ``_source`` (today only ``"system_nudge"`` for wake
|
||||
# turns) so non-originating SSE consumers can render the thin
|
||||
# ``.msg.user.system-nudge`` marker before the reminder bubble.
|
||||
# Without it, a non-originating tab anchors the bubble below
|
||||
# whichever stale prior user message exists.
|
||||
source_tag = user_msg.get("_source")
|
||||
source_arg: str | None = source_tag if isinstance(source_tag, str) and source_tag else None
|
||||
try:
|
||||
self.ui.on_user_reminder(reminders)
|
||||
self.ui.on_user_reminder(reminders, source=source_arg)
|
||||
except Exception:
|
||||
log.warning("ui.on_user_reminder failed; reminder still attached", exc_info=True)
|
||||
|
||||
|
||||
@@ -1319,7 +1319,7 @@ class SessionUIBase:
|
||||
def on_error(self, message: str) -> None:
|
||||
self._enqueue({"type": "error", "message": message})
|
||||
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]]) -> None:
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]], source: str | None = None) -> None:
|
||||
"""Surface a metacognitive user-channel nudge as its own UI
|
||||
element.
|
||||
|
||||
@@ -1331,8 +1331,16 @@ class SessionUIBase:
|
||||
originating tab. The history-replay path surfaces the same
|
||||
shape via ``_build_history`` so a tab reconnecting later
|
||||
renders the same bubble.
|
||||
|
||||
``source`` mirrors the user-message dict's ``_source`` field
|
||||
(today only ``"system_nudge"`` for wake-driven reminders). The
|
||||
frontend uses it to render the thin ``.msg.user.system-nudge``
|
||||
marker before anchoring the reminder bubbles below it.
|
||||
"""
|
||||
self._enqueue({"type": "user_reminder", "reminders": reminders})
|
||||
evt: dict[str, Any] = {"type": "user_reminder", "reminders": reminders}
|
||||
if source:
|
||||
evt["source"] = source
|
||||
self._enqueue(evt)
|
||||
|
||||
def on_tool_reminder(self, reminders: list[dict[str, Any]], tool_call_id: str) -> None:
|
||||
"""Surface a metacognitive tool-channel nudge (``tool_error`` /
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ class NullUI:
|
||||
def on_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]]) -> None:
|
||||
def on_user_reminder(self, reminders: list[dict[str, Any]], source: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
def on_tool_reminder(self, reminders: list[dict[str, Any]], tool_call_id: str) -> None:
|
||||
|
||||
+37
-8
@@ -515,22 +515,51 @@ def _build_history(
|
||||
entry = {"role": msg["role"], "content": content}
|
||||
if attachments_meta:
|
||||
entry["attachments"] = attachments_meta
|
||||
# Surface the ``_source`` side-channel so the frontend can apply
|
||||
# the ``.msg.user.system-nudge`` class on history replay (today
|
||||
# only the wake-driven empty user turn carries ``"system_nudge"``).
|
||||
# Persisted via the conversations._source column added in
|
||||
# migration 050; legacy rows without the column lack the key
|
||||
# entirely.
|
||||
if msg.get("_source"):
|
||||
entry["source"] = str(msg["_source"])
|
||||
# Surface the ``_reminders`` side-channel so a tab reconnecting
|
||||
# via /history renders the same metacognitive nudge bubble the
|
||||
# originating tab saw live (user-channel reminders via
|
||||
# ``user_reminder`` SSE; tool-channel via ``tool_reminder``).
|
||||
# Reminders are in-memory only (not persisted to DB), so this
|
||||
# only fires for the originating session.
|
||||
# Persisted via the conversations._reminders column added in
|
||||
# migration 050 — multi-tab / multi-device tabs reconnecting
|
||||
# later see the same shape now, not just the originating tab.
|
||||
reminders = msg.get("_reminders")
|
||||
if isinstance(reminders, list):
|
||||
# Filter first so an all-malformed _reminders doesn't set the
|
||||
# field to []; absent vs. empty-list should mean the same
|
||||
# thing on the wire.
|
||||
clean_reminders = [
|
||||
{"type": str(r.get("type") or ""), "text": str(r.get("text") or "")}
|
||||
for r in reminders
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
# thing on the wire. Project on a known set of keys —
|
||||
# narrows the blast radius if a future producer accidentally
|
||||
# stuffs sensitive fields into the dict. ``watch_triggered``
|
||||
# carries the structured watch-card fields (watch_name,
|
||||
# command, poll_count, max_polls, is_final) and other
|
||||
# producers leave them unset.
|
||||
clean_reminders: list[dict[str, Any]] = []
|
||||
for r in reminders:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
rtype = str(r.get("type") or "")
|
||||
rtext = str(r.get("text") or "")
|
||||
if not rtype and not rtext:
|
||||
continue
|
||||
clean: dict[str, Any] = {"type": rtype, "text": rtext}
|
||||
# Preserve watch-card optional fields verbatim.
|
||||
for opt_key in (
|
||||
"watch_name",
|
||||
"command",
|
||||
"poll_count",
|
||||
"max_polls",
|
||||
"is_final",
|
||||
):
|
||||
if opt_key in r:
|
||||
clean[opt_key] = r[opt_key]
|
||||
clean_reminders.append(clean)
|
||||
if clean_reminders:
|
||||
entry["reminders"] = clean_reminders
|
||||
if msg.get("tool_calls"):
|
||||
|
||||
@@ -915,6 +915,73 @@
|
||||
margin-right: 6px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
/* Newline preservation for every metacog reminder bubble.
|
||||
`.msg.user-reminder` sets `white-space: pre-wrap` on the outer
|
||||
element but the shared `.msg-body` rule below explicitly resets to
|
||||
`normal` for markdown-friendly spacing. Restoring `pre-wrap` for
|
||||
the reminder body keeps multi-line shell output (watch_triggered)
|
||||
and bulleted lists (idle_children) readable instead of collapsing
|
||||
newlines into single spaces. */
|
||||
.msg.user-reminder .msg-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Watch-result card — full-width structured rendering for the
|
||||
`watch_triggered` reminder type. First-pass functional treatment
|
||||
(no bespoke design polish yet) — the goal is making 50-line
|
||||
`gh pr view` shell output legible inside the chat flow rather than
|
||||
buried in a small advisory bubble. */
|
||||
.msg.watch-result {
|
||||
border-left-color: var(--cyan);
|
||||
background: var(--panel);
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.msg.watch-result .msg-watch-header {
|
||||
color: var(--cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.msg.watch-result .msg-watch-cmd {
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg.watch-result .msg-watch-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
/* Shell output can be wide (URLs, JSON, file lists) — break inside
|
||||
long words so the off-canvas mobile drawer doesn't blow horizontal
|
||||
layout. R7 in the plan's risk register. */
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.watch-result .msg-watch-footer {
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* System-nudge marker — visible-but-thin replacement for the wake's
|
||||
synthetic empty user turn. Rendered above wake-driven reminder
|
||||
bubbles so they anchor to a real DOM element instead of falling
|
||||
below an old user message (the §3.4 misalignment in the briefing).
|
||||
Today only `_source = "system_nudge"` triggers it; future producers
|
||||
can add their own thin marker via the same pattern. */
|
||||
.msg.user.system-nudge {
|
||||
border-left-color: var(--yellow);
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
background: transparent;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.msg.assistant {
|
||||
border-left-color: var(--hair-2);
|
||||
}
|
||||
|
||||
+117
-36
@@ -568,17 +568,20 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
// insertAdjacentElement('afterend', el) call drops the bubble
|
||||
// immediately below.
|
||||
//
|
||||
// Multi-tab caveat: the server emits no user_message SSE event
|
||||
// today, so a non-originating tab open on the same workstream
|
||||
// Wake-driven reminders (``evt.source === "system_nudge"``)
|
||||
// render the thin .msg.user.system-nudge marker first and
|
||||
// anchor below it, so non-originating tabs see the same shape
|
||||
// the originating tab does.
|
||||
//
|
||||
// Multi-tab caveat (non-wake): the server emits no user_message
|
||||
// SSE event for real user input today, so a non-originating tab
|
||||
// sees the reminder without a paired user-message render — the
|
||||
// anchor falls on a stale prior user bubble, mis-positioning
|
||||
// the reminder. The next /history reload corrects it (the
|
||||
// entry["reminders"] propagation in _build_history is
|
||||
// anchor-stable because replayHistory runs addUserMessage first
|
||||
// for every turn). Acceptable cost for stage 1; closing the
|
||||
// gap is a follow-up that adds a user_message SSE event.
|
||||
// the reminder. The next /history reload corrects it.
|
||||
// Acceptable cost for stage 1; closing the gap is a follow-up
|
||||
// that adds a user_message SSE event.
|
||||
if (Array.isArray(evt.reminders) && evt.reminders.length) {
|
||||
this.addUserReminder(evt.reminders);
|
||||
this.addUserReminder(evt.reminders, evt.source || "");
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -707,7 +710,78 @@ Pane.prototype.removeThinkingIndicator = function () {
|
||||
if (el) el.remove();
|
||||
};
|
||||
|
||||
Pane.prototype.addUserReminder = function (reminders) {
|
||||
// Build a structured ``.msg.watch-result`` card for a
|
||||
// ``watch_triggered`` reminder — full-width treatment with command
|
||||
// preview header + shell output body + poll counter footer. Mirrors
|
||||
// the coordinator pane's buildWatchResultBubble; first-pass functional
|
||||
// rendering only. All text goes through textContent so shell output
|
||||
// containing angle brackets / scripts / steering bytes renders inertly.
|
||||
function _buildWatchResultBubble(r) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg watch-result";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "watch");
|
||||
el.setAttribute("aria-label", "watch");
|
||||
var header = document.createElement("div");
|
||||
header.className = "msg-watch-header";
|
||||
header.textContent =
|
||||
"watch" + (r.watch_name ? " · " + String(r.watch_name) : "");
|
||||
el.appendChild(header);
|
||||
if (r.command) {
|
||||
var cmd = document.createElement("div");
|
||||
cmd.className = "msg-watch-cmd";
|
||||
cmd.textContent = "$ " + String(r.command);
|
||||
el.appendChild(cmd);
|
||||
}
|
||||
var body = document.createElement("pre");
|
||||
body.className = "msg-watch-body";
|
||||
body.textContent = r.text || "";
|
||||
el.appendChild(body);
|
||||
if (r.poll_count != null && r.max_polls != null) {
|
||||
var footer = document.createElement("div");
|
||||
footer.className = "msg-watch-footer";
|
||||
var finalSuffix = r.is_final ? " · final" : "";
|
||||
footer.textContent =
|
||||
"poll " + String(r.poll_count) + "/" + String(r.max_polls) + finalSuffix;
|
||||
el.appendChild(footer);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Default ``.msg.user-reminder`` bubble — yellow themed advisory used
|
||||
// for every metacog nudge other than ``watch_triggered``.
|
||||
function _buildDefaultReminderBubble(r) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg user-reminder";
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
var textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
el.appendChild(labelEl);
|
||||
el.appendChild(textEl);
|
||||
return el;
|
||||
}
|
||||
|
||||
Pane.prototype.addSystemNudgeMarker = function () {
|
||||
// Thin .msg.user.system-nudge marker rendered as the anchor for
|
||||
// wake-driven reminder bubbles. Replaces the previously-invisible
|
||||
// synthetic empty user turn with a visible-but-subtle DOM element so
|
||||
// the bubble below it lands in the right place even when the wake
|
||||
// fires long after the user's last real message.
|
||||
this.removeEmptyState();
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg user system-nudge";
|
||||
el.setAttribute("data-source", "system_nudge");
|
||||
el.setAttribute("aria-label", "system nudge");
|
||||
el.textContent = "system nudge";
|
||||
this.messagesEl.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
Pane.prototype.addUserReminder = function (reminders, source) {
|
||||
// Render each metacognitive reminder as its own bubble immediately
|
||||
// BELOW the user message it advises — semantically the reminder is
|
||||
// a hint to the model right before the assistant turn. Always
|
||||
@@ -719,22 +793,25 @@ Pane.prototype.addUserReminder = function (reminders) {
|
||||
// exists at all (e.g. a non-originating tab receiving a reminder
|
||||
// before any user turn has rendered) we append; the next /history
|
||||
// reload corrects any anchor anomaly.
|
||||
//
|
||||
// ``source === "system_nudge"`` is the wake-driven case: render
|
||||
// a thin .msg.user.system-nudge marker first and anchor below it.
|
||||
// ``watch_triggered`` reminders branch off into a structured
|
||||
// .msg.watch-result card.
|
||||
this.removeEmptyState();
|
||||
var userBubbles = this.messagesEl.querySelectorAll(".msg.user");
|
||||
var anchor = userBubbles.length ? userBubbles[userBubbles.length - 1] : null;
|
||||
var anchor;
|
||||
if (source === "system_nudge") {
|
||||
anchor = this.addSystemNudgeMarker();
|
||||
} else {
|
||||
var userBubbles = this.messagesEl.querySelectorAll(".msg.user");
|
||||
anchor = userBubbles.length ? userBubbles[userBubbles.length - 1] : null;
|
||||
}
|
||||
for (var i = 0; i < reminders.length; i++) {
|
||||
var r = reminders[i] || {};
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg user-reminder";
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
var textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
el.appendChild(labelEl);
|
||||
el.appendChild(textEl);
|
||||
var el =
|
||||
r.type === "watch_triggered"
|
||||
? _buildWatchResultBubble(r)
|
||||
: _buildDefaultReminderBubble(r);
|
||||
if (anchor) {
|
||||
anchor.insertAdjacentElement("afterend", el);
|
||||
// Anchor advances so multiple reminders stack below the user
|
||||
@@ -757,7 +834,9 @@ Pane.prototype.addToolReminder = function (reminders, toolCallId) {
|
||||
// to "last .ts-approval block in messagesEl", which is correct
|
||||
// because messages render in order — the assistant block carrying
|
||||
// the tool batch is always the most recent approval block by the
|
||||
// time we hit the tool message that owns the reminder.
|
||||
// time we hit the tool message that owns the reminder. Tool-channel
|
||||
// reminders also branch on r.type so a watch_triggered drained at
|
||||
// the tool seam (channel="any") renders the structured card.
|
||||
this.removeEmptyState();
|
||||
var anchor = null;
|
||||
if (toolCallId) {
|
||||
@@ -775,19 +854,10 @@ Pane.prototype.addToolReminder = function (reminders, toolCallId) {
|
||||
}
|
||||
for (var i = 0; i < reminders.length; i++) {
|
||||
var r = reminders[i] || {};
|
||||
var el = document.createElement("div");
|
||||
// Same .msg.user-reminder class — visual treatment is shared
|
||||
// across user and tool channels (both are metacog nudges).
|
||||
el.className = "msg user-reminder";
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-user-reminder-label";
|
||||
labelEl.textContent =
|
||||
"metacognition" + (r.type ? " · " + String(r.type) : "");
|
||||
var textEl = document.createElement("span");
|
||||
textEl.className = "msg-user-reminder-text";
|
||||
textEl.textContent = r.text || "";
|
||||
el.appendChild(labelEl);
|
||||
el.appendChild(textEl);
|
||||
var el =
|
||||
r.type === "watch_triggered"
|
||||
? _buildWatchResultBubble(r)
|
||||
: _buildDefaultReminderBubble(r);
|
||||
if (anchor) {
|
||||
anchor.insertAdjacentElement("afterend", el);
|
||||
anchor = el;
|
||||
@@ -1053,6 +1123,17 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var msg = messages[i];
|
||||
if (msg.role === "user") {
|
||||
if (msg.source === "system_nudge") {
|
||||
// Wake-driven empty user turn: render the thin marker
|
||||
// (replaces the previously-skipped synthetic empty bubble)
|
||||
// and anchor reminder bubbles below it.
|
||||
this.addUserReminder(
|
||||
Array.isArray(msg.reminders) ? msg.reminders : [],
|
||||
"system_nudge",
|
||||
);
|
||||
lastToolBlock = null;
|
||||
continue;
|
||||
}
|
||||
// addUserMessage first so addUserReminder's "anchor to most
|
||||
// recent .msg.user" lookup finds THIS message's bubble (not the
|
||||
// previous user message's, which would associate the reminder
|
||||
|
||||
Reference in New Issue
Block a user