feat(floating): SpeechBubble + truthful memory receipt (S5)#159
Conversation
The cat voices its answer/narration as a tethered #roro-stage island that
blooms beside it, plus founder decision 5's strategic payload: a subtle,
TRUTHFUL "used what I remember about your setup" receipt shown ONLY when a
recalled fact demonstrably shaped the turn — never on every turn, never when
memory was not recalled. A bare no-payload success shows nothing (the locked
no-Done-banner contract).
- SpeechBubble (src/renderer/reactions/speechBubble.ts): a TurnAwareCaptionSink.
Answer text arrives via update('assistant', …, true) — the same seam that
feeds the dev CaptionPanel; live streaming accumulates message.delta tokens
via noteEvent (incremental deltas, so the lossy update() seam can't carry
them); the recall gate + run.started reset also ride noteEvent. setTalking(true)
while showing/streaming, false on clear. Auto-fade after a length-scaled dwell
clamp(words*220, 2500, 8000); hovering pauses (banks the remainder), leaving
resumes; new content resets.
- Truthful receipt: reduceSpeechTurn tracks whether a `status` beat this turn
indicated recall (reusing parseMemoryStatus — no parallel signal); reset on
run.started; receiptVisible = recalled AND answer text.
- Composite sink fans one subscribeActionEvents feed to BOTH the dev CaptionPanel
(unchanged, display:none in floating) and the floating bubble. actionEvents
extracts applyCaptionEvent as the single event->caption fan (forwards noteEvent
to turn-aware sinks; message.delta stays off update() so the CaptionPanel is
unchanged). Bubble mounts in FLOATING mode only.
No new ActionEvent kinds; askMachine/CaptionPanel/WorkingChip/AlertCard untouched.
Unit-tested the pure bits (fade dwell, recall gate, receiptVisible, composite
fan, applyCaptionEvent mapping) + jsdom-tested render/stream/final/fade-reset/
hover-pause/restraint. On-screen verified over the debug port (scenarios a-e).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc69eedfc6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (e.kind === 'run.started') return { recalledThisTurn: false }; | ||
| if (e.kind === 'status') { | ||
| const counts = parseMemoryStatus(e.text); | ||
| if (counts && (counts.factCount > 0 || counts.episodeCount > 0)) { | ||
| return { recalledThisTurn: true }; |
There was a problem hiding this comment.
Keep recall set after the executor starts
When a remembered coding turn reaches the executor, the receipt is cleared before the final message: runTurn emits the memory status during recallContext before dispatch, and pumpRun forwards the executor's later run.started, so this reset drops recalledThisTurn. Because answer/clarify turns do not emit run.started, a zero-count memory status after a prior recalled answer also leaves the flag stuck true. The speech bubble therefore hides the receipt on the main coding-turn path and can show it on a later non-recall answer; update this state from the per-turn memory status/run id instead of clearing on executor start.
Useful? React with 👍 / 👎.
…s + hover-during-fade (codex S5 round 1) Three findings, two of them TRUTHFULNESS bugs (the receipt is the moat payload): 1. (real) The recalled flag was per-bubble, not per-run: a recall in one turn could attach 'used what I remember' to a LATER run's answer that didn't recall. Now reduceSpeechTurn records the recalled runId, and receiptForAnswerEvent shows the receipt ONLY for a real answer whose runId matches the recalled run. Regression test: recall in run r, answer in run r2 → no receipt. 2. (real) A stopped run.failed is forwarded to the sink as the 'Stopped.' line, which the old gate treated as a real answer — so after recall+stop the receipt attached to 'Stopped.'. receiptForAnswerEvent now requires the event be a message/ run.completed(finalText), never a stop/fail. Regression test added. Eligibility is computed in noteEvent (which sees the answer event's kind+runId) since update() only gets (role,text,final); applyCaptionEvent always calls noteEvent(e) before update(). 3. (real) Hovering DURING the 320ms fade-out didn't cancel it (only the pending-dwell phase paused), so the bubble vanished under the cursor. onHoverEnter now also cancels the leave phase + restores the bubble. Regression test with fake timers. tsc clean · 1082 passed / 11 skipped (+3 regression tests) · eslint 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound 2) Both round-2 findings share a root: a new turn did not clear the prior bubble, so a recalled-answer receipt could LINGER into the next turn (which did not earn it) — run.started only reset streamBuffer, and a generic run.failed calls no update() at all. Now noteEvent clearNow()s the bubble on run.started (a fresh turn) and on run.failed (a failed turn has no answer — the AlertCard shows the error; a STOPPED failure still repopulates 'Stopped.' via the update() that follows). So a receipt can never survive into a later turn or over a failure. Regression tests: recalled answer in turn A, then run.started(B) / run.failed(B) → bubble + receipt cleared. tsc clean · 1084 passed / 11 skipped (+2) · eslint 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…through the executor run.started (codex S5 round 3)
Two deep findings verified against the FROZEN orchestrator:
1. The orchestrator emits the brain's PLANNING beat as kind:'message' ('…is planning
the task…') BEFORE the answer, and answer-turn narration is ALSO kind:'message' —
the two are structurally identical. So receipting any 'message' risked claiming
recall under a planning line (a lie).
2. The executor's run.started REUSES the turn's runId (recall already ran for it:
recall → planning → run.started, one runId). Clearing recall on run.started wiped it
for the very turn that recalled, so the executor's run.completed answer lost the
receipt.
Fix (frozen orchestrator → fix the consumer): the receipt attaches ONLY to
run.completed.finalText (the unambiguous executor answer) in the SAME run that
recalled; reduceSpeechTurn keeps the recall through a same-runId run.started and clears
only on a NEW-runId turn. Truthful-safe: NEVER over-claims (a bare message never earns
it); it under-claims on pure answer-turns (no executor) — acceptable, and a founder-
tunable follow-up would need an orchestrator signal to tell planning from answer
messages. Tests rewritten to the real executor sequence (status→run.started(same run)→
run.completed.finalText) + a planning-beat-never-receipted case.
tsc clean · 1085 passed / 11 skipped · eslint 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ough-run.started (codex S5 round 4) The header contract still said 'run.started resets the recall gate' — the round-3 reducer intentionally KEEPS recall through a same-runId executor run.started (clearing only on a new-runId turn or a failure). Comment-only sync to the shipped behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round 5) Real auto-fade bug: clearNow() (on run.started/run.failed) hid the bubble without resetting . Since a display:none element emits no mouseleave, if a run cleared the bubble while the cursor was over it, stayed true — and the NEXT answer entered scheduleFade() with hovering=true, so its fade timer never started and the bubble stayed visible forever. hideNow() now resets hovering=false (a real re-hover fires a fresh mouseenter). Regression test with fake timers: hover→pause→run.started clears→next answer still fades. tsc clean · 1086 passed / 11 skipped · eslint 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound 6) Two more real state-machine edges: 1. Turn-boundary clearing keyed off run.started/run.failed, but answer/clarify turns emit NEITHER (turn.started is also executor-only) — so a prior answer + receipt lingered into a new non-executor turn until a later message replaced it. Now clear on any runId CHANGE (the only universal new-turn signal), covering executor AND answer turns; the run.started/run.failed clears remain as intra-turn clears. 2. hideNow() reset hovering but left fadeRemaining stale, and message.delta cancelFade() didn't zero it — so a hover-leave mid-stream could resume a prior turn's banked dwell and fade LIVE streaming tokens early. Now fadeRemaining is zeroed in hideNow() and on every streaming delta (the dwell starts only at the final). Regression tests: an answer-turn (new runId, no run.started) clears the prior receipt; a run-clear + new streaming answer + mid-stream hover-leave does not fade early. tsc clean · 1088 passed / 11 skipped (+2) · eslint 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
S5 of the floating-UI redesign (spec §SpeechBubble + founder decision 5). The cat voices its answer as a tethered bubble — and surfaces the memory moat truthfully.
What
reactions/speechBubble.ts): a newCaptionSinkcomposed with the dev CaptionPanel (compositeCaptionSink) so ONEsubscribeActionEventsfeed drives both; the dev CaptionPanel behavior is unchanged (noteEvent is optional-chained past it). Mounts as a#roro-stageisland (marker-hit-tested), tethered beside the cat. Fed by message / message.delta / run.completed.finalText — ZERO new ActionEvent kinds.clamp(words*220ms, 2500, 8000); hovering pauses (banks the remainder), leaving resumes; new content resets. Cat lip-syncs viasetTalking. Restraint-gated: a bare no-payload success shows nothing.reduceSpeechTurn):· used what I remember about your setupappears only when astatusbeat this turn indicated recall (factCount>0 || episodeCount>0) AND there's real answer text; reset on run.started. Never claimed when memory wasn't recalled.On-screen verified (independent re-check + agent screenshots)
Recall turn → answer + the receipt row; no-recall turn → answer, NO receipt (the truthfulness guarantee); bare success → no bubble (restraint); auto-fade + hover-pause;
#roro-speechmounts as a stage island (in #roro-stage, marker-hit-tested).Frozen
ActionEvent union (no new kinds), orchestrator/brain/memory, the dev CaptionPanel, askMachine, S1-S4b surfaces.
Gate
tsc clean · 1079 passed / 11 skipped (+23: pure fade/recall-gate + jsdom render/stream/restraint/fade) · eslint 0 errors.
Founder-tuning
Receipt copy is one constant (
MEMORY_RECEIPT_TEXT); fade knobs are 220ms/word + 2500/8000 bounds + 320ms fade-out.🤖 Generated with Claude Code