diff --git a/docs/bestie-voice.md b/docs/bestie-voice.md index 99ec1a89..22b1dda4 100644 --- a/docs/bestie-voice.md +++ b/docs/bestie-voice.md @@ -1,8 +1,20 @@ # Bestie realtime voice Open Bestie from its companion launcher anywhere the app offers the panel, then -click the headphones button. Speak naturally and interrupt a reply by speaking. -The panel shows transcripts, microphone mute, thinking level and tool approval. +click Call at the bottom of the panel. Speak naturally and interrupt a reply by speaking. +The ellipsis menu beside Close contains thinking level and tool approval settings. +During a call, the bottom controls show mute, live microphone activity and hang-up; +Bestie gently nods and tilts in response to its audio playback, changing its lean +after speech pauses and settling when quiet. The microphone +waveform uses monochrome bars and a mirrored layer at 20% opacity. Call controls +fade into a stable footer when starting, with immediate keyboard transitions. Reduced motion keeps +both indicators still while their opacity responds to audio. Transcripts are hidden +by default. Toggle **Show transcript** in the ellipsis menu at any time to reveal +or hide the current conversation using the Messages app’s message rows. When shown, +the space above the call controls splits evenly between Bestie and a separate +transcript scroll area. Messages soften into a subtle blur and fade at its top edge. Hiding +the transcript does not erase it or restart the call. The choice lasts while +Bestie is enabled in this app session. Tool approval requests remain visible. Closing the panel, changing community/account, disabling Bestie or disconnecting the relay ends the call. Immediate panel relocation retains the same call. diff --git a/docs/review/bestie-voice/README.md b/docs/review/bestie-voice/README.md new file mode 100644 index 00000000..bb019c93 --- /dev/null +++ b/docs/review/bestie-voice/README.md @@ -0,0 +1,47 @@ +# Bestie voice review screenshots + +Captured from the real Bestie components in Chromium using isolated identities, synthetic audio, and fixture transcript text. These are UI states, not recordings of a live model conversation. + +## idle + +![idle](idle.png) + +## settings + +![settings](settings.png) + +## connecting + +![connecting](connecting.png) + +## listening + +![listening](listening.png) + +## speaking + +![speaking](speaking.png) + +## muted + +![muted](muted.png) + +## transcript + +![transcript](transcript.png) + +## transcript-dark + +![transcript-dark](transcript-dark.png) + +## transcript-compact + +![transcript-compact](transcript-compact.png) + +Reproduce with: + +```sh +BESTIE_REVIEW_SCREENSHOTS=1 bin/pnpm exec playwright test --config tests/browser/playwright.config.mjs bestie.spec.mjs --project chromium --project webkit --no-deps +``` + +Captures are written under `test-results/browser/`. diff --git a/docs/review/bestie-voice/connecting.png b/docs/review/bestie-voice/connecting.png new file mode 100644 index 00000000..dc45b4b0 Binary files /dev/null and b/docs/review/bestie-voice/connecting.png differ diff --git a/docs/review/bestie-voice/idle.png b/docs/review/bestie-voice/idle.png new file mode 100644 index 00000000..14bf38c1 Binary files /dev/null and b/docs/review/bestie-voice/idle.png differ diff --git a/docs/review/bestie-voice/listening.png b/docs/review/bestie-voice/listening.png new file mode 100644 index 00000000..434baf9c Binary files /dev/null and b/docs/review/bestie-voice/listening.png differ diff --git a/docs/review/bestie-voice/muted.png b/docs/review/bestie-voice/muted.png new file mode 100644 index 00000000..1ce97c82 Binary files /dev/null and b/docs/review/bestie-voice/muted.png differ diff --git a/docs/review/bestie-voice/settings.png b/docs/review/bestie-voice/settings.png new file mode 100644 index 00000000..9a9495c4 Binary files /dev/null and b/docs/review/bestie-voice/settings.png differ diff --git a/docs/review/bestie-voice/speaking.png b/docs/review/bestie-voice/speaking.png new file mode 100644 index 00000000..6b289513 Binary files /dev/null and b/docs/review/bestie-voice/speaking.png differ diff --git a/docs/review/bestie-voice/transcript-compact.png b/docs/review/bestie-voice/transcript-compact.png new file mode 100644 index 00000000..0a9688b4 Binary files /dev/null and b/docs/review/bestie-voice/transcript-compact.png differ diff --git a/docs/review/bestie-voice/transcript-dark.png b/docs/review/bestie-voice/transcript-dark.png new file mode 100644 index 00000000..b9a19fd9 Binary files /dev/null and b/docs/review/bestie-voice/transcript-dark.png differ diff --git a/docs/review/bestie-voice/transcript.png b/docs/review/bestie-voice/transcript.png new file mode 100644 index 00000000..b0dc0761 Binary files /dev/null and b/docs/review/bestie-voice/transcript.png differ diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 1f5816b0..54472a93 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -76,7 +76,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", renderToStaticMarkup( createElement(firstBestie.component, { target: "", close() {} }), ), - /Voice is available/, + /Voice isn’t available in this app\./, ); await services.plugins.change("disable", "buzz.bestie"); assert.equal( diff --git a/src/bundled/bestie/Bestie.module.css b/src/bundled/bestie/Bestie.module.css new file mode 100644 index 00000000..50f6ab9f --- /dev/null +++ b/src/bundled/bestie/Bestie.module.css @@ -0,0 +1,255 @@ +.panel { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + color: var(--text-primary); +} +.body { + display: grid; + flex: 1; + grid-template-rows: minmax(0, 1fr); + min-height: 0; + overflow: hidden; + padding: 0 24px; +} +.body[data-transcript="true"] { + grid-template-rows: repeat(2, minmax(0, 1fr)); +} +.identity { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + min-height: 0; + padding: 24px 0 16px; +} +.avatar { + position: relative; + display: grid; + place-items: center; + width: 176px; + height: 176px; + flex-shrink: 1; + min-height: 0; +} +.avatar img { + position: absolute; + inset: 0; + margin: auto; + max-width: 100%; + width: 160px; + height: 160px; + max-height: 100%; + object-fit: contain; + transform-origin: 50% 75%; +} +.status { + margin: 0; + text-align: center; + color: var(--text-secondary); +} +.transcriptViewport { + position: relative; + min-height: 0; + min-width: 0; + overflow: hidden; +} +.transcript { + height: 100%; + min-width: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + scrollbar-width: thin; + padding-top: 32px; + scroll-padding-top: 32px; + mask-image: linear-gradient(to bottom, transparent, black 32px); +} +.transcriptViewport::after { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 32px; + pointer-events: none; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + mask-image: linear-gradient(to bottom, black, transparent); +} + +.permission { + flex-shrink: 1; + min-height: 0; + max-height: 40%; + overflow: auto; + margin: 12px 24px 0; + padding: 16px; + border-radius: var(--radius-control); + background: var(--neutral-2); +} +.permission pre { + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin-block: 12px; +} +.decisions { + display: flex; + gap: 8px; +} +.controls { + --call-ease-out: cubic-bezier(0.23, 1, 0.32, 1); + display: grid; + flex-shrink: 0; + align-items: center; + padding: 16px 24px 24px; +} +.callStart, +.callControls { + grid-area: 1 / 1; + min-width: 0; + min-height: 48px; + display: flex; + align-items: center; + justify-content: center; + transition: + opacity var(--duration-state) var(--call-ease-out), + transform var(--duration-settle) var(--call-ease-out), + visibility 0s; +} +.callControls { + gap: 20px; + opacity: 0; + visibility: hidden; + transform: translateY(8px) scale(0.97); + transition-delay: 0s, 0s, var(--duration-state); +} +.callStart { + opacity: 1; + transform: translateY(0) scale(1); +} +.controls[data-active="true"] .callStart { + opacity: 0; + visibility: hidden; + transform: translateY(-4px) scale(0.96); + transition-delay: 0s, 0s, var(--duration-state); +} +.controls[data-active="true"] .callControls { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + transition-delay: 0s; +} +.muteControl, +.hangup { + display: flex; + flex-shrink: 0; +} +.controls button:active:not([data-disabled]) { + transform: scale(0.97); +} +.hangup button { + background: var(--red-9); + color: var(--text-on-accent); +} +.hangup button:hover:not([data-disabled]) { + background: var(--red-10); +} +.wave { + position: relative; + height: 48px; + min-width: 0; + flex: 1; + color: var(--text-secondary); +} +.waveFront, +.waveTrail { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; +} +.waveTrail { + opacity: 0.2; + transform: scaleX(-1); +} +.wave span { + display: block; + flex: 0 0 2px; + height: 40px; + background: currentColor; + border-radius: var(--radius-pill); + clip-path: inset(19px 0 round 1px); +} +.controls .wave { + opacity: 0; + transition: opacity var(--duration-state) var(--call-ease-out); +} +.controls[data-active="true"] .wave { + opacity: 1; + transition-delay: 40ms; +} +.controls[data-instant="true"] .callStart, +.controls[data-instant="true"] .callControls, +.controls[data-instant="true"] .wave { + transition: none; +} +.menuPositioner { + z-index: 1000; +} +.menu { + min-width: 220px; + max-height: var(--available-height); + overflow: auto; + padding: 6px; + border-radius: var(--radius-control); + border: 1px solid var(--border-primary); + background: var(--bg-float); + color: var(--text-primary); + box-shadow: var(--shadow-sm); +} +.menuLabel { + padding: 8px 10px 4px; + color: var(--text-secondary); +} +.menuItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 7px 10px; + border-radius: var(--radius-row); + cursor: default; +} +.menuItem[data-highlighted] { + outline: none; + background: var(--neutral-4); +} +.menuItem[data-disabled] { + color: var(--text-disabled); +} +.separator { + height: 1px; + background: var(--border-primary); + margin: 6px; +} +.menuHint { + color: var(--text-secondary); + padding: 8px 10px; + margin: 0; +} + +@media (prefers-reduced-motion: reduce) { + .callStart, + .callControls, + .controls[data-active="true"] .callStart, + .controls[data-active="true"] .callControls { + transform: none; + } + .controls button:active:not([data-disabled]) { + transform: none; + } +} diff --git a/src/bundled/bestie/Bestie.tsx b/src/bundled/bestie/Bestie.tsx index 535aaeda..4c2ead12 100644 --- a/src/bundled/bestie/Bestie.tsx +++ b/src/bundled/bestie/Bestie.tsx @@ -1,13 +1,19 @@ -import { useLayoutEffect, useSyncExternalStore } from "react"; +import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { - IconHeadphones, IconMicrophone, IconMicrophoneOff, + IconPhone, IconPhoneOff, } from "@tabler/icons-react"; +import { Transcript } from "./Transcript"; +import { PanelHeaderActions } from "../../features/panels/PanelHeaderActions"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Button } from "../../shared/design-system/ui/Button"; import type { BestieCall } from "./call"; +import { CallSettings } from "./CallSettings"; +import { VoiceWave } from "./VoiceWave"; +import { SpeakingAvatar } from "./SpeakingAvatar"; +import styles from "./Bestie.module.css"; export function Bestie({ call, @@ -22,137 +28,62 @@ export function Bestie({ call.snapshot, ); useLayoutEffect(() => call.attach(), [call]); + const [instantControls, setInstantControls] = useState(false); + const controls = useRef(null); + const restoreFocus = useRef(false); const permission = state.permission; const connected = state.phase === "listening" || state.phase === "speaking"; const busy = connected || state.phase === "connecting" || state.phase === "stopping"; + useLayoutEffect(() => { + if (!restoreFocus.current) return; + restoreFocus.current = false; + controls.current + ?.querySelector( + busy + ? '[aria-label="End Bestie conversation"]' + : '[aria-label="Start Bestie voice conversation"]', + ) + ?.focus(); + }, [busy]); + const status = !available + ? "Voice isn’t available in this app." + : !state.community + ? "Choose a community to call Bestie." + : state.message === "Conversation ended." || + state.message.startsWith("Listening through ") + ? "" + : state.message; return (
-
- -
-

