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:
Patrick Buckley
2026-05-06 17:07:00 -07:00
parent 592433b46d
commit dec175f176
11 changed files with 542 additions and 77 deletions
+143
View File
@@ -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"}]
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+11 -2
View File
@@ -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)
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"):
+67
View File
@@ -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
View File
@@ -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