Skip to content
49 changes: 49 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
Expand Down Expand Up @@ -60,7 +62,20 @@ export async function bootstrap(): Promise<void> {
// 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),
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/character/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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;
}
39 changes: 28 additions & 11 deletions src/renderer/events/actionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<TurnAwareCaptionSink>).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> = [];
Expand All @@ -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 {
Expand Down
Loading
Loading