Skip to content

feat(floating): SpeechBubble + truthful memory receipt (S5)#159

Merged
gorajing merged 7 commits into
mainfrom
feat/floating-speech
Jul 4, 2026
Merged

feat(floating): SpeechBubble + truthful memory receipt (S5)#159
gorajing merged 7 commits into
mainfrom
feat/floating-speech

Conversation

@gorajing

@gorajing gorajing commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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

  • SpeechBubble (reactions/speechBubble.ts): a new CaptionSink composed with the dev CaptionPanel (compositeCaptionSink) so ONE subscribeActionEvents feed drives both; the dev CaptionPanel behavior is unchanged (noteEvent is optional-chained past it). Mounts as a #roro-stage island (marker-hit-tested), tethered beside the cat. Fed by message / message.delta / run.completed.finalText — ZERO new ActionEvent kinds.
  • Auto-fade clamp(words*220ms, 2500, 8000); hovering pauses (banks the remainder), leaving resumes; new content resets. Cat lip-syncs via setTalking. Restraint-gated: a bare no-payload success shows nothing.
  • Truthful memory receipt (pure reduceSpeechTurn): · used what I remember about your setup appears only when a status beat 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-speech mounts 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

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/renderer/reactions/speechBubble.ts Outdated
Comment on lines +58 to +62
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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

gorajing and others added 6 commits July 4, 2026 04:42
…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>
@gorajing
gorajing merged commit 7f05b71 into main Jul 4, 2026
3 checks passed
@gorajing
gorajing deleted the feat/floating-speech branch July 4, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant