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..e16fd7e --- /dev/null +++ b/src/renderer/reactions/speechBubble.test.ts @@ -0,0 +1,405 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + SpeechBubble, + MEMORY_RECEIPT_TEXT, + clamp, + wordCount, + fadeDwellMs, + reduceSpeechTurn, + initialSpeechTurnState, + receiptForAnswerEvent, + 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)', () => { + 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('records the runId when a recalled status fires', () => { + expect(reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()).recalledRunId).toBe('r'); + }); + + it('stays null for a not-recalled status (factCount 0, episodeCount 0)', () => { + expect(reduceSpeechTurn(initialSpeechTurnState(), statusNoRecall()).recalledRunId).toBeNull(); + }); + + 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).recalledRunId).toBe('r'); + }); + + 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', () => { + const ev: ActionEvent = { kind: 'status', runId: 'r', text: 'Taking one screen snapshot.', ts: 0 }; + expect(reduceSpeechTurn(initialSpeechTurnState(), ev).recalledRunId).toBeNull(); + }); + + it('receiptForAnswerEvent shows ONLY on run.completed.finalText in the SAME run that recalled', () => { + const recalled = reduceSpeechTurn(initialSpeechTurnState(), statusRecalled()); // recalledRunId 'r' + 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 + }); +}); + +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 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)', () => { + 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 (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: '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, 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, 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 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, 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); + 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('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()); + 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('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: 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(''); + }); + + 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); + }); + + 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); + }); + + 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 + }); + + 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 new file mode 100644 index 0000000..c459b08 --- /dev/null +++ b/src/renderer/reactions/speechBubble.ts @@ -0,0 +1,386 @@ +// 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 { + /** 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 => ({ recalledRunId: null }); + +/** + * 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') { + // 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)) { + return { recalledRunId: e.runId }; + } + } + return state; +} + +/** + * 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 definitiveAnswer = e.kind === 'run.completed' && !!e.finalText && e.finalText.trim().length > 0; + return definitiveAnswer && state.recalledRunId !== null && state.recalledRunId === e.runId; +} + +// ---- 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): 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). + */ +export class SpeechBubble implements TurnAwareCaptionSink { + private readonly island: HTMLElement; + private readonly textEl: HTMLElement; + private readonly receiptEl: HTMLElement; + + 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; + + // 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, this.pendingReceipt); + this.show(); + this.scheduleFade(fadeDwellMs(answer)); + } + + // ---- 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') { + // 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. A STOPPED + // failure still forwards 'Stopped.' via update() right after this, which repopulates the clean bubble. + this.clearNow(); + 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 + this.fadeRemaining = 0; // no banked dwell mid-stream, or a hover-leave could fade live tokens early + } + } + + // ---- 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(); + } 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 ?? ''); + } + }; + + 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; + // 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. 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. */ + 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.recalledRunId !== null, + }; + } +} + +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; + }, + }; +}