From bc69eedfc64446a82651c37fd610d72744663bf7 Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 04:25:57 -0700 Subject: [PATCH 1/7] feat(floating): SpeechBubble + truthful memory receipt (S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.css | 49 +++ src/renderer/bootstrap.ts | 17 +- src/renderer/character/types.ts | 13 + src/renderer/events/actionEvents.ts | 39 ++- src/renderer/reactions/speechBubble.test.ts | 296 +++++++++++++++++ src/renderer/reactions/speechBubble.ts | 336 ++++++++++++++++++++ 6 files changed, 738 insertions(+), 12 deletions(-) create mode 100644 src/renderer/reactions/speechBubble.test.ts create mode 100644 src/renderer/reactions/speechBubble.ts diff --git a/src/index.css b/src/index.css index 7003114..6cd978c 100644 --- a/src/index.css +++ b/src/index.css @@ -549,6 +549,55 @@ body.floating-window #floating-error.success { opacity: 1; } +/* ---- SpeechBubble (S5): the cat voicing its answer/narration as a tethered #roro-stage island, blooming + FROM the cat like the other reactions. position:absolute + left/top/transform-origin come from the tether + placer; these rules carry only the look, the show/hide, the scale-in bloom, and the fade-out. The + `.roro-speech__receipt` row is the truthful memory receipt — subtle, secondary, only present when a + recalled fact shaped the turn. ---- */ +#roro-speech { + display: none; +} +body.floating-window #roro-speech.shown { + display: block; + z-index: 4; + max-width: min(260px, 70vw); + padding: 8px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(20, 20, 28, 0.82); + color: #f4f4f8; + font: inherit; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + animation: working-chip-bloom 160ms ease-out; +} +/* Fade-out: a new answer removes `.leaving` and re-blooms; the dwell timer adds it, then the island is + hidden after the animation (mirrors LEAVE_MS in speechBubble.ts). */ +body.floating-window #roro-speech.shown.leaving { + animation: roro-speech-leave 320ms ease forwards; +} +@keyframes roro-speech-leave { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.94); } +} +.roro-speech__text { + white-space: pre-wrap; + word-break: break-word; +} +.roro-speech__receipt { + margin-top: 5px; + font-size: 11px; + line-height: 1.3; + opacity: 0.6; + color: rgba(196, 222, 255, 0.92); +} +.roro-speech__receipt::before { + content: '· '; +} + /* ---- Phase C1: destructive-confirm chip (shown in BOTH modes — safety is not floating-only) ---- */ #confirm-chip { display: none; diff --git a/src/renderer/bootstrap.ts b/src/renderer/bootstrap.ts index aff9250..b504ad5 100644 --- a/src/renderer/bootstrap.ts +++ b/src/renderer/bootstrap.ts @@ -31,7 +31,9 @@ import { getCompanion } from './events/bridge'; import { runState } from './events/runState'; import { mountInteractionRegions } from './reactions/interactionRegions'; import { pointInRect } from './reactions/clickThrough'; +import { mountSpeechBubble, compositeCaptionSink } from './reactions/speechBubble'; import { FLOATING_LAYOUT, type Rect } from '../shared/floatingLayout'; +import type { CaptionSink } from './character/types'; function el(id: string): T | null { return document.getElementById(id) as T | null; @@ -60,7 +62,20 @@ export async function bootstrap(): Promise { // 3: captions + timeline + executor/brain subscriptions. const captions = new CaptionPanel(); const timeline = new ActionTimeline(); - subscribeActionEvents({ character: driver, timeline, captions }); + // S5: the cat's SPEECH BUBBLE + truthful memory receipt. A tethered #roro-stage island that voices the + // assistant text (the SAME CaptionSink seam that feeds the dev CaptionPanel) and, when a recalled fact + // demonstrably shaped the turn, appends a subtle truthful "used what I remember…" receipt. FLOATING mode + // only; COMPOSED with the CaptionPanel so ONE subscribeActionEvents feed drives both (the CaptionPanel is + // display:none inside #overlay in floating — harmless). In dev mode the bubble is not mounted. + const speech = config.floatingWindow + ? mountSpeechBubble({ + driver, + getCatBounds: () => avatar.cat.getScreenBounds(), + smoke: config.floatingSmoke, + }) + : null; + const captionSink: CaptionSink = speech ? compositeCaptionSink(captions, speech.sink) : captions; + subscribeActionEvents({ character: driver, timeline, captions: captionSink }); const brainGate = createBrainReadinessGate({ subscribe: (cb) => getCompanion()?.onBootstrapStatus?.((s) => cb(s)) ?? (() => undefined), diff --git a/src/renderer/character/types.ts b/src/renderer/character/types.ts index faa44c8..5c1d16d 100644 --- a/src/renderer/character/types.ts +++ b/src/renderer/character/types.ts @@ -6,6 +6,7 @@ import type { AvatarState } from '../../shared/avatar'; import type { GazeTarget } from '../../shared/gaze'; +import type { ActionEvent } from '../../shared/events'; export type { AvatarState }; @@ -67,3 +68,15 @@ export interface CharacterDriver { export interface CaptionSink { update(role: 'user' | 'assistant', text: string, isFinal: boolean): void; } + +/** + * A CaptionSink that ALSO observes the raw ActionEvent stream for turn-scoped + * bookkeeping the (role, text, isFinal) seam can't carry — e.g. the SpeechBubble's + * truthful memory receipt needs the `status` memory-recall beat (never assistant + * text) and `run.started` (turn boundary), plus `message.delta` tokens for live + * streaming. subscribeActionEvents forwards every event to `noteEvent`; sinks that + * don't implement it (the dev CaptionPanel) are unaffected (optional-chained). + */ +export interface TurnAwareCaptionSink extends CaptionSink { + noteEvent(e: ActionEvent): void; +} diff --git a/src/renderer/events/actionEvents.ts b/src/renderer/events/actionEvents.ts index 5f1f01a..e501f66 100644 --- a/src/renderer/events/actionEvents.ts +++ b/src/renderer/events/actionEvents.ts @@ -11,7 +11,7 @@ import { eventToAvatarState } from '../../shared/avatar'; import { parseMemoryStatus, SCREEN_CAPTURE_STATUS_TEXT, type ActionEvent } from '../../shared/events'; import { isStoppedTerminalError } from '../../shared/stopped'; -import type { ActivityCue, CharacterDriver, CaptionSink } from '../character/types'; +import type { ActivityCue, CharacterDriver, CaptionSink, TurnAwareCaptionSink } from '../character/types'; import type { ActionTimeline } from '../character/captions'; import { getCompanion, getBrain } from './bridge'; import { runState } from './runState'; @@ -90,6 +90,29 @@ export function activityForEvent(e: ActionEvent): ActivityCue | null { } } +/** + * Fan ONE ActionEvent into a CaptionSink: the single source of truth for + * event -> caption mapping (reused by the SpeechBubble's on-screen smoke so it + * mirrors production exactly). Two channels: + * - noteEvent(e): raw stream for turn-aware sinks (memory-recall gate, live + * message.delta streaming, run.started reset). No-op on plain CaptionSinks. + * - update('assistant', …, true): the COMPLETE assistant text (message / + * run.completed.finalText / a 'Stopped.' acknowledgement) — the legible turn + * line. message.delta is deliberately NOT forwarded to update() (it's + * incremental tokens; the turn-aware sink accumulates it via noteEvent), so + * the dev CaptionPanel's behaviour is unchanged. + */ +export function applyCaptionEvent(captions: CaptionSink, e: ActionEvent): void { + (captions as Partial).noteEvent?.(e); + if (e.kind === 'message' && e.text) { + captions.update('assistant', e.text, true); + } else if (e.kind === 'run.completed' && e.finalText) { + captions.update('assistant', e.finalText, true); + } else if (e.kind === 'run.failed' && isStoppedTerminalError(e.error)) { + captions.update('assistant', 'Stopped.', true); + } +} + export function subscribeActionEvents(opts: SubscribeOptions): () => void { const { character, timeline, captions } = opts; const unsubs: Array<() => void> = []; @@ -109,16 +132,10 @@ export function subscribeActionEvents(opts: SubscribeOptions): () => void { if (activity) character.setActivity(activity); timeline.append(e); // Surface narration + final summary as assistant caption lines so the - // text-input turn is legible without any voice output. - if (captions) { - if (e.kind === 'message' && e.text) { - captions.update('assistant', e.text, true); - } else if (e.kind === 'run.completed' && e.finalText) { - captions.update('assistant', e.finalText, true); - } else if (e.kind === 'run.failed' && isStoppedTerminalError(e.error)) { - captions.update('assistant', 'Stopped.', true); - } - } + // text-input turn is legible without any voice output — AND drive any + // turn-aware sink (the SpeechBubble) via noteEvent for its live streaming + // + truthful memory receipt. + if (captions) applyCaptionEvent(captions, e); }), ); } else { diff --git a/src/renderer/reactions/speechBubble.test.ts b/src/renderer/reactions/speechBubble.test.ts new file mode 100644 index 0000000..7a330ad --- /dev/null +++ b/src/renderer/reactions/speechBubble.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + SpeechBubble, + MEMORY_RECEIPT_TEXT, + clamp, + wordCount, + fadeDwellMs, + reduceSpeechTurn, + initialSpeechTurnState, + receiptVisible, + compositeCaptionSink, +} from './speechBubble'; +import { applyCaptionEvent } from '../events/actionEvents'; +import { formatMemoryStatus, type ActionEvent } from '../../shared/events'; +import type { CaptionSink, CharacterDriver } from '../character/types'; + +// ---- test doubles ---- +function fakeDriver(): CharacterDriver & { talking: boolean } { + const driver = { + talking: false, + state: 'idle' as const, + setState() {}, + setActivity() {}, + setMouthOpen() {}, + setTalking(v: boolean) { + driver.talking = v; + }, + setMuted() {}, + }; + return driver as unknown as CharacterDriver & { talking: boolean }; +} + +const runStarted = (): ActionEvent => ({ kind: 'run.started', agent: 'claude', runId: 'r', ts: 0 }); +const statusRecalled = (): ActionEvent => ({ + kind: 'status', + runId: 'r', + text: formatMemoryStatus({ factCount: 2, episodeCount: 1 }), + ts: 0, +}); +const statusNoRecall = (): ActionEvent => ({ + kind: 'status', + runId: 'r', + text: formatMemoryStatus({ factCount: 0, episodeCount: 0 }), + ts: 0, +}); + +// =========================== PURE =========================== + +describe('speechBubble — pure fade-dwell computation', () => { + it('clamp bounds a value', () => { + expect(clamp(5, 10, 20)).toBe(10); + expect(clamp(15, 10, 20)).toBe(15); + expect(clamp(25, 10, 20)).toBe(20); + }); + + it('wordCount collapses whitespace and treats empty as 0', () => { + expect(wordCount('')).toBe(0); + expect(wordCount(' ')).toBe(0); + expect(wordCount('one')).toBe(1); + expect(wordCount(' two words \n here ')).toBe(3); + }); + + it('fadeDwellMs = clamp(words * 220, 2500, 8000)', () => { + expect(fadeDwellMs('one')).toBe(2500); // 1*220=220 → floor 2500 + expect(fadeDwellMs(Array(20).fill('w').join(' '))).toBe(4400); // 20*220=4400 (inside band) + expect(fadeDwellMs(Array(100).fill('w').join(' '))).toBe(8000); // 100*220=22000 → ceil 8000 + // exact band edges + expect(fadeDwellMs(Array(12).fill('w').join(' '))).toBe(2640); // 12*220=2640 + }); +}); + +describe('speechBubble — recalled gate (pure reducer)', () => { + it('starts closed', () => { + expect(initialSpeechTurnState().recalledThisTurn).toBe(false); + }); + + it('opens when a recalled status fires', () => { + const s = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); + expect(s.recalledThisTurn).toBe(true); + }); + + it('stays closed for a not-recalled status (factCount 0, episodeCount 0)', () => { + const s = reduceSpeechTurn(initialSpeechTurnState(), statusNoRecall()); + expect(s.recalledThisTurn).toBe(false); + }); + + it('opens when only episodes were recalled (facts 0, episodes >0)', () => { + const ev: ActionEvent = { kind: 'status', runId: 'r', text: formatMemoryStatus({ factCount: 0, episodeCount: 3 }), ts: 0 }; + expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledThisTurn).toBe(true); + }); + + it('resets on run.started (new turn)', () => { + const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); + expect(recalled.recalledThisTurn).toBe(true); + const reset = reduceSpeechTurn(recalled, runStarted()); + expect(reset.recalledThisTurn).toBe(false); + }); + + it('ignores unrelated status text', () => { + const ev: ActionEvent = { kind: 'status', runId: 'r', text: 'Taking one screen snapshot.', ts: 0 }; + expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledThisTurn).toBe(false); + }); + + it('receiptVisible needs BOTH recall AND answer text', () => { + expect(receiptVisible(true, 'an answer')).toBe(true); + expect(receiptVisible(true, ' ')).toBe(false); // no answer + expect(receiptVisible(false, 'an answer')).toBe(false); // not recalled — never (a lie) + expect(receiptVisible(false, '')).toBe(false); + }); +}); + +describe('speechBubble — composite sink fans to both', () => { + it('update() reaches every sink; noteEvent() reaches only turn-aware sinks', () => { + const withNote = { update: vi.fn(), noteEvent: vi.fn() }; + const plain = { update: vi.fn() } as CaptionSink & { update: ReturnType }; + const composite = compositeCaptionSink(withNote, plain); + + composite.update('assistant', 'hi', true); + expect(withNote.update).toHaveBeenCalledWith('assistant', 'hi', true); + expect(plain.update).toHaveBeenCalledWith('assistant', 'hi', true); + + const ev = runStarted(); + expect(() => composite.noteEvent(ev)).not.toThrow(); // plain sink has no noteEvent — must not throw + expect(withNote.noteEvent).toHaveBeenCalledWith(ev); + }); +}); + +describe('applyCaptionEvent — the single event→caption fan', () => { + it('forwards every event to noteEvent, but only COMPLETE text to update()', () => { + const sink = { update: vi.fn(), noteEvent: vi.fn() }; + + applyCaptionEvent(sink, statusRecalled()); + expect(sink.noteEvent).toHaveBeenCalledTimes(1); + expect(sink.update).not.toHaveBeenCalled(); // status is never assistant text + + applyCaptionEvent(sink, { kind: 'message.delta', runId: 'r', text: 'tok', ts: 0 }); + expect(sink.noteEvent).toHaveBeenCalledTimes(2); + expect(sink.update).not.toHaveBeenCalled(); // deltas ride noteEvent, not update() + + applyCaptionEvent(sink, { kind: 'message', runId: 'r', text: 'the answer', ts: 0 }); + expect(sink.update).toHaveBeenCalledWith('assistant', 'the answer', true); + + applyCaptionEvent(sink, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'final', ts: 0 }); + expect(sink.update).toHaveBeenCalledWith('assistant', 'final', true); + + sink.update.mockClear(); + applyCaptionEvent(sink, { kind: 'run.completed', runId: 'r', ok: true, ts: 0 }); // no finalText + expect(sink.update).not.toHaveBeenCalled(); // bare success — nothing to say + }); +}); + +// =========================== jsdom (render/stream/fade/receipt/restraint) =========================== + +describe('SpeechBubble — render + streaming + memory receipt', () => { + let driver: CharacterDriver & { talking: boolean }; + let bubble: SpeechBubble; + + beforeEach(() => { + document.body.innerHTML = ''; + vi.useFakeTimers(); + driver = fakeDriver(); + bubble = new SpeechBubble({ driver, getCatBounds: () => null }); + }); + afterEach(() => { + bubble.dispose(); + vi.useRealTimers(); + }); + + it('shows a final assistant answer, lip-syncs (setTalking true), no receipt without recall', () => { + bubble.update('assistant', 'Here is your answer', true); + const st = bubble.debugState(); + expect(st.visible).toBe(true); + expect(st.text).toBe('Here is your answer'); + expect(st.receipt).toBe(''); + expect(driver.talking).toBe(true); + }); + + it('ignores the user role and non-final placeholders (restraint)', () => { + bubble.update('user', 'my question', true); + expect(bubble.debugState().visible).toBe(false); + bubble.update('assistant', 'Thinking through it...', false); + expect(bubble.debugState().visible).toBe(false); + }); + + it('streams message.delta tokens live via noteEvent, then the final replaces them', () => { + bubble.noteEvent({ kind: 'message.delta', runId: 'r', text: 'Hel', ts: 0 }); + bubble.noteEvent({ kind: 'message.delta', runId: 'r', text: 'lo', ts: 0 }); + expect(bubble.debugState().visible).toBe(true); + expect(bubble.debugState().text).toBe('Hello'); + expect(driver.talking).toBe(true); + + bubble.update('assistant', 'Hello there, friend', true); + expect(bubble.debugState().text).toBe('Hello there, friend'); + }); + + it('appends the truthful receipt when a recalled status fired this turn + there is an answer', () => { + bubble.noteEvent(runStarted()); + bubble.noteEvent(statusRecalled()); + bubble.update('assistant', 'Your dev setup uses pnpm', true); + expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + }); + + it('NEVER shows the receipt when memory was not recalled (truthful)', () => { + bubble.noteEvent(runStarted()); + bubble.noteEvent(statusNoRecall()); + bubble.update('assistant', 'Here is a generic answer', true); + const st = bubble.debugState(); + expect(st.visible).toBe(true); + expect(st.receipt).toBe(''); + }); + + it('resets the recall flag on run.started so a later non-recall turn shows NO receipt', () => { + // Turn 1: recalled. + bubble.noteEvent(runStarted()); + bubble.noteEvent(statusRecalled()); + bubble.update('assistant', 'answer one', true); + expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + // Turn 2: fresh, no recall. + bubble.noteEvent(runStarted()); + bubble.update('assistant', 'answer two', true); + expect(bubble.debugState().receipt).toBe(''); + }); + + it('restraint: a bare run.completed with no finalText shows NO bubble', () => { + bubble.noteEvent(runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, ts: 0 }); + expect(bubble.debugState().visible).toBe(false); + expect(driver.talking).toBe(false); + }); + + it('restraint: a run.completed WITH finalText does show', () => { + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'All set', ts: 0 }); + expect(bubble.debugState().visible).toBe(true); + expect(bubble.debugState().text).toBe('All set'); + }); +}); + +describe('SpeechBubble — auto-fade + hover pause', () => { + let driver: CharacterDriver & { talking: boolean }; + let bubble: SpeechBubble; + + beforeEach(() => { + document.body.innerHTML = ''; + vi.useFakeTimers(); + driver = fakeDriver(); + bubble = new SpeechBubble({ driver, getCatBounds: () => null }); + }); + afterEach(() => { + bubble.dispose(); + vi.useRealTimers(); + }); + + const island = (): HTMLElement => { + const el = document.getElementById('roro-speech'); + if (!el) throw new Error('bubble island missing'); + return el; + }; + + it('fades after the length-scaled dwell and drops setTalking(false)', () => { + bubble.update('assistant', 'Hi', true); // 1 word → 2500ms floor + expect(bubble.debugState().visible).toBe(true); + + vi.advanceTimersByTime(2500); // dwell elapses → leave begins + expect(driver.talking).toBe(false); + + vi.advanceTimersByTime(320); // leave animation completes + expect(bubble.debugState().visible).toBe(false); + }); + + it('new content RESETS the dwell timer', () => { + bubble.update('assistant', 'Hi', true); // dwell 2500 + vi.advanceTimersByTime(2000); + bubble.update('assistant', 'A brand new answer', true); // 4 words → 2500; resets + vi.advanceTimersByTime(2000); // 2s into the new dwell — still up + expect(bubble.debugState().visible).toBe(true); + vi.advanceTimersByTime(500 + 320); // finish new dwell + leave + expect(bubble.debugState().visible).toBe(false); + }); + + it('hovering PAUSES the fade; leaving RESUMES it with the banked remainder', () => { + bubble.update('assistant', 'Hi', true); // dwell 2500 + vi.advanceTimersByTime(1000); + island().dispatchEvent(new MouseEvent('mouseenter')); + + vi.advanceTimersByTime(10000); // far past the dwell — paused, so still visible + expect(bubble.debugState().visible).toBe(true); + expect(driver.talking).toBe(true); + + island().dispatchEvent(new MouseEvent('mouseleave')); // resume with ~1500ms banked + vi.advanceTimersByTime(1499); + expect(bubble.debugState().visible).toBe(true); + vi.advanceTimersByTime(1 + 320); // dwell remainder + leave + expect(bubble.debugState().visible).toBe(false); + }); +}); diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts new file mode 100644 index 0000000..f2694cf --- /dev/null +++ b/src/renderer/reactions/speechBubble.ts @@ -0,0 +1,336 @@ +// src/renderer/reactions/speechBubble.ts — the cat's SPEECH BUBBLE (S5) + the truthful memory receipt. +// +// The cat voicing its answer/narration as a tethered #roro-stage island beside the cat, plus founder +// decision 5's strategic payload: a subtle, TRUTHFUL "used what I remember about your setup" line that +// appears ONLY when a recalled fact demonstrably shaped the turn (never on every turn — that would be a +// lie that erodes trust; a bare no-payload success shows nothing, matching the locked no-Done-banner +// contract). It is a CaptionSink (the answer text arrives via update('assistant', …), the same seam that +// feeds the dev CaptionPanel) AND turn-aware (noteEvent observes the raw stream for the memory-recall beat, +// run.started reset, and live message.delta tokens). It is fanned in via a COMPOSITE sink so BOTH the dev +// CaptionPanel (unchanged, hidden in floating) AND this bubble receive updates; the bubble mounts in +// FLOATING mode only. + +import type { ActionEvent } from '../../shared/events'; +import { parseMemoryStatus } from '../../shared/events'; +import type { CaptionSink, CharacterDriver, TurnAwareCaptionSink } from '../character/types'; +import type { Rect } from '../../shared/floatingLayout'; +import { applyCaptionEvent } from '../events/actionEvents'; +import { mountIsland, place as placeIsland } from './stage'; + +// ---- pure bits (unit-tested) ---- + +/** The exact, truthful receipt copy (founder-tunable). Rendered as a distinct subtle row under the answer. */ +export const MEMORY_RECEIPT_TEXT = 'used what I remember about your setup'; + +export function clamp(v: number, lo: number, hi: number): number { + return Math.min(Math.max(v, lo), hi); +} + +/** Word count of a caption line (collapses whitespace; empty → 0). */ +export function wordCount(text: string): number { + const trimmed = text.trim(); + return trimmed ? trimmed.split(/\s+/).length : 0; +} + +/** + * How long a FINAL answer dwells before auto-fading: scaled to reading length, + * `clamp(words * 220ms, 2500, 8000)`. A one-word "Done" gets the 2.5s floor; a long + * answer gets up to 8s. Hovering pauses this; new content resets it. + */ +export function fadeDwellMs(text: string): number { + return clamp(wordCount(text) * 220, 2500, 8000); +} + +/** Per-turn memory-recall bookkeeping for the truthful receipt. */ +export interface SpeechTurnState { + /** A `status` beat this turn indicated memory was actually RECALLED (factCount>0 || episodeCount>0). */ + recalledThisTurn: boolean; +} + +export const initialSpeechTurnState = (): SpeechTurnState => ({ recalledThisTurn: false }); + +/** + * Reduce one ActionEvent into the recall gate. run.started resets the flag (new turn); a `status` beat whose + * formatMemoryStatus indicates recall SETS it (reusing parseMemoryStatus — no parallel signal). Everything + * else is unchanged. Pure + exhaustive-friendly. + */ +export function reduceSpeechTurn(state: SpeechTurnState, e: ActionEvent): SpeechTurnState { + 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 }; + } + } + return state; +} + +/** + * The truthful-receipt gate: the "used what I remember" line shows IFF memory was recalled THIS turn AND + * there is a real answer to attach it to. No recall → never (a lie); no answer → nothing to receipt. + */ +export function receiptVisible(recalledThisTurn: boolean, answerText: string): boolean { + return recalledThisTurn && answerText.trim().length > 0; +} + +// ---- composite sink ---- + +/** + * A CaptionSink that fans update()/noteEvent() to several sinks, so ONE subscribeActionEvents feed drives + * both the dev CaptionPanel and the floating SpeechBubble. noteEvent is forwarded only to sinks that + * implement it (the bubble); plain CaptionSinks (the CaptionPanel) are optional-chained past. + */ +export function compositeCaptionSink(...sinks: CaptionSink[]): TurnAwareCaptionSink { + return { + update(role, text, isFinal) { + for (const s of sinks) s.update(role, text, isFinal); + }, + noteEvent(e) { + for (const s of sinks) (s as Partial).noteEvent?.(e); + }, + }; +} + +// ---- the bubble ---- + +/** How long the fade-out animation runs before the island is fully hidden (mirrors the CSS). */ +const LEAVE_MS = 320; + +export interface SpeechBubbleDeps { + driver: CharacterDriver; + /** The live cat body rect (window-local CSS px) to tether beside — null when unavailable (jsdom / pre-mount). */ + getCatBounds?: () => Rect | null; +} + +/** + * The tethered speech bubble. Implements TurnAwareCaptionSink: + * - update('assistant', text, true): a COMPLETE answer → show it (+ the receipt row when the gate is open), + * drive setTalking(true), and schedule the length-scaled auto-fade. + * - update(…, false): ignored — streaming is driven precisely by noteEvent(message.delta); the unfired + * 'Thinking through it…' placeholder shows nothing (restraint). + * - noteEvent(e): run.started resets the recall gate + stream buffer; a recalled `status` opens the gate; + * message.delta tokens accumulate + render live. + * - a bare no-payload success (run.completed with no finalText, no message) never calls update() → the + * bubble stays hidden (the locked no-Done-banner contract). + */ +export class SpeechBubble implements TurnAwareCaptionSink { + private readonly island: HTMLElement; + private readonly textEl: HTMLElement; + private readonly receiptEl: HTMLElement; + + private turn: SpeechTurnState = initialSpeechTurnState(); + private streamBuffer = ''; + private visible = false; + + // Auto-fade dwell + hover pause/resume. + private fadeTimer: ReturnType | null = null; + private leaveTimer: ReturnType | null = null; + private fadeDeadline = 0; + private fadeRemaining = 0; + private hovering = false; + + constructor(private readonly deps: SpeechBubbleDeps) { + const island = document.createElement('div'); + island.id = 'roro-speech'; + island.setAttribute('role', 'status'); + island.setAttribute('aria-live', 'polite'); + + this.textEl = document.createElement('div'); + this.textEl.className = 'roro-speech__text'; + + this.receiptEl = document.createElement('div'); + this.receiptEl.className = 'roro-speech__receipt'; + this.receiptEl.hidden = true; + + island.append(this.textEl, this.receiptEl); + mountIsland(island); + this.island = island; + + island.addEventListener('mouseenter', this.onHoverEnter); + island.addEventListener('mouseleave', this.onHoverLeave); + } + + // ---- CaptionSink: the answer text ---- + update(role: 'user' | 'assistant', text: string, isFinal: boolean): void { + if (role !== 'assistant') return; // the bubble is the CAT's voice; the user's line lives in the input + if (!isFinal) return; // streaming rides noteEvent(message.delta); 'Thinking through it…' shows nothing + const answer = text.trim(); + if (!answer) { + this.clearNow(); + return; + } + this.streamBuffer = ''; + this.renderText(answer, receiptVisible(this.turn.recalledThisTurn, answer)); + this.show(); + this.scheduleFade(fadeDwellMs(answer)); + } + + // ---- turn-aware: recall gate + reset + live streaming ---- + noteEvent(e: ActionEvent): void { + this.turn = reduceSpeechTurn(this.turn, e); + if (e.kind === 'run.started') { + this.streamBuffer = ''; // fresh turn — drop any half-streamed prior content + return; + } + if (e.kind === 'message.delta' && e.text) { + this.streamBuffer += e.text; + this.renderText(this.streamBuffer, false); // no receipt mid-stream — only the settled final earns it + this.show(); + this.cancelFade(); // still streaming; the dwell starts only once the final arrives + } + } + + // ---- rendering ---- + private renderText(answer: string, withReceipt: boolean): void { + this.textEl.textContent = answer; + this.receiptEl.textContent = withReceipt ? MEMORY_RECEIPT_TEXT : ''; + this.receiptEl.hidden = !withReceipt; + } + + private show(): void { + // Any new content cancels an in-flight fade-out and (re)asserts the talking signal. + this.cancelLeave(); + const firstShow = !this.visible; + this.island.classList.remove('leaving'); + this.island.classList.add('shown'); + this.visible = true; + this.deps.driver.setTalking(true); + // Tether beside the cat on appearance + when the final settles the size. Streaming deltas grow the box + // in place (no per-token re-place) so a breathing cat can't jitter it token-by-token. + if (firstShow) this.placeBeside(); + } + + private placeBeside(): void { + const cat = this.deps.getCatBounds?.(); + if (cat) placeIsland(this.island, cat); + } + + // ---- auto-fade dwell (hover pauses; new content resets) ---- + private scheduleFade(ms: number): void { + this.cancelFade(); + this.placeBeside(); // final size settled — re-clamp against the edges + this.fadeRemaining = ms; + if (!this.hovering) this.startFadeTimer(ms); + } + + private startFadeTimer(ms: number): void { + this.fadeDeadline = Date.now() + ms; + this.fadeTimer = setTimeout(() => { + this.fadeTimer = null; + this.beginLeave(); + }, ms); + } + + private cancelFade(): void { + if (this.fadeTimer !== null) { + clearTimeout(this.fadeTimer); + this.fadeTimer = null; + } + } + + private readonly onHoverEnter = (): void => { + this.hovering = true; + if (this.fadeTimer !== null) { + // Pause: bank the remaining dwell and stop the clock. + this.fadeRemaining = Math.max(0, this.fadeDeadline - Date.now()); + this.cancelFade(); + } + }; + + private readonly onHoverLeave = (): void => { + this.hovering = false; + // Resume with the banked remainder (only while still shown and a dwell was pending). + if (this.visible && this.fadeTimer === null && this.leaveTimer === null && this.fadeRemaining > 0) { + this.startFadeTimer(this.fadeRemaining); + } + }; + + // ---- fade-out ---- + private beginLeave(): void { + this.deps.driver.setTalking(false); // the cat stops voicing as the bubble fades + this.island.classList.add('leaving'); + this.leaveTimer = setTimeout(() => { + this.leaveTimer = null; + this.hideNow(); + }, LEAVE_MS); + } + + private cancelLeave(): void { + if (this.leaveTimer !== null) { + clearTimeout(this.leaveTimer); + this.leaveTimer = null; + } + } + + private hideNow(): void { + this.island.classList.remove('shown', 'leaving'); + this.textEl.textContent = ''; + this.receiptEl.textContent = ''; + this.receiptEl.hidden = true; + this.streamBuffer = ''; + this.visible = false; + } + + /** Immediate clear (empty final / unmount): no fade animation. */ + private clearNow(): void { + this.cancelFade(); + this.cancelLeave(); + if (this.visible) this.deps.driver.setTalking(false); + this.hideNow(); + } + + dispose(): void { + this.island.removeEventListener('mouseenter', this.onHoverEnter); + this.island.removeEventListener('mouseleave', this.onHoverLeave); + this.clearNow(); + this.island.remove(); + } + + // ---- test/verify introspection ---- + /** Snapshot for the on-screen smoke + jsdom assertions. */ + debugState(): { visible: boolean; text: string; receipt: string; recalled: boolean } { + return { + visible: this.island.classList.contains('shown'), + text: this.textEl.textContent ?? '', + receipt: this.receiptEl.hidden ? '' : this.receiptEl.textContent ?? '', + recalled: this.turn.recalledThisTurn, + }; + } +} + +interface SpeechBubbleSmokeHook { + /** Push a raw ActionEvent through the SAME fan as production (applyCaptionEvent → noteEvent + finals). */ + feed(e: ActionEvent): void; + state(): ReturnType; +} + +function smokeWindow(): { __roroSpeechSmoke?: SpeechBubbleSmokeHook } { + return window as unknown as { __roroSpeechSmoke?: SpeechBubbleSmokeHook }; +} + +export interface SpeechBubbleHandle { + /** The bubble as a caption sink — compose it with the CaptionPanel and hand to subscribeActionEvents. */ + sink: SpeechBubble; + unmount: () => void; +} + +/** + * Mount the SpeechBubble as a stage island and (in smoke mode) expose the deterministic on-screen verify + * hook. Returns the bubble as a sink to compose into the caption fan. FLOATING mode only. + */ +export function mountSpeechBubble(opts: SpeechBubbleDeps & { smoke?: boolean }): SpeechBubbleHandle { + const bubble = new SpeechBubble(opts); + const hook: SpeechBubbleSmokeHook = { + feed: (e) => applyCaptionEvent(bubble, e), + state: () => bubble.debugState(), + }; + if (opts.smoke) smokeWindow().__roroSpeechSmoke = hook; + + return { + sink: bubble, + unmount: () => { + bubble.dispose(); + if (smokeWindow().__roroSpeechSmoke === hook) delete smokeWindow().__roroSpeechSmoke; + }, + }; +} From 423abc0aafa64ca2f000029c831c66f4a918f29f Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 04:42:43 -0700 Subject: [PATCH 2/7] fix(floating): scope the memory receipt by runId + exclude non-answers + hover-during-fade (codex S5 round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/reactions/speechBubble.test.ts | 86 +++++++++++++++------ src/renderer/reactions/speechBubble.ts | 46 +++++++---- 2 files changed, 93 insertions(+), 39 deletions(-) diff --git a/src/renderer/reactions/speechBubble.test.ts b/src/renderer/reactions/speechBubble.test.ts index 7a330ad..8172516 100644 --- a/src/renderer/reactions/speechBubble.test.ts +++ b/src/renderer/reactions/speechBubble.test.ts @@ -8,7 +8,7 @@ import { fadeDwellMs, reduceSpeechTurn, initialSpeechTurnState, - receiptVisible, + receiptForAnswerEvent, compositeCaptionSink, } from './speechBubble'; import { applyCaptionEvent } from '../events/actionEvents'; @@ -71,42 +71,46 @@ describe('speechBubble — pure fade-dwell computation', () => { }); describe('speechBubble — recalled gate (pure reducer)', () => { - it('starts closed', () => { - expect(initialSpeechTurnState().recalledThisTurn).toBe(false); + const answer = (runId = 'r'): ActionEvent => ({ kind: 'message', runId, text: 'here is the answer', ts: 0 }); + const completed = (runId = 'r'): ActionEvent => ({ kind: 'run.completed', runId, ok: true, finalText: 'done', ts: 0 }); + const stopped = (runId = 'r'): ActionEvent => ({ kind: 'run.failed', runId, ok: false, error: 'stopped by user', ts: 0 }); + + it('starts with no recalled run', () => { + expect(initialSpeechTurnState().recalledRunId).toBeNull(); }); - it('opens when a recalled status fires', () => { - const s = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); - expect(s.recalledThisTurn).toBe(true); + it('records the runId when a recalled status fires', () => { + expect(reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()).recalledRunId).toBe('r'); }); - it('stays closed for a not-recalled status (factCount 0, episodeCount 0)', () => { - const s = reduceSpeechTurn(initialSpeechTurnState(), statusNoRecall()); - expect(s.recalledThisTurn).toBe(false); + it('stays null for a not-recalled status (factCount 0, episodeCount 0)', () => { + expect(reduceSpeechTurn(initialSpeechTurnState(), statusNoRecall()).recalledRunId).toBeNull(); }); - it('opens when only episodes were recalled (facts 0, episodes >0)', () => { + it('records when only episodes were recalled (facts 0, episodes >0)', () => { const ev: ActionEvent = { kind: 'status', runId: 'r', text: formatMemoryStatus({ factCount: 0, episodeCount: 3 }), ts: 0 }; - expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledThisTurn).toBe(true); + expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledRunId).toBe('r'); }); - it('resets on run.started (new turn)', () => { + it('clears on run.started (a new turn has not recalled yet)', () => { const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); - expect(recalled.recalledThisTurn).toBe(true); - const reset = reduceSpeechTurn(recalled, runStarted()); - expect(reset.recalledThisTurn).toBe(false); + expect(recalled.recalledRunId).toBe('r'); + expect(reduceSpeechTurn(recalled, runStarted()).recalledRunId).toBeNull(); }); it('ignores unrelated status text', () => { const ev: ActionEvent = { kind: 'status', runId: 'r', text: 'Taking one screen snapshot.', ts: 0 }; - expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledThisTurn).toBe(false); + expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledRunId).toBeNull(); }); - it('receiptVisible needs BOTH recall AND answer text', () => { - expect(receiptVisible(true, 'an answer')).toBe(true); - expect(receiptVisible(true, ' ')).toBe(false); // no answer - expect(receiptVisible(false, 'an answer')).toBe(false); // not recalled — never (a lie) - expect(receiptVisible(false, '')).toBe(false); + it('receiptForAnswerEvent shows ONLY for a real answer in the SAME run that recalled', () => { + const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); // recalledRunId 'r' + expect(receiptForAnswerEvent(recalled, answer('r'))).toBe(true); // message answer, same run + expect(receiptForAnswerEvent(recalled, completed('r'))).toBe(true); // run.completed finalText, same run + expect(receiptForAnswerEvent(recalled, answer('other'))).toBe(false); // answer in a DIFFERENT run → no leak + expect(receiptForAnswerEvent(recalled, stopped('r'))).toBe(false); // stop is NOT a real answer → no receipt on "Stopped." + expect(receiptForAnswerEvent(initialSpeechTurnState(), answer('r'))).toBe(false); // no recall → never (a lie) + expect(receiptForAnswerEvent(recalled, { kind: 'message', runId: 'r', text: ' ', ts: 0 })).toBe(false); // empty answer }); }); @@ -197,14 +201,34 @@ describe('SpeechBubble — render + streaming + memory receipt', () => { it('appends the truthful receipt when a recalled status fired this turn + there is an answer', () => { bubble.noteEvent(runStarted()); bubble.noteEvent(statusRecalled()); - bubble.update('assistant', 'Your dev setup uses pnpm', true); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'Your dev setup uses pnpm', ts: 0 }); + expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + }); + + it('NEVER leaks the receipt to a LATER run that did not recall (runId-scoped)', () => { + // Turn A recalls memory and answers. + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer A', ts: 0 }); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + // Turn B (a DIFFERENT run) does NOT recall — its answer must carry no receipt. + applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r2', text: 'answer B, no recall', ts: 0 }); + expect(bubble.debugState().receipt).toBe(''); + }); + + it('does NOT attach the receipt to a "Stopped." acknowledgement after a recall', () => { + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, statusRecalled()); + // A stopped run.failed is forwarded to the sink as the 'Stopped.' line — NOT a real answer. + applyCaptionEvent(bubble, { kind: 'run.failed', runId: 'r', ok: false, error: 'stopped by user', ts: 0 }); + expect(bubble.debugState().receipt).toBe(''); }); it('NEVER shows the receipt when memory was not recalled (truthful)', () => { bubble.noteEvent(runStarted()); bubble.noteEvent(statusNoRecall()); - bubble.update('assistant', 'Here is a generic answer', true); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'Here is a generic answer', ts: 0 }); const st = bubble.debugState(); expect(st.visible).toBe(true); expect(st.receipt).toBe(''); @@ -214,11 +238,11 @@ describe('SpeechBubble — render + streaming + memory receipt', () => { // Turn 1: recalled. bubble.noteEvent(runStarted()); bubble.noteEvent(statusRecalled()); - bubble.update('assistant', 'answer one', true); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer one', ts: 0 }); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); // Turn 2: fresh, no recall. bubble.noteEvent(runStarted()); - bubble.update('assistant', 'answer two', true); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer two', ts: 0 }); expect(bubble.debugState().receipt).toBe(''); }); @@ -293,4 +317,16 @@ describe('SpeechBubble — auto-fade + hover pause', () => { vi.advanceTimersByTime(1 + 320); // dwell remainder + leave expect(bubble.debugState().visible).toBe(false); }); + + it('hovering DURING the fade-out cancels it — the bubble does not vanish under the cursor', () => { + bubble.update('assistant', 'Hi', true); // dwell 2500 + vi.advanceTimersByTime(2500); // dwell elapses → the 320ms fade-out begins + expect(island().classList.contains('leaving')).toBe(true); + vi.advanceTimersByTime(100); // 100ms into the fade-out + island().dispatchEvent(new MouseEvent('mouseenter')); // cursor enters mid-fade-out + vi.advanceTimersByTime(1000); // well past when the leave would have hidden it + expect(bubble.debugState().visible).toBe(true); // restored, not hidden under the cursor + expect(island().classList.contains('leaving')).toBe(false); + expect(driver.talking).toBe(true); + }); }); diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index f2694cf..7b7f5c8 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -43,34 +43,39 @@ export function fadeDwellMs(text: string): number { /** Per-turn memory-recall bookkeeping for the truthful receipt. */ export interface SpeechTurnState { - /** A `status` beat this turn indicated memory was actually RECALLED (factCount>0 || episodeCount>0). */ - recalledThisTurn: boolean; + /** The runId whose `status` beat indicated memory was actually RECALLED (factCount>0 || episodeCount>0), + * or null. Scoped BY runId so a recall in one turn can never attach the receipt to a later turn's answer. */ + recalledRunId: string | null; } -export const initialSpeechTurnState = (): SpeechTurnState => ({ recalledThisTurn: false }); +export const initialSpeechTurnState = (): SpeechTurnState => ({ recalledRunId: null }); /** - * Reduce one ActionEvent into the recall gate. run.started resets the flag (new turn); a `status` beat whose - * formatMemoryStatus indicates recall SETS it (reusing parseMemoryStatus — no parallel signal). Everything - * else is unchanged. Pure + exhaustive-friendly. + * Reduce one ActionEvent into the recall gate. run.started clears it (a fresh turn hasn't recalled yet); a + * `status` beat whose formatMemoryStatus indicates recall records THAT event's runId (reusing + * parseMemoryStatus — no parallel signal). Everything else is unchanged. Pure + exhaustive-friendly. */ export function reduceSpeechTurn(state: SpeechTurnState, e: ActionEvent): SpeechTurnState { - if (e.kind === 'run.started') return { recalledThisTurn: false }; + if (e.kind === 'run.started') return { recalledRunId: null }; if (e.kind === 'status') { const counts = parseMemoryStatus(e.text); if (counts && (counts.factCount > 0 || counts.episodeCount > 0)) { - return { recalledThisTurn: true }; + return { recalledRunId: e.runId }; } } return state; } /** - * The truthful-receipt gate: the "used what I remember" line shows IFF memory was recalled THIS turn AND - * there is a real answer to attach it to. No recall → never (a lie); no answer → nothing to receipt. + * The truthful-receipt gate. The "used what I remember" line shows IFF the event IS a real answer (a `message` + * with text, or a `run.completed` with finalText — NOT a 'Stopped.'/failed acknowledgement) AND memory was + * recalled in the SAME run as that answer. Wrong run, no recall, or a non-answer terminal → never (a lie). */ -export function receiptVisible(recalledThisTurn: boolean, answerText: string): boolean { - return recalledThisTurn && answerText.trim().length > 0; +export function receiptForAnswerEvent(state: SpeechTurnState, e: ActionEvent): boolean { + const realAnswer = + (e.kind === 'message' && !!e.text && e.text.trim().length > 0) || + (e.kind === 'run.completed' && !!e.finalText && e.finalText.trim().length > 0); + return realAnswer && state.recalledRunId !== null && state.recalledRunId === e.runId; } // ---- composite sink ---- @@ -119,6 +124,7 @@ export class SpeechBubble implements TurnAwareCaptionSink { private readonly receiptEl: HTMLElement; private turn: SpeechTurnState = initialSpeechTurnState(); + private pendingReceipt = false; // receipt eligibility for the answer event currently flowing (set by noteEvent) private streamBuffer = ''; private visible = false; @@ -160,7 +166,7 @@ export class SpeechBubble implements TurnAwareCaptionSink { return; } this.streamBuffer = ''; - this.renderText(answer, receiptVisible(this.turn.recalledThisTurn, answer)); + this.renderText(answer, this.pendingReceipt); this.show(); this.scheduleFade(fadeDwellMs(answer)); } @@ -168,6 +174,10 @@ export class SpeechBubble implements TurnAwareCaptionSink { // ---- turn-aware: recall gate + reset + live streaming ---- noteEvent(e: ActionEvent): void { this.turn = reduceSpeechTurn(this.turn, e); + // Decide receipt eligibility HERE, where we still have the answer event's kind + runId — update() only + // gets (role, text, final). applyCaptionEvent always calls noteEvent(e) before update() for the same e, + // so this is fresh when update() renders. Non-answer terminals (Stopped./failed) → false. + this.pendingReceipt = receiptForAnswerEvent(this.turn, e); if (e.kind === 'run.started') { this.streamBuffer = ''; // fresh turn — drop any half-streamed prior content return; @@ -234,6 +244,14 @@ export class SpeechBubble implements TurnAwareCaptionSink { // Pause: bank the remaining dwell and stop the clock. this.fadeRemaining = Math.max(0, this.fadeDeadline - Date.now()); this.cancelFade(); + } else if (this.leaveTimer !== null) { + // The cursor entered DURING the fade-OUT: cancel it and restore the bubble to shown so it can't vanish + // under the cursor. Re-bank a full dwell (from the shown text) to resume once the cursor leaves. + this.cancelLeave(); + this.island.classList.remove('leaving'); + this.visible = true; + this.deps.driver.setTalking(true); + this.fadeRemaining = fadeDwellMs(this.textEl.textContent ?? ''); } }; @@ -293,7 +311,7 @@ export class SpeechBubble implements TurnAwareCaptionSink { visible: this.island.classList.contains('shown'), text: this.textEl.textContent ?? '', receipt: this.receiptEl.hidden ? '' : this.receiptEl.textContent ?? '', - recalled: this.turn.recalledThisTurn, + recalled: this.turn.recalledRunId !== null, }; } } From 7f9371c6c88ee16082ffb3daef3206c4d441ba94 Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 04:49:37 -0700 Subject: [PATCH 3/7] fix(floating): clear the speech bubble at turn boundaries (codex S5 round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/reactions/speechBubble.test.ts | 23 +++++++++++++++++++++ src/renderer/reactions/speechBubble.ts | 11 +++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/renderer/reactions/speechBubble.test.ts b/src/renderer/reactions/speechBubble.test.ts index 8172516..2635bb9 100644 --- a/src/renderer/reactions/speechBubble.test.ts +++ b/src/renderer/reactions/speechBubble.test.ts @@ -225,6 +225,29 @@ describe('SpeechBubble — render + streaming + memory receipt', () => { expect(bubble.debugState().receipt).toBe(''); }); + it('a new run.started clears a still-visible prior receipt (no cross-turn linger)', () => { + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer A', ts: 0 }); + expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + // Turn B begins before A's bubble faded — the stale answer + receipt must clear immediately. + applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); + const st = bubble.debugState(); + expect(st.visible).toBe(false); + expect(st.text).toBe(''); + expect(st.receipt).toBe(''); + }); + + it('a failing run clears a still-visible prior receipt (no stale receipt over a failure)', () => { + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer A', ts: 0 }); + expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + applyCaptionEvent(bubble, { kind: 'run.failed', runId: 'r2', ok: false, error: 'boom' , ts: 0 }); + expect(bubble.debugState().visible).toBe(false); + expect(bubble.debugState().receipt).toBe(''); + }); + it('NEVER shows the receipt when memory was not recalled (truthful)', () => { bubble.noteEvent(runStarted()); bubble.noteEvent(statusNoRecall()); diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index 7b7f5c8..283736d 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -179,7 +179,16 @@ export class SpeechBubble implements TurnAwareCaptionSink { // so this is fresh when update() renders. Non-answer terminals (Stopped./failed) → false. this.pendingReceipt = receiptForAnswerEvent(this.turn, e); if (e.kind === 'run.started') { - this.streamBuffer = ''; // fresh turn — drop any half-streamed prior content + // A NEW turn begins — clear the prior turn's answer + receipt outright (not just the stream buffer), so + // a still-visible receipt from a recalled turn can never linger into / be mis-attributed to this turn. + this.clearNow(); + return; + } + if (e.kind === 'run.failed') { + // A failed turn produces no answer (the AlertCard shows the error) — drop any stale bubble so a prior + // receipt can't survive a failing run. A STOPPED failure still forwards 'Stopped.' via update() right + // after this, which repopulates the (now-clean) bubble; a generic failure leaves it empty. + this.clearNow(); return; } if (e.kind === 'message.delta' && e.text) { From 5d6b7096e66b55d264525a46d64a2e0d7fd9fc73 Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 05:10:12 -0700 Subject: [PATCH 4/7] fix(floating): receipt only on run.completed.finalText + keep recall through the executor run.started (codex S5 round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/reactions/speechBubble.test.ts | 80 +++++++++++---------- src/renderer/reactions/speechBubble.ts | 24 ++++--- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/src/renderer/reactions/speechBubble.test.ts b/src/renderer/reactions/speechBubble.test.ts index 2635bb9..d5d570e 100644 --- a/src/renderer/reactions/speechBubble.test.ts +++ b/src/renderer/reactions/speechBubble.test.ts @@ -92,10 +92,10 @@ describe('speechBubble — recalled gate (pure reducer)', () => { expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledRunId).toBe('r'); }); - it('clears on run.started (a new turn has not recalled yet)', () => { - const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); - expect(recalled.recalledRunId).toBe('r'); - expect(reduceSpeechTurn(recalled, runStarted()).recalledRunId).toBeNull(); + it('KEEPS the recall through the executor run.started (same runId) but CLEARS it for a new-runId turn', () => { + const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); // recalledRunId 'r' + expect(reduceSpeechTurn(recalled, runStarted()).recalledRunId).toBe('r'); // executor run.started, same run → kept + expect(reduceSpeechTurn(recalled, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }).recalledRunId).toBeNull(); // new turn → cleared }); it('ignores unrelated status text', () => { @@ -103,14 +103,14 @@ describe('speechBubble — recalled gate (pure reducer)', () => { expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledRunId).toBeNull(); }); - it('receiptForAnswerEvent shows ONLY for a real answer in the SAME run that recalled', () => { + it('receiptForAnswerEvent shows ONLY on run.completed.finalText in the SAME run that recalled', () => { const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); // recalledRunId 'r' - expect(receiptForAnswerEvent(recalled, answer('r'))).toBe(true); // message answer, same run - expect(receiptForAnswerEvent(recalled, completed('r'))).toBe(true); // run.completed finalText, same run - expect(receiptForAnswerEvent(recalled, answer('other'))).toBe(false); // answer in a DIFFERENT run → no leak - expect(receiptForAnswerEvent(recalled, stopped('r'))).toBe(false); // stop is NOT a real answer → no receipt on "Stopped." - expect(receiptForAnswerEvent(initialSpeechTurnState(), answer('r'))).toBe(false); // no recall → never (a lie) - expect(receiptForAnswerEvent(recalled, { kind: 'message', runId: 'r', text: ' ', ts: 0 })).toBe(false); // empty answer + expect(receiptForAnswerEvent(recalled, completed('r'))).toBe(true); // executor answer (finalText), same run + expect(receiptForAnswerEvent(recalled, answer('r'))).toBe(false); // a bare `message` (planning OR answer-turn) is NEVER receipted — can't tell planning apart + expect(receiptForAnswerEvent(recalled, completed('other'))).toBe(false); // answer in a DIFFERENT run → no leak + expect(receiptForAnswerEvent(recalled, stopped('r'))).toBe(false); // stop is NOT an answer → no receipt on "Stopped." + expect(receiptForAnswerEvent(initialSpeechTurnState(), completed('r'))).toBe(false); // no recall → never (a lie) + expect(receiptForAnswerEvent(recalled, { kind: 'run.completed', runId: 'r', ok: true, finalText: ' ', ts: 0 })).toBe(false); // empty finalText }); }); @@ -198,37 +198,45 @@ describe('SpeechBubble — render + streaming + memory receipt', () => { expect(bubble.debugState().text).toBe('Hello there, friend'); }); - it('appends the truthful receipt when a recalled status fired this turn + there is an answer', () => { - bubble.noteEvent(runStarted()); - bubble.noteEvent(statusRecalled()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'Your dev setup uses pnpm', ts: 0 }); + it('appends the truthful receipt on the executor answer of a recalled turn', () => { + // Real executor sequence: recall status → executor run.started (SAME run) → run.completed(finalText). + applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, runStarted()); // executor run.started, same runId 'r' — recall must survive it + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'Ran the Vitest suite', ts: 0 }); + expect(bubble.debugState().text).toBe('Ran the Vitest suite'); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); }); + it('a bare `message` (planning beat / answer-turn narration) NEVER carries the receipt', () => { + applyCaptionEvent(bubble, statusRecalled()); + // The orchestrator emits the planning beat as kind:'message' — it must NOT earn the receipt. + applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'qwen is planning the task…', ts: 0 }); + expect(bubble.debugState().receipt).toBe(''); + }); + it('NEVER leaks the receipt to a LATER run that did not recall (runId-scoped)', () => { - // Turn A recalls memory and answers. - applyCaptionEvent(bubble, runStarted()); applyCaptionEvent(bubble, statusRecalled()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer A', ts: 0 }); + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'answer A', ts: 0 }); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); // Turn B (a DIFFERENT run) does NOT recall — its answer must carry no receipt. applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r2', text: 'answer B, no recall', ts: 0 }); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r2', ok: true, finalText: 'answer B, no recall', ts: 0 }); expect(bubble.debugState().receipt).toBe(''); }); it('does NOT attach the receipt to a "Stopped." acknowledgement after a recall', () => { - applyCaptionEvent(bubble, runStarted()); applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, runStarted()); // A stopped run.failed is forwarded to the sink as the 'Stopped.' line — NOT a real answer. applyCaptionEvent(bubble, { kind: 'run.failed', runId: 'r', ok: false, error: 'stopped by user', ts: 0 }); expect(bubble.debugState().receipt).toBe(''); }); it('a new run.started clears a still-visible prior receipt (no cross-turn linger)', () => { - applyCaptionEvent(bubble, runStarted()); applyCaptionEvent(bubble, statusRecalled()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer A', ts: 0 }); + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'answer A', ts: 0 }); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); // Turn B begins before A's bubble faded — the stale answer + receipt must clear immediately. applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); @@ -239,33 +247,33 @@ describe('SpeechBubble — render + streaming + memory receipt', () => { }); it('a failing run clears a still-visible prior receipt (no stale receipt over a failure)', () => { - applyCaptionEvent(bubble, runStarted()); applyCaptionEvent(bubble, statusRecalled()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer A', ts: 0 }); + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'answer A', ts: 0 }); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); - applyCaptionEvent(bubble, { kind: 'run.failed', runId: 'r2', ok: false, error: 'boom' , ts: 0 }); + applyCaptionEvent(bubble, { kind: 'run.failed', runId: 'r2', ok: false, error: 'boom', ts: 0 }); expect(bubble.debugState().visible).toBe(false); expect(bubble.debugState().receipt).toBe(''); }); it('NEVER shows the receipt when memory was not recalled (truthful)', () => { - bubble.noteEvent(runStarted()); - bubble.noteEvent(statusNoRecall()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'Here is a generic answer', ts: 0 }); + applyCaptionEvent(bubble, statusNoRecall()); + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'Here is a generic answer', ts: 0 }); const st = bubble.debugState(); expect(st.visible).toBe(true); expect(st.receipt).toBe(''); }); - it('resets the recall flag on run.started so a later non-recall turn shows NO receipt', () => { - // Turn 1: recalled. - bubble.noteEvent(runStarted()); - bubble.noteEvent(statusRecalled()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer one', ts: 0 }); + it('a genuinely new turn (new runId) that did not recall shows NO receipt', () => { + // Turn 1: recalled executor answer. + applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'answer one', ts: 0 }); expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); - // Turn 2: fresh, no recall. - bubble.noteEvent(runStarted()); - applyCaptionEvent(bubble, { kind: 'message', runId: 'r', text: 'answer two', ts: 0 }); + // Turn 2: a fresh run that does not recall. + applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r2', ok: true, finalText: 'answer two', ts: 0 }); expect(bubble.debugState().receipt).toBe(''); }); diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index 283736d..9fada01 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -56,7 +56,13 @@ export const initialSpeechTurnState = (): SpeechTurnState => ({ recalledRunId: n * parseMemoryStatus — no parallel signal). Everything else is unchanged. Pure + exhaustive-friendly. */ export function reduceSpeechTurn(state: SpeechTurnState, e: ActionEvent): SpeechTurnState { - if (e.kind === 'run.started') return { recalledRunId: null }; + if (e.kind === 'run.started') { + // The EXECUTOR's run.started reuses the TURN's runId — and recall already ran for that turn before it + // (orchestrator: recall → planning beat → executor run.started, all one runId). So a run.started for the + // SAME run must NOT clear the recall, or the turn's run.completed answer would lose the receipt. Only a + // DIFFERENT runId is a genuinely new turn → clear. + return e.runId === state.recalledRunId ? state : { recalledRunId: null }; + } if (e.kind === 'status') { const counts = parseMemoryStatus(e.text); if (counts && (counts.factCount > 0 || counts.episodeCount > 0)) { @@ -67,15 +73,17 @@ export function reduceSpeechTurn(state: SpeechTurnState, e: ActionEvent): Speech } /** - * The truthful-receipt gate. The "used what I remember" line shows IFF the event IS a real answer (a `message` - * with text, or a `run.completed` with finalText — NOT a 'Stopped.'/failed acknowledgement) AND memory was - * recalled in the SAME run as that answer. Wrong run, no recall, or a non-answer terminal → never (a lie). + * The truthful-receipt gate. The "used what I remember" line shows IFF the event is the turn's DEFINITIVE + * answer — `run.completed.finalText` — AND memory was recalled in the SAME run. It deliberately does NOT + * receipt a bare `message`: the orchestrator emits `message` BOTH for the brain's PLANNING beat on executor + * turns ("…is planning the task…", before the answer) AND for answer-turn narration, and the two are + * structurally identical — so receipting any `message` would risk claiming recall under a planning line (a + * lie). We under-claim (no receipt on a pure answer-turn) rather than ever over-claim. Non-answer terminals + * (Stopped./failed) and a wrong-run answer → never. */ export function receiptForAnswerEvent(state: SpeechTurnState, e: ActionEvent): boolean { - const realAnswer = - (e.kind === 'message' && !!e.text && e.text.trim().length > 0) || - (e.kind === 'run.completed' && !!e.finalText && e.finalText.trim().length > 0); - return realAnswer && state.recalledRunId !== null && state.recalledRunId === e.runId; + const definitiveAnswer = e.kind === 'run.completed' && !!e.finalText && e.finalText.trim().length > 0; + return definitiveAnswer && state.recalledRunId !== null && state.recalledRunId === e.runId; } // ---- composite sink ---- From 32b8fea6779767b5d7ffe48046469a28004341ce Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 05:15:41 -0700 Subject: [PATCH 5/7] docs(floating): sync speechBubble contract comment to keep-recall-through-run.started (codex S5 round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/reactions/speechBubble.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index 9fada01..dff48a1 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -121,8 +121,10 @@ export interface SpeechBubbleDeps { * drive setTalking(true), and schedule the length-scaled auto-fade. * - update(…, false): ignored — streaming is driven precisely by noteEvent(message.delta); the unfired * 'Thinking through it…' placeholder shows nothing (restraint). - * - noteEvent(e): run.started resets the recall gate + stream buffer; a recalled `status` opens the gate; - * message.delta tokens accumulate + render live. + * - noteEvent(e): a recalled `status` records the recalling runId; the executor `run.started` (same runId) + * KEEPS that recall (recall ran before it) and clears the visible bubble, while a NEW-runId run.started — + * or a run.failed — clears both the bubble and (for a new run) the recall; message.delta tokens accumulate + * + render live. The receipt attaches only to run.completed.finalText in the recalling run. * - a bare no-payload success (run.completed with no finalText, no message) never calls update() → the * bubble stays hidden (the locked no-Done-banner contract). */ From de5f97699c860a147281c220b005c64421005281 Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 05:20:35 -0700 Subject: [PATCH 6/7] fix(floating): reset the hover latch when the bubble hides (codex S5 round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/reactions/speechBubble.test.ts | 16 ++++++++++++++++ src/renderer/reactions/speechBubble.ts | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/src/renderer/reactions/speechBubble.test.ts b/src/renderer/reactions/speechBubble.test.ts index d5d570e..ba59085 100644 --- a/src/renderer/reactions/speechBubble.test.ts +++ b/src/renderer/reactions/speechBubble.test.ts @@ -360,4 +360,20 @@ describe('SpeechBubble — auto-fade + hover pause', () => { expect(island().classList.contains('leaving')).toBe(false); expect(driver.talking).toBe(true); }); + + it('resets the hover latch when a run clears the bubble mid-hover, so the next answer still fades', () => { + bubble.update('assistant', 'Turn A answer', true); + island().dispatchEvent(new MouseEvent('mouseenter')); // hovering = true → fade paused + vi.advanceTimersByTime(10000); + expect(bubble.debugState().visible).toBe(true); // paused, still up + // A new run starts while the cursor is over the bubble — clearNow() hides it; no mouseleave fires once + // it is display:none, so `hovering` must be reset here or the next answer would never fade. + applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); + expect(bubble.debugState().visible).toBe(false); + // Turn B answers — its fade MUST start despite the earlier hover. + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r2', ok: true, finalText: 'Turn B answer', ts: 0 }); + expect(bubble.debugState().visible).toBe(true); + vi.advanceTimersByTime(2500 + 320); // dwell + leave + expect(bubble.debugState().visible).toBe(false); // faded, not stuck visible forever + }); }); diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index dff48a1..9e8a9e3 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -306,6 +306,11 @@ export class SpeechBubble implements TurnAwareCaptionSink { this.receiptEl.hidden = true; this.streamBuffer = ''; this.visible = false; + // Reset the hover latch: a hidden (display:none) bubble emits no mouseleave, so a run.started/failed clear + // while the cursor is over it would otherwise leave `hovering` stuck true — and the NEXT answer would enter + // scheduleFade() with hovering=true and never start its fade timer (stuck visible forever). A real re-hover + // fires a fresh mouseenter. + this.hovering = false; } /** Immediate clear (empty final / unmount): no fade animation. */ From bb31cc6d8fd99e0b69e26ce4c759cb08c90cdaa8 Mon Sep 17 00:00:00 2001 From: Jin Choi Date: Sat, 4 Jul 2026 05:31:33 -0700 Subject: [PATCH 7/7] fix(floating): clear on runId change + reset banked dwell (codex S5 round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/reactions/speechBubble.test.ts | 26 +++++++++++++++++++++ src/renderer/reactions/speechBubble.ts | 20 +++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/renderer/reactions/speechBubble.test.ts b/src/renderer/reactions/speechBubble.test.ts index ba59085..e16fd7e 100644 --- a/src/renderer/reactions/speechBubble.test.ts +++ b/src/renderer/reactions/speechBubble.test.ts @@ -256,6 +256,21 @@ describe('SpeechBubble — render + streaming + memory receipt', () => { expect(bubble.debugState().receipt).toBe(''); }); + it('clears a prior receipt when a new NON-executor (answer) turn starts — no run.started needed', () => { + // Turn A: executor answer with the receipt. + applyCaptionEvent(bubble, statusRecalled()); + applyCaptionEvent(bubble, runStarted()); + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'answer A', ts: 0 }); + expect(bubble.debugState().receipt).toBe(MEMORY_RECEIPT_TEXT); + // Turn B is an ANSWER turn (no executor run.started/turn.started). Its FIRST event is a memory status with + // a NEW runId — the runId change alone must clear the prior turn's answer + receipt. + applyCaptionEvent(bubble, { kind: 'status', runId: 'r2', text: formatMemoryStatus({ factCount: 0, episodeCount: 0 }), ts: 0 }); + const st = bubble.debugState(); + expect(st.visible).toBe(false); + expect(st.text).toBe(''); + expect(st.receipt).toBe(''); + }); + it('NEVER shows the receipt when memory was not recalled (truthful)', () => { applyCaptionEvent(bubble, statusNoRecall()); applyCaptionEvent(bubble, runStarted()); @@ -376,4 +391,15 @@ describe('SpeechBubble — auto-fade + hover pause', () => { vi.advanceTimersByTime(2500 + 320); // dwell + leave expect(bubble.debugState().visible).toBe(false); // faded, not stuck visible forever }); + + it('a run-clear + new streaming answer does not resume the prior turn banked dwell (live tokens do not fade early)', () => { + applyCaptionEvent(bubble, { kind: 'run.completed', runId: 'r', ok: true, finalText: 'prior answer', ts: 0 }); // banks a dwell + applyCaptionEvent(bubble, { kind: 'run.started', agent: 'claude', runId: 'r2', ts: 0 }); // new turn → clearNow (dwell dropped) + applyCaptionEvent(bubble, { kind: 'message.delta', runId: 'r2', text: 'new answer streaming', ts: 0 }); + expect(bubble.debugState().visible).toBe(true); + island().dispatchEvent(new MouseEvent('mouseenter')); + island().dispatchEvent(new MouseEvent('mouseleave')); // mid-stream hover-leave must NOT resume the stale dwell + vi.advanceTimersByTime(10000); + expect(bubble.debugState().visible).toBe(true); // still streaming (no final yet) → no early fade + }); }); diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index 9e8a9e3..c459b08 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -135,6 +135,7 @@ export class SpeechBubble implements TurnAwareCaptionSink { private turn: SpeechTurnState = initialSpeechTurnState(); private pendingReceipt = false; // receipt eligibility for the answer event currently flowing (set by noteEvent) + private shownTurnRunId: string | null = null; // the runId of the turn whose content is/was in the bubble private streamBuffer = ''; private visible = false; @@ -183,21 +184,26 @@ export class SpeechBubble implements TurnAwareCaptionSink { // ---- turn-aware: recall gate + reset + live streaming ---- noteEvent(e: ActionEvent): void { + // A runId CHANGE is the ONLY universal new-turn boundary: executor turns fire run.started/turn.started + // (both executor-only), but answer/clarify turns fire NEITHER — so keying the clear off those alone would + // let a prior turn's answer + receipt linger into a new non-executor turn. Clear on any runId change. + if (this.shownTurnRunId !== null && e.runId !== this.shownTurnRunId) this.clearNow(); + this.shownTurnRunId = e.runId; + this.turn = reduceSpeechTurn(this.turn, e); // Decide receipt eligibility HERE, where we still have the answer event's kind + runId — update() only // gets (role, text, final). applyCaptionEvent always calls noteEvent(e) before update() for the same e, // so this is fresh when update() renders. Non-answer terminals (Stopped./failed) → false. this.pendingReceipt = receiptForAnswerEvent(this.turn, e); if (e.kind === 'run.started') { - // A NEW turn begins — clear the prior turn's answer + receipt outright (not just the stream buffer), so - // a still-visible receipt from a recalled turn can never linger into / be mis-attributed to this turn. + // Same-turn executor start: drop the brain's PLANNING narration so the bubble is clean for the answer + // (the WorkingChip carries activity during the run; run.completed.finalText repopulates the answer). this.clearNow(); return; } if (e.kind === 'run.failed') { - // A failed turn produces no answer (the AlertCard shows the error) — drop any stale bubble so a prior - // receipt can't survive a failing run. A STOPPED failure still forwards 'Stopped.' via update() right - // after this, which repopulates the (now-clean) bubble; a generic failure leaves it empty. + // A failed turn produces no answer (the AlertCard shows the error) — drop any stale bubble. A STOPPED + // failure still forwards 'Stopped.' via update() right after this, which repopulates the clean bubble. this.clearNow(); return; } @@ -206,6 +212,7 @@ export class SpeechBubble implements TurnAwareCaptionSink { this.renderText(this.streamBuffer, false); // no receipt mid-stream — only the settled final earns it this.show(); this.cancelFade(); // still streaming; the dwell starts only once the final arrives + this.fadeRemaining = 0; // no banked dwell mid-stream, or a hover-leave could fade live tokens early } } @@ -309,8 +316,9 @@ export class SpeechBubble implements TurnAwareCaptionSink { // Reset the hover latch: a hidden (display:none) bubble emits no mouseleave, so a run.started/failed clear // while the cursor is over it would otherwise leave `hovering` stuck true — and the NEXT answer would enter // scheduleFade() with hovering=true and never start its fade timer (stuck visible forever). A real re-hover - // fires a fresh mouseenter. + // fires a fresh mouseenter. Also drop any banked dwell so a later hover-leave can't resume a stale timer. this.hovering = false; + this.fadeRemaining = 0; } /** Immediate clear (empty final / unmount): no fade animation. */