Your Bestie

-

- {state.community - ? new URL(state.community).host - : "Choose a community"} + + + +

+
+ +

+ {status}

- : - } - variant={busy ? "tint" : "solid"} - shape="round" - aria-label={ - busy ? "End Bestie conversation" : "Start Bestie voice conversation" - } - disabled={ - !available || !state.community || state.phase === "stopping" - } - onClick={() => { - if (busy) void call.end(); - else void call.start(); - }} - /> -
- {!available ? ( -

- Voice is available in the live development app when - BUZZ_REALTIME_ENDPOINT is configured. -

- ) : ( - <> -

- {state.message} -

-
- - {connected && ( - - ) : ( - - ) - } - aria-label={ - state.muted - ? "Unmute Bestie microphone" - : "Mute Bestie microphone" - } - aria-pressed={state.muted} - onClick={call.mute} - /> - )} -
- -

- Bestie uses its own identity in this community. Closing this panel - ends the call. -

- - )} -
- {state.messages.map((message) => ( -
-

- {message.role === "user" ? "You" : "Bestie"} -

-

- {message.text} -

-
- ))} + {state.showTranscript && }
{permission && (
-

+

{permission.title || "Approve tool call?"}

-
+          
             {JSON.stringify(permission.rawInput ?? permission, null, 2)}
           
-
+
)} +
setInstantControls(false)} + onKeyDownCapture={() => setInstantControls(true)} + > +
+ + + ) : ( + + ) + } + variant="quiet" + size="default" + shape="control" + disabled={!connected} + aria-label={ + state.muted + ? "Unmute Bestie microphone" + : "Mute Bestie microphone" + } + aria-pressed={state.muted} + onClick={call.mute} + /> + + + + } + variant="quiet" + size="default" + shape="control" + aria-label="End Bestie conversation" + disabled={state.phase === "stopping"} + onClick={(event) => { + restoreFocus.current = event.detail === 0; + setInstantControls(event.detail === 0); + void call.end(); + }} + /> + +
+
+ +
+
); } diff --git a/src/bundled/bestie/CallSettings.tsx b/src/bundled/bestie/CallSettings.tsx new file mode 100644 index 00000000..36fdfe28 --- /dev/null +++ b/src/bundled/bestie/CallSettings.tsx @@ -0,0 +1,124 @@ +import { Menu } from "@base-ui/react/menu"; +import { IconCheck, IconDots } from "@tabler/icons-react"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import type { BestieCall, CallSnapshot } from "./call"; +import styles from "./Bestie.module.css"; + +export function CallSettings({ + call, + state, + busy, +}: { + call: BestieCall; + state: CallSnapshot; + busy: boolean; +}) { + return ( + + } + aria-label="Bestie call settings" + /> + } + /> + + + { + if (event.key === "Escape") event.stopPropagation(); + }} + > + + Show transcript + + + + + + + + Thinking + + + {[ + ["none", "Off"], + ["minimal", "Minimal"], + ["low", "Low"], + ["medium", "Medium"], + ["high", "High"], + ].map(([value, label]) => ( + + {label} + + + + + ))} + + + + + + Tool approval + + + + Automatically approve + + + + + + Ask each time + + + + + + + {busy && ( +

+ End the call to change thinking or tool approval. +

+ )} +
+
+
+
+ ); +} diff --git a/src/bundled/bestie/SpeakingAvatar.tsx b/src/bundled/bestie/SpeakingAvatar.tsx new file mode 100644 index 00000000..6cb92490 --- /dev/null +++ b/src/bundled/bestie/SpeakingAvatar.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from "react"; +import styles from "./Bestie.module.css"; + +export function SpeakingAvatar({ + analyser, +}: { + analyser: AnalyserNode | null; +}) { + const image = useRef(null); + useEffect(() => { + const element = image.current; + if (!element) return; + const reset = () => { + element.style.opacity = "1"; + element.style.transform = "translateY(0px) rotate(0deg) scale(1)"; + }; + reset(); + if (!analyser) return; + const samples = new Float32Array(analyser.fftSize); + const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + let frame = 0; + let previous = performance.now(); + let level = 0; + let velocity = 0; + let tilt = 0; + let tiltVelocity = 0; + let direction = -1; + let quietFor = 1; + const draw = (now: number) => { + analyser.getFloatTimeDomainData(samples); + const rms = Math.sqrt( + samples.reduce((sum, sample) => sum + sample * sample, 0) / + samples.length, + ); + const target = rms < 0.002 ? 0 : Math.min(1, Math.sqrt(rms * 4)); + // Change the lean only at a new phrase, never on a repeating idle loop. + // Small springs keep momentum through syllables and settle during pauses. + let elapsed = Math.min((now - previous) / 1000, 0.032); + previous = now; + if (target === 0) quietFor += elapsed; + else { + if (quietFor >= 0.18) direction *= -1; + quietFor = 0; + } + const tiltTarget = target * direction; + // Cap elapsed time after backgrounding and integrate in small steps. + while (elapsed > 0) { + const step = Math.min(elapsed, 1 / 120); + velocity += (100 * (target - level) - 10 * velocity) * step; + level += velocity * step; + tiltVelocity += (100 * (tiltTarget - tilt) - 10 * tiltVelocity) * step; + tilt += tiltVelocity * step; + elapsed -= step; + } + if ( + target === 0 && + Math.abs(level) < 0.001 && + Math.abs(velocity) < 0.001 && + Math.abs(tilt) < 0.001 && + Math.abs(tiltVelocity) < 0.001 + ) { + level = velocity = tilt = tiltVelocity = 0; + } + element.style.opacity = reduced.matches + ? String(0.94 + target * 0.06) + : "1"; + element.style.transform = reduced.matches + ? "translateY(0px) rotate(0deg) scale(1)" + : `translateY(${-level * 3}px) rotate(${tilt * 1.8}deg) scale(${1 + level * 0.025})`; + frame = requestAnimationFrame(draw); + }; + frame = requestAnimationFrame(draw); + return () => { + cancelAnimationFrame(frame); + reset(); + }; + }, [analyser]); + return ( +
+ Bestie +
+ ); +} diff --git a/src/bundled/bestie/Transcript.tsx b/src/bundled/bestie/Transcript.tsx new file mode 100644 index 00000000..e4f24ebb --- /dev/null +++ b/src/bundled/bestie/Transcript.tsx @@ -0,0 +1,54 @@ +// biome-ignore-all lint/a11y/noNoninteractiveTabindex: The transcript supports keyboard scrolling, like the shared message timeline. +import { MessageRow } from "../../features/messages/MessageRow"; +import type { CallSnapshot } from "./call"; +import styles from "./Bestie.module.css"; + +const speakers = { + user: { name: "You" }, + assistant: { name: "Bestie", picture: "/bestie.png" }, +}; +// Voice transcripts have no relay attachments or profile navigation. +const transcriptMedia = (url: string) => + url === "/bestie.png" ? url : undefined; +const openTranscriptLink = () => false; + +export function Transcript({ + messages, +}: { + messages: CallSnapshot["messages"]; +}) { + return ( +
+
+ {messages.map((message) => ( + + ))} +
+
+ ); +} diff --git a/src/bundled/bestie/VoiceWave.tsx b/src/bundled/bestie/VoiceWave.tsx new file mode 100644 index 00000000..f41dbef7 --- /dev/null +++ b/src/bundled/bestie/VoiceWave.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from "react"; +import styles from "./Bestie.module.css"; + +const barHeight = 40; +const barPitch = 5; // 2px bars with 3px gaps. +const clip = (height: number) => + `inset(${(barHeight - height) / 2}px 0 round 1px)`; + +export function VoiceWave({ + analyser, + muted, +}: { + analyser: AnalyserNode | null; + muted: boolean; +}) { + const ref = useRef(null); + const [count, setCount] = useState(0); + const bars = Array.from({ length: count }, (_, index) => index); + useEffect(() => { + const element = ref.current; + if (!element) return; + const observer = new ResizeObserver(([entry]) => { + if (entry) + setCount( + Math.max(0, Math.floor((entry.contentRect.width + 3) / barPitch)), + ); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + useEffect(() => { + const layers = Array.from(ref.current?.children ?? []).map( + (layer) => Array.from(layer.children) as HTMLElement[], + ); + const reset = () => { + for (const layer of layers) { + for (const element of layer) { + element.style.clipPath = clip(2); + element.style.opacity = "1"; + } + } + }; + reset(); + if (!analyser || muted || !count) return; + const data = new Float32Array(analyser.fftSize); + const history = new Float32Array(count); + const amplitudes = new Float32Array(count); + const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + let frame = 0; + let previous = performance.now(); + let sampled = 0; + let level = 0; + const draw = (now: number) => { + const elapsed = Math.min(now - previous, 64); + previous = now; + analyser.getFloatTimeDomainData(data); + const rms = Math.sqrt( + data.reduce((sum, value) => sum + value * value, 0) / data.length, + ); + const target = rms < 0.003 ? 0 : Math.min(1, rms * 5); + level += + (target - level) * + (1 - Math.exp(-elapsed / (target > level ? 45 : 140))); + // A short volume history adds shape; the live level gives immediate feedback. + if (now - sampled >= 1000 / 30) { + sampled = now; + history.copyWithin(0, 1); + history[history.length - 1] = level; + } + for (let index = 0; index < count; index++) { + const taper = + count === 1 ? 1 : Math.sin((Math.PI * index) / (count - 1)) ** 1.5; + const amplitude = (level * 0.6 + (history[index] ?? 0) * 0.4) * taper; + const smooth = + (amplitudes[index] ?? 0) + + (amplitude - (amplitudes[index] ?? 0)) * + (1 - Math.exp(-elapsed / 60)); + amplitudes[index] = smooth; + for (const layer of layers) { + const element = layer[index]; + if (!element) continue; + element.style.clipPath = clip( + reduced.matches ? 6 : 2 + smooth * (barHeight - 2), + ); + element.style.opacity = String( + reduced.matches + ? 0.35 + level * 0.65 + : 0.45 + Math.min(1, level * 2) * 0.55, + ); + } + } + frame = requestAnimationFrame(draw); + }; + frame = requestAnimationFrame(draw); + return () => { + cancelAnimationFrame(frame); + reset(); + }; + }, [analyser, muted, count]); + return ( +
+ + +
+ ); +} diff --git a/src/bundled/bestie/call.test.ts b/src/bundled/bestie/call.test.ts index 4101b295..71be2ce2 100644 --- a/src/bundled/bestie/call.test.ts +++ b/src/bundled/bestie/call.test.ts @@ -235,3 +235,72 @@ test("disabling the plugin fences late callbacks and further starts", async () = expect(f.open).toHaveBeenCalledTimes(1); expect(f.call.snapshot().permission).toBeUndefined(); }); + +test("audio analyzers follow the active call and ignores retired callbacks", async () => { + const f = fixture(); + await f.call.start(); + const old = f.callbacks(); + const analyser = {} as AnalyserNode; + const output = {} as AnalyserNode; + old.analyzers(analyser, output); + expect(f.call.snapshot().inputAnalyser).toBe(analyser); + expect(f.call.snapshot().outputAnalyser).toBe(output); + await f.call.end(); + expect(f.call.snapshot().inputAnalyser).toBeNull(); + expect(f.call.snapshot().outputAnalyser).toBeNull(); + await f.call.start(); + old.analyzers(analyser, output); + expect(f.call.snapshot().inputAnalyser).toBeNull(); + expect(f.call.snapshot().outputAnalyser).toBeNull(); + f.callbacks().analyzers(analyser, output); + f.callbacks().ended(); + expect(f.call.snapshot().inputAnalyser).toBeNull(); + expect(f.call.snapshot().outputAnalyser).toBeNull(); +}); + +test("community replacement clears both audio analyzers immediately", async () => { + const f = fixture(); + await f.call.start(); + f.callbacks().analyzers({} as AnalyserNode, {} as AnalyserNode); + f.community("two"); + expect(f.call.snapshot().inputAnalyser).toBeNull(); + expect(f.call.snapshot().outputAnalyser).toBeNull(); +}); + +test("transcript visibility defaults off and can change without restarting the call", async () => { + const f = fixture(); + expect(f.call.snapshot().showTranscript).toBe(false); + await f.call.start(); + f.callbacks().transcript("Kept while hidden."); + f.call.setShowTranscript(true); + expect(f.call.snapshot().showTranscript).toBe(true); + const messages = f.call.snapshot().messages; + f.call.setShowTranscript(false); + expect(f.call.snapshot().messages).toBe(messages); + f.call.setShowTranscript(true); + expect(f.open).toHaveBeenCalledTimes(1); + expect(f.voice.stop).not.toHaveBeenCalled(); + await f.call.end(); + await f.call.start(); + expect(f.call.snapshot().showTranscript).toBe(true); + expect(f.call.snapshot().messages).toEqual([]); +}); + +test("transcript timestamps stay at first receipt as message chunks arrive", async () => { + const now = vi.spyOn(Date, "now"); + try { + const f = fixture(); + await f.call.start(); + now.mockReturnValue(1000); + f.callbacks().transcript("First "); + now.mockReturnValue(5000); + f.callbacks().transcript("reply."); + f.callbacks().userTranscript("Next question.", "user-1"); + expect(f.call.snapshot().messages).toMatchObject([ + { text: "First reply.", createdAt: 1 }, + { text: "Next question.", createdAt: 5 }, + ]); + } finally { + now.mockRestore(); + } +}); diff --git a/src/bundled/bestie/call.ts b/src/bundled/bestie/call.ts index b866a14f..6d016ef6 100644 --- a/src/bundled/bestie/call.ts +++ b/src/bundled/bestie/call.ts @@ -7,7 +7,12 @@ import type { openVoice, } from "./media/voice.mjs"; -type Message = { id: string; role: "user" | "assistant"; text: string }; +type Message = { + id: string; + role: "user" | "assistant"; + text: string; + createdAt: number; +}; type Phase = | "idle" | "connecting" @@ -20,6 +25,9 @@ export type CallSnapshot = Readonly<{ message: string; community: string | undefined; muted: boolean; + showTranscript: boolean; + inputAnalyser: AnalyserNode | null; + outputAnalyser: AnalyserNode | null; permission: (PermissionTool & { request: number }) | undefined; thinking: string; approval: "auto" | "ask"; @@ -68,6 +76,9 @@ export function createBestieCall( message: "Start a voice conversation with Bestie.", community: connection(relay.snapshot())?.relay, muted: false, + showTranscript: false, + inputAnalyser: null, + outputAnalyser: null, thinking: "none", approval: "auto", permission: undefined, @@ -97,11 +108,14 @@ export function createBestieCall( !disposed && active?.id === id && same(active.scope, connection(relay.snapshot())); - const add = (message: Message) => { + const add = (message: Omit) => { const messages = [...state.messages]; const index = messages.findIndex((m) => m.id === message.id); - if (index < 0) messages.push(message); - else messages[index] = message; + const createdAt = + messages[index]?.createdAt ?? Math.floor(Date.now() / 1000); + const row = { ...message, createdAt }; + if (index < 0) messages.push(row); + else messages[index] = row; publish({ messages: messages .slice(-40) @@ -119,6 +133,8 @@ export function createBestieCall( publish({ phase: "stopping", muted: false, + inputAnalyser: null, + outputAnalyser: null, permission: undefined, message, }); @@ -161,6 +177,9 @@ export function createBestieCall( }); }; }, + setShowTranscript(showTranscript: boolean) { + publish({ showTranscript }); + }, setThinking(thinking: string) { if ( !active && @@ -194,6 +213,8 @@ export function createBestieCall( permission: undefined, messages: [], muted: false, + inputAnalyser: null, + outputAnalyser: null, firstSoundMs: undefined, }); let assistant = 0, @@ -202,7 +223,10 @@ export function createBestieCall( finished = false; const ui: VoiceUI = { evidence() {}, - analyzers() {}, + analyzers(input, output) { + if (current(call.id)) + publish({ inputAnalyser: input, outputAnalyser: output }); + }, status(message) { if (current(call.id)) publish({ message }); }, @@ -235,6 +259,8 @@ export function createBestieCall( publish({ phase: state.phase === "error" ? "error" : "idle", muted: false, + inputAnalyser: null, + outputAnalyser: null, permission: undefined, }); }, diff --git a/src/features/panels/PanelCard.tsx b/src/features/panels/PanelCard.tsx index 17c2f7de..d04bb579 100644 --- a/src/features/panels/PanelCard.tsx +++ b/src/features/panels/PanelCard.tsx @@ -1,4 +1,7 @@ -import { X } from "lucide-react"; +import { useState } from "react"; +import { PanelHeaderActionsContext } from "./PanelHeaderActions"; +import { IconX } from "@tabler/icons-react"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; import type { PanelProps, RegisteredPanel } from "./service"; import { PanelView } from "./PanelView"; import styles from "./Panels.module.css"; @@ -12,6 +15,7 @@ export function PanelCard({ panel: RegisteredPanel; closeLabel?: string; }) { + const [actions, setActions] = useState(null); return ( ); diff --git a/src/features/panels/PanelHeaderActions.tsx b/src/features/panels/PanelHeaderActions.tsx new file mode 100644 index 00000000..c151618c --- /dev/null +++ b/src/features/panels/PanelHeaderActions.tsx @@ -0,0 +1,12 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +// Local card chrome, independent of the public panel contribution contract. +export const PanelHeaderActionsContext = createContext( + null, +); + +export function PanelHeaderActions({ children }: { children: ReactNode }) { + const target = useContext(PanelHeaderActionsContext); + return target ? createPortal(children, target) : children; +} diff --git a/src/features/panels/Panels.module.css b/src/features/panels/Panels.module.css index 2181c68a..9f8c6081 100644 --- a/src/features/panels/Panels.module.css +++ b/src/features/panels/Panels.module.css @@ -27,14 +27,6 @@ text-overflow: ellipsis; white-space: nowrap; } -.heading button { - display: inline-flex; - flex-shrink: 0; - border: 0; - background: var(--surface-control); - color: inherit; - padding: 7px 9px; -} .content { display: flex; flex: 1; @@ -87,3 +79,9 @@ width: 100%; } } + +.actions { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/src/features/relay/signed-boundary.test.ts b/src/features/relay/signed-boundary.test.ts index 5eb8633f..f78fddb4 100644 --- a/src/features/relay/signed-boundary.test.ts +++ b/src/features/relay/signed-boundary.test.ts @@ -2,7 +2,7 @@ import { assert, afterEach, expect, it, vi } from "vitest"; import { connectSignedTransport } from "./transport"; -import { ApiCapacity } from "./http-admission"; +import { ApiCapacity, ApiPaused } from "./http-admission"; import { PublishRejected } from "./outbox"; import { signed, keypair } from "./testing"; function required(value: T | undefined): T { @@ -115,19 +115,17 @@ it("a signer already waiting cannot bypass a newly learned shared cooldown", asy "relay", ); const one = t.query([{ kinds: [0], limit: 1 }]).catch((e) => e); + // Real crypto digest completion may reorder requests. Establish which signer + // owns each gate before releasing the first request into the shared cooldown. + await vi.waitFor(() => expect(pending).toHaveLength(1)); const two = t.query([{ kinds: [0], limit: 2 }]).catch((e) => e); - await vi.advanceTimersByTimeAsync(600); await vi.waitFor(() => expect(pending).toHaveLength(2)); required(pending[0])(); - await tick(); - await vi.advanceTimersByTimeAsync(1); await one; required(pending[1])(); - await tick(); - await vi.advanceTimersByTimeAsync(1); - expect(starts).toHaveLength(1); + expect(await two).toBeInstanceOf(ApiPaused); await vi.advanceTimersByTimeAsync(3500); - await two; + expect(starts).toHaveLength(1); }); function deferredSigner() { diff --git a/tests/browser/bestie.spec.mjs b/tests/browser/bestie.spec.mjs index a478e18d..9bc26289 100644 --- a/tests/browser/bestie.spec.mjs +++ b/tests/browser/bestie.spec.mjs @@ -15,12 +15,22 @@ const agentSource = ` import { createInterface } from 'node:readline'; const send = event => console.log(JSON.stringify({jsonrpc:'2.0',...event})); const update = value => send({method:'_buzz/unstable/realtime/update',params:{sessionId:'session',streamId:'stream',update:value}}); -let ready=false, spoke=false, permission; +let ready=false, spoke=false, permission, tone=false, toneSamples=0, toneSequence=0; process.on('message', command => { - if(command.type==='ask') { + if(command.type==='ready') { + ready=true;update({type:'ready'}); + } else if(command.type==='ask') { permission=command.id; send({id:command.id,method:'session/request_permission',params:{sessionId:'session',toolCall:{toolCallId:command.id,title:command.title,rawInput:{command:'buzz channels list'}},options:[{optionId:'yes',kind:'allow_once'},{optionId:'no',kind:'reject_once'}]}}); + } else if(command.type==='history') { + for(let i=1;i<=20;i++) { + update({type:'speech_started'}); + send({method:'session/update',params:{sessionId:'session',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'Transcript message '+i+'. A little more detail to read while Bestie stays visible above the conversation.'}}}}); + } + } else if(command.type==='speak') { + tone=true; toneSamples=0; toneSequence++; } else if(command.type==='cancel') { + tone=false; update({type:'speech_started'}); send({method:'session/update',params:{sessionId:'session',update:{sessionUpdate:'tool_call_update',toolCallId:permission,status:'failed'}}}); } else if(command.type==='exit') process.exit(0); @@ -29,10 +39,17 @@ for await(const line of createInterface({input:process.stdin})) { const event=JSON.parse(line); if(event.method==='initialize') send({id:event.id,result:{agentCapabilities:{_meta:{buzz:{realtimeAudio:1}}}}}); else if(event.method==='session/new') send({id:event.id,result:{sessionId:'session'}}); - else if(event.method==='session/prompt') {ready=true;update({type:'ready'});} + else if(event.method==='session/prompt') {process.send({type:'prompt'});if(!process.env.BESTIE_TEST_HOLD_READY){ready=true;update({type:'ready'});}} else if(event.method==='_buzz/unstable/realtime/append') { send({id:event.id,result:{}}); process.send({type:'capture',bytes:Buffer.from(event.params.data,'base64').length}); + // Keep playback active until the test explicitly interrupts it. + if(tone) { + const pcm=Buffer.alloc(Buffer.from(event.params.data,'base64').length); + for(let i=0;i events.push({ ...event, child: index })); @@ -126,7 +144,7 @@ async function fixture(page) { const destination = context.createMediaStreamDestination(); const source = context.createOscillator(); const gain = context.createGain(); - gain.gain.value = 0.001; + gain.gain.value = 0.15; source.connect(gain).connect(destination); source.start(); const track = destination.stream.getAudioTracks()[0]; @@ -264,9 +282,16 @@ test("ACP streams complete sparse notifications and audio, then closes its agent } }); +async function chooseSetting(page, label) { + await button(page, "Bestie call settings").click(); + await page.getByRole("menuitemradio", { name: label, exact: true }).click(); + await expect(page.getByRole("menu")).toHaveCount(0); +} + async function start(page, f) { await button(page, "Start Bestie voice conversation").click(); - await expect(page.getByRole("status")).toContainText("Listening"); + await expect(button(page, "Mute Bestie microphone")).toBeEnabled(); + await expect(page.getByRole("status")).not.toContainText("Listening through"); await expect.poll(f.microphones).toBe(1); await expect .poll(() => @@ -280,15 +305,160 @@ async function start(page, f) { .toBe(true); } +test("Bestie gestures with speech phrases, respects reduced motion, and settles after interruption", async ({ + page, +}, testInfo) => { + const f = await fixture(page); + try { + await start(page, f); + const avatar = page.getByRole("img", { name: "Bestie", exact: true }); + await expect(avatar).toHaveCSS("width", "160px"); + await expect(avatar.locator("..")).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + await expect + .poll(() => + f.events.some( + (event) => event.type === "playback" && event.samples > 0, + ), + ) + .toBe(true); + await expect(avatar).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); + f.children[0].send({ type: "speak" }); + await expect + .poll(() => + avatar.evaluate( + (element) => new DOMMatrix(getComputedStyle(element).transform).a, + ), + ) + .toBeGreaterThan(1.01); + await capture(page, testInfo, "speaking"); + const firstTilt = await avatar.evaluate( + (element) => new DOMMatrix(getComputedStyle(element).transform).b, + ); + f.children[0].send({ type: "cancel" }); + await expect(avatar).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); + f.children[0].send({ type: "speak" }); + await expect + .poll(() => + avatar.evaluate( + (element, initialTilt) => + new DOMMatrix(getComputedStyle(element).transform).b * + Math.sign(initialTilt), + firstTilt, + ), + ) + .toBeLessThan(-0.005); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(avatar).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); + await expect + .poll(() => avatar.evaluate((element) => Number(element.style.opacity))) + .toBeGreaterThan(0.95); + await page.emulateMedia({ reducedMotion: "no-preference" }); + f.children[0].send({ type: "cancel" }); + await expect(avatar).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); + await expect(button(page, "Mute Bestie microphone")).toBeEnabled(); + } finally { + await f.close(); + } +}); + +test("call controls keep their space and keyboard focus through connecting and cancellation", async ({ + page, +}, testInfo) => { + const f = await fixture(page, { holdReady: true }); + try { + const call = button(page, "Start Bestie voice conversation"); + const footer = page.locator("footer"); + const before = await footer.boundingBox(); + await call.focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => f.events.some((event) => event.type === "prompt")) + .toBe(true); + const hangup = button(page, "End Bestie conversation"); + await expect(hangup).toBeFocused(); + await capture(page, testInfo, "connecting"); + await expect(button(page, "Mute Bestie microphone")).toBeDisabled(); + expect((await footer.boundingBox()).height).toBe(before.height); + await expect(footer).toHaveAttribute("data-instant", "true"); + await expect(call).toHaveCount(0); + await page.keyboard.press("Enter"); + await expect(button(page, "Start Bestie voice conversation")).toBeFocused(); + await expect(page.getByRole("status")).toBeEmpty(); + await expect(button(page, "End Bestie conversation")).toHaveCount(0); + expect((await footer.boundingBox()).height).toBe(before.height); + // Restart with a pointer, then explicitly release the connection gate. + await call.click(); + await expect + .poll(() => f.events.filter((event) => event.type === "prompt").length) + .toBe(2); + await expect(footer).toHaveAttribute("data-instant", "false"); + f.control({ type: "ready" }); + await expect(button(page, "Mute Bestie microphone")).toBeEnabled(); + await expect(button(page, "End Bestie conversation")).toBeEnabled(); + } finally { + await f.close(); + } +}); + +async function capture(page, testInfo, name) { + if (!process.env.BESTIE_REVIEW_SCREENSHOTS) return; + await page.evaluate(async () => { + await document.fonts.ready; + await Promise.all( + document + .getAnimations() + .filter( + (animation) => + animation.effect?.getComputedTiming().iterations !== Infinity, + ) + .map((animation) => animation.finished.catch(() => {})), + ); + }); + await page + .getByRole("complementary", { name: "Bestie", exact: true }) + .screenshot({ path: testInfo.outputPath(`${name}.png`) }); +} + +async function toggleTranscript(page) { + await button(page, "Bestie call settings").click(); + const setting = page.getByRole("menuitemcheckbox", { + name: "Show transcript", + }); + await expect(setting).toBeEnabled(); + await setting.click(); +} + test("actual Bestie call keeps duplex media and thinking through panel relocation, then stops on hide", async ({ page, -}) => { +}, testInfo) => { const f = await fixture(page); try { - await page - .getByRole("combobox", { name: "Bestie thinking level" }) - .selectOption("high"); + await chooseSetting(page, "High"); await start(page, f); + const transcript = page.getByRole("log", { name: "Bestie transcript" }); + // Playback proves the response arrived while the transcript was hidden. + await expect + .poll(() => + f.events.some( + (event) => event.type === "playback" && event.samples > 0, + ), + ) + .toBe(true); + await expect(transcript).toHaveCount(0); + await capture(page, testInfo, "listening"); + await toggleTranscript(page); + await expect(transcript).toContainText("Fixture voice reply."); + const message = transcript.locator("[data-message-id]"); + await expect(message.locator("strong")).toHaveText("Bestie"); + await expect(message.locator("time")).toHaveAttribute("datetime", /T/); + await expect(message.locator("img")).toHaveAttribute("src", "/bestie.png"); + await toggleTranscript(page); + await expect(transcript).toHaveCount(0); + await toggleTranscript(page); + await expect( page.getByRole("log", { name: "Bestie transcript" }), ).toContainText("Fixture voice reply."); @@ -299,16 +469,98 @@ test("actual Bestie call keeps duplex media and thinking through panel relocatio ), ) .toBe(true); + const wave = page.getByRole("img", { name: "Microphone activity" }); + await expect(wave).toBeVisible(); + await expect(wave.locator(":scope > div")).toHaveCount(2); + await expect(wave.locator(":scope > div").first()).toHaveCSS( + "opacity", + "0.2", + ); + await expect(wave.locator(":scope > div").first()).toHaveCSS( + "transform", + "matrix(-1, 0, 0, 1, 0, 0)", + ); + await expect(wave.locator("span").first()).toHaveCSS("width", "2px"); + await expect(wave.locator(":scope > div").last()).toHaveCSS("gap", "3px"); + const monochrome = await wave + .locator("span") + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor); + expect(monochrome).toBe( + await wave.evaluate((element) => getComputedStyle(element).color), + ); + const controls = wave.locator(".."); + const geometry = await controls.evaluate((footer) => { + const [mute, wave, hangup] = footer.children; + return { + available: + footer.clientWidth - + Number.parseFloat(getComputedStyle(footer).paddingLeft) - + Number.parseFloat(getComputedStyle(footer).paddingRight), + occupied: + hangup.getBoundingClientRect().right - + mute.getBoundingClientRect().left, + width: wave.getBoundingClientRect().width, + }; + }); + expect(geometry.occupied).toBeCloseTo(geometry.available, 0); + expect(geometry.width).toBeGreaterThan(144); + await expect( + wave.locator(":scope > div").last().locator("span"), + ).toHaveCount(Math.floor((geometry.width + 3) / 5)); + await expect + .poll(() => + wave + .locator("span") + .evaluateAll((bars) => + bars.some( + (bar) => Number.parseFloat(bar.style.clipPath.slice(6)) < 19, + ), + ), + ) + .toBe(true); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect + .poll(() => + wave + .locator("span") + .evaluateAll((bars) => + bars.every( + (bar) => bar.style.clipPath === "inset(17px 0px round 1px)", + ), + ), + ) + .toBe(true); await button(page, "Mute Bestie microphone").click(); + await expect + .poll(() => + page + .getByRole("img", { name: "Microphone muted" }) + .locator("span") + .evaluateAll((bars) => + bars.every( + (bar) => bar.style.clipPath === "inset(19px 0px round 1px)", + ), + ), + ) + .toBe(true); await expect(button(page, "Unmute Bestie microphone")).toHaveAttribute( "aria-pressed", "true", ); + await capture(page, testInfo, "muted"); await button(page, "Relocate Bestie").click(); + await expect(transcript).toContainText("Fixture voice reply."); await expect(button(page, "End Bestie conversation")).toBeVisible(); + await button(page, "Bestie call settings").click(); await expect( - page.getByRole("combobox", { name: "Bestie thinking level" }), - ).toHaveValue("high"); + page.getByRole("menuitemradio", { name: "High", exact: true }), + ).toBeChecked(); + await expect( + page.getByRole("menuitemradio", { name: "Off", exact: true }), + ).toBeDisabled(); + await page.keyboard.press("Escape"); + await expect(button(page, "End Bestie conversation")).toBeVisible(); expect(f.children.length).toBe(1); await expect.poll(f.microphones).toBe(1); await button(page, "Hide Bestie").click(); @@ -332,9 +584,8 @@ test("community changes revoke the old call and clear transcripts after both act }) => { const f = await fixture(page); try { - await page - .getByRole("combobox", { name: "Bestie tool approval mode" }) - .selectOption("ask"); + await chooseSetting(page, "Ask each time"); + await toggleTranscript(page); await start(page, f); await expect(page.getByRole("log")).toContainText("Fixture voice reply."); f.control({ type: "ask", id: "old", title: "Old community tool" }); @@ -377,9 +628,7 @@ test("approval controls forward exact choices and disappear when speech revokes }) => { const f = await fixture(page); try { - await page - .getByRole("combobox", { name: "Bestie tool approval mode" }) - .selectOption("ask"); + await chooseSetting(page, "Ask each time"); await start(page, f); f.control({ type: "ask", id: "denied", title: "Read fixture channels" }); const approval = page.getByRole("region", { name: "Bestie tool approval" }); @@ -426,9 +675,11 @@ test("automatic approval is the default and reaches the exact ACP tool request w }) => { const f = await fixture(page); try { + await button(page, "Bestie call settings").click(); await expect( - page.getByRole("combobox", { name: "Bestie tool approval mode" }), - ).toHaveValue("auto"); + page.getByRole("menuitemradio", { name: "Automatically approve" }), + ).toBeChecked(); + await page.keyboard.press("Escape"); await start(page, f); f.control({ type: "ask", @@ -452,3 +703,138 @@ test("automatic approval is the default and reaches the exact ACP tool request w await f.close(); } }); + +test("transcript occupies the lower half and scrolls beneath a soft edge without moving Bestie", async ({ + page, +}, testInfo) => { + const f = await fixture(page); + try { + await toggleTranscript(page); + await start(page, f); + const transcript = page.getByRole("log", { name: "Bestie transcript" }); + await expect(transcript).toContainText("Fixture voice reply."); + f.control({ type: "history" }); + await expect(transcript.locator("[data-message-id]")).toHaveCount(21); + const viewport = transcript.locator(".."); + const identity = page + .getByRole("img", { name: "Bestie", exact: true }) + .locator("../.."); + const initial = await identity.boundingBox(); + expect((await viewport.boundingBox()).height).toBeCloseTo( + initial.height, + 0, + ); + await expect(transcript).toHaveCSS("overflow-y", "auto"); + expect( + await transcript.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ).toBe(true); + await expect(transcript).toHaveCSS("mask-image", /linear-gradient/); + expect( + await viewport.evaluate( + (element) => getComputedStyle(element, "::after").backdropFilter, + ), + ).toBe("blur(2px)"); + await transcript.focus(); + await page.keyboard.press("End"); + await expect + .poll(() => + transcript.evaluate((element) => + Math.abs( + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ), + ) + .toBeLessThan(2); + expect(await identity.boundingBox()).toEqual(initial); + await transcript.evaluate((element) => { + element.scrollTop = 110; + }); + await capture(page, testInfo, "transcript"); + await page.evaluate(() => { + document.documentElement.dataset.colorMode = "dark"; + }); + await capture(page, testInfo, "transcript-dark"); + await page.evaluate(() => { + document.documentElement.dataset.colorMode = "light"; + }); + // A short panel still divides evenly and keeps the footer outside the scroll area. + await page.locator("main > div").evaluate((element) => { + element.style.height = "400px"; + }); + await expect + .poll(async () => (await viewport.boundingBox()).height) + .toBeLessThan(initial.height); + const compact = await identity.boundingBox(); + expect((await viewport.boundingBox()).height).toBeCloseTo( + compact.height, + 0, + ); + const footer = await page.locator("footer").boundingBox(); + expect( + (await viewport.boundingBox()).y + (await viewport.boundingBox()).height, + ).toBeLessThanOrEqual(footer.y + 1); + const avatarBox = await page + .getByRole("img", { name: "Bestie", exact: true }) + .boundingBox(); + expect(avatarBox.y).toBeGreaterThanOrEqual(compact.y); + expect(avatarBox.y + avatarBox.height).toBeLessThanOrEqual( + (await viewport.boundingBox()).y, + ); + await capture(page, testInfo, "transcript-compact"); + await toggleTranscript(page); + await expect(transcript).toHaveCount(0); + expect((await identity.boundingBox()).height).toBeCloseTo( + compact.height * 2, + 0, + ); + } finally { + await f.close(); + } +}); + +test("settings sit beside close and Escape dismisses only the menu", async ({ + page, +}, testInfo) => { + const f = await fixture(page); + try { + const card = page.getByRole("complementary", { + name: "Bestie", + exact: true, + }); + await expect(card.getByText("Your Bestie", { exact: true })).toHaveCount(0); + await capture(page, testInfo, "idle"); + const header = card.locator("header"); + await expect( + header.getByRole("button", { name: "Bestie call settings" }), + ).toBeVisible(); + await expect( + header.getByRole("button", { name: "Close Bestie panel" }), + ).toBeVisible(); + const settings = header.getByRole("button", { + name: "Bestie call settings", + }); + const close = header.getByRole("button", { name: "Close Bestie panel" }); + const settingsBox = await settings.boundingBox(); + const closeBox = await close.boundingBox(); + expect(closeBox.width).toBe(settingsBox.width); + expect(closeBox.height).toBe(settingsBox.height); + expect(closeBox.width).toBe(closeBox.height); + await expect(close).toHaveAttribute("data-icon-shape", "control"); + await expect(close.locator("svg")).toHaveAttribute("width", "18"); + await button(page, "Bestie call settings").click(); + await expect( + page.getByRole("menuitemradio", { name: "Off", exact: true }), + ).toBeChecked(); + await capture(page, testInfo, "settings"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(button(page, "Bestie call settings")).toBeFocused(); + await expect(card).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(card).toHaveCount(0); + } finally { + await f.close(); + } +}); diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index fef58f9a..ad4ebc19 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -321,9 +321,8 @@ test("Bestie owns the launcher and the reusable companion card across pages and await expect(bestie).toHaveCount(0); await launch.click(); await expect(bestie).toBeVisible(); - await expect(bestie).toContainText( - "Voice is available in the live development app", - ); + await expect(bestie).toContainText("Voice isn’t available in this app."); + await expect(button(page, "Start Bestie voice conversation")).toBeDisabled(); await expect(launch).toHaveAttribute("aria-expanded", "true"); await launch.click(); await expect(bestie).toHaveCount(0); diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index d0107a59..41044bf9 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -731,13 +731,22 @@ test("recovery is a keyboard-selectable action without transferring editor focus const index = await page.evaluate( () => window.completionFixture.queries().length - 1, ); - await page.evaluate( - ({ index, withChoice }) => - window.completionFixture.fail(index, withChoice), - { index, withChoice }, - ); - if (withChoice) await input.press("ArrowUp"); + expect( + await page.evaluate( + ({ index, withChoice }) => + window.completionFixture.fail(index, withChoice), + { index, withChoice }, + ), + ).toBe(true); const retry = page.getByRole("option", { name: "Retry suggestions" }); + // Publication schedules a render; keyboard navigation needs mounted options. + await expect(retry).toBeVisible(); + if (withChoice) { + await expect( + page.getByRole("option", { name: "Choice", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await input.press("ArrowUp"); + } await expect(retry).toHaveAttribute("aria-selected", "true"); await input.press("Enter"); await expect(page.getByRole("option", { name: "Recovered" })).toBeVisible(); diff --git a/tests/fixtures/bestie.tsx b/tests/fixtures/bestie.tsx index 85ed02a1..4cc379ee 100644 --- a/tests/fixtures/bestie.tsx +++ b/tests/fixtures/bestie.tsx @@ -3,6 +3,7 @@ import { Context } from "@deepseek-ai/cordis"; import { useState } from "react"; import { createRoot } from "react-dom/client"; import { apply } from "../../src/bundled/bestie"; +import { PanelCard } from "../../src/features/panels/PanelCard"; import type { Panel } from "../../src/features/panels/service"; import type { RelayData, @@ -53,7 +54,12 @@ ctx.provide("panels", { }); apply(ctx); if (!panel) throw new Error("Bestie contribution missing"); -const Content = panel.component; +const contribution = { + ...panel, + key: "buzz.bestie:companion", + pluginId: "buzz.bestie", + revision: "fixture", +}; function Fixture() { const [visible, setVisible] = useState(true); @@ -74,15 +80,23 @@ function Fixture() { {visible ? "Hide" : "Show"} Bestie -
+
{visible && (relocated ? ( -