Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/components/student-space/capture/AskSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,16 @@ export function AskSheet() {
}
}

function showPreparedReading() {
if (preparedReflection) {
setReframe((current) => current ?? preparedToReframe(preparedReflection))
setReframeActionMode('ready')
setStage('reframe')
return
}
void prepareMirrorDraft()
}

function commitCapture(payload: Record<string, unknown>, options: Record<string, unknown> = {}) {
const entry: Record<string, unknown> = {
kind: 'ask',
Expand Down Expand Up @@ -793,6 +803,7 @@ export function AskSheet() {
}

function logReview() {
if (prepareInFlight) return
if (preparedReflection) {
void logPreparedReframe()
return
Expand Down Expand Up @@ -1183,9 +1194,10 @@ export function AskSheet() {
imageDataUrl={uploadedImageDataUrl}
reframe={reframe}
thread={thread}
busy={prepareInFlight}
onDiscard={() => close()}
onLog={logReview}
onReframe={() => void prepareMirrorDraft()}
onReframe={showPreparedReading}
/>
) : null}

Expand Down Expand Up @@ -1273,6 +1285,7 @@ function ReviewStage({
imageDataUrl,
reframe,
thread,
busy = false,
onDiscard,
onLog,
onReframe,
Expand All @@ -1282,6 +1295,7 @@ function ReviewStage({
imageDataUrl: string | null
reframe: Reframe | null
thread: ThreadMessage[]
busy?: boolean
onDiscard: () => void
onLog: () => void
onReframe: () => void
Expand All @@ -1290,7 +1304,10 @@ function ReviewStage({
<section className="flex min-h-0 flex-col gap-4">
<h2 className="m-0 text-xl font-semibold">Here's what you said.</h2>
<div className="rounded-3xl bg-white/72 p-4 text-base leading-7 text-[rgba(43,38,32,0.82)]">
{reviewText || 'Audio recorded. Transcript will appear after Mirror listens.'}
<p className="m-0 whitespace-pre-wrap">
{reviewText || 'Audio recorded. Transcript will appear after Mirror listens.'}
</p>
{busy ? <TypingIndicator label="Reading" /> : null}
</div>
{imageDataUrl ? (
<img src={imageDataUrl} alt="" className="max-h-56 rounded-3xl object-cover" />
Expand All @@ -1310,8 +1327,9 @@ function ReviewStage({
{reviewText ? (
<button
type="button"
disabled={busy}
onClick={onReframe}
className="min-h-12 rounded-full bg-[#f3eee2] px-5 text-sm font-semibold text-[rgba(43,38,32,0.82)]"
className="min-h-12 rounded-full bg-[#f3eee2] px-5 text-sm font-semibold text-[rgba(43,38,32,0.82)] disabled:cursor-not-allowed disabled:opacity-45"
>
What I heard
</button>
Expand All @@ -1326,8 +1344,9 @@ function ReviewStage({
</button>
<button
type="button"
disabled={busy}
onClick={onLog}
className="min-h-11 rounded-full bg-(--color-onb-accent) px-5 text-sm font-semibold text-white"
className="min-h-11 rounded-full bg-(--color-onb-accent) px-5 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-45"
>
Log
</button>
Expand Down
9 changes: 7 additions & 2 deletions src/components/student-space/onboarding/Greeting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,17 @@ export function Greeting({
className={cn(
'absolute inset-0 flex flex-col items-center justify-center',
'px-6 pt-8 pb-[max(2rem,env(safe-area-inset-bottom))] gap-7',
'bg-(--color-onb-bg-cream) text-(--color-onb-ink)',
'overflow-hidden bg-transparent text-(--color-onb-ink)',
'transition-opacity duration-[320ms] ease-out',
visible ? 'opacity-100' : 'opacity-0',
)}
data-testid="onboarding-greeting"
>
<div className="flex max-w-[360px] flex-col items-center gap-3 text-center">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_52%,rgba(250,242,227,0.82),rgba(250,242,227,0.52)_32%,rgba(250,242,227,0.16)_58%,rgba(250,242,227,0)_78%)]"
/>
<div className="relative z-[1] flex max-w-[360px] flex-col items-center gap-3 text-center">
<h1 className="m-0 font-medium text-[clamp(28px,7vw,36px)]">{hello}</h1>
<p className="m-0 text-[18px] text-(--color-onb-ink)">{ONBOARDING_COPY.greeting.sub}</p>
<p className="mx-0 mt-1 mb-0 text-sm italic text-(--color-onb-ink-faint)">
Expand All @@ -65,6 +69,7 @@ export function Greeting({
data-testid="onboarding-greeting-cta"
onClick={onAdvance}
className={cn(
'relative z-[1]',
'min-h-[56px] rounded-[14px] border-0 px-[26px] text-base tracking-[0.02em]',
'cursor-pointer bg-(--color-onb-accent) text-white',
'shadow-[0_8px_20px_rgba(255,138,92,0.30),0_1px_2px_rgba(43,38,32,0.06)]',
Expand Down
99 changes: 98 additions & 1 deletion test/components/student-space/capture/capture-stack.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom

import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
Expand All @@ -13,6 +13,16 @@ import { EngineOverlayProvider, useEngineOverlay } from '~/lib/student-space/use

type CaptureEntry = Record<string, unknown> & { id: string }

function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}

function makeCaptures() {
const subscribers = new Set<(entry: CaptureEntry) => void>()
const entries: CaptureEntry[] = []
Expand Down Expand Up @@ -271,6 +281,93 @@ describe('React capture stack', () => {
})
})

it('keeps realtime Log disabled until prepared reflection is ready', async () => {
class MockRTCPeerConnection {}
// @ts-expect-error happy-dom does not provide RTCPeerConnection.
globalThis.RTCPeerConnection = MockRTCPeerConnection
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn() },
})

const prepared = {
localCaptureId: 'ask-realtime',
transcript: 'realtime transcript',
validation: 'That was heard live.',
inferredMeaning: 'Voice went through Realtime.',
storyReframe: 'Kira heard the Realtime session.',
contextType: 'school',
transcription: { provider: 'openai_realtime', transcript: 'realtime transcript' },
}
const stopped = deferred<typeof prepared>()
const stop = vi.fn(() => stopped.promise)
const createRealtimeMirrorCapture = vi.fn(async (input: Record<string, unknown>) => {
const onConversationUpdate = input.onConversationUpdate as
| ((message: Record<string, unknown>) => void)
| undefined
onConversationUpdate?.({
id: 'student-1',
role: 'student',
text: 'Can you hear me?',
status: 'final',
})
return { stop, abort: vi.fn() }
})
const logPreparedReflection = vi.fn(async () => ({
mirrorEntry: {
id: 88,
transcript: 'realtime transcript',
validation: 'That was heard live.',
storyReframe: 'Kira heard the Realtime session.',
inferredMeaning: 'Voice went through Realtime.',
contextType: 'school',
reviewStatus: 'confirmed',
},
}))
const prepareReflection = vi.fn(async () => {
throw new Error('Realtime reading should be reused instead of prepared again.')
})
const game = makeGame({
state: {
backend: { createRealtimeMirrorCapture, logPreparedReflection, prepareReflection },
},
})
renderDirectAsk(game)

await userEvent.click(screen.getByText('open ask directly'))
await userEvent.click(screen.getByRole('button', { name: 'Start voice recording' }))
await waitFor(() => expect(createRealtimeMirrorCapture).toHaveBeenCalledTimes(1))

await userEvent.click(screen.getByRole('button', { name: 'Done' }))
await waitFor(() => expect(stop).toHaveBeenCalledTimes(1))
await waitFor(() => expect(screen.getByRole('status', { name: 'Reading' })).toBeInTheDocument())
expect(screen.getByRole('button', { name: 'What I heard' })).toBeDisabled()
const pendingLog = screen.getByRole('button', { name: 'Log' })
expect(pendingLog).toBeDisabled()
await userEvent.click(pendingLog)
expect(game.state.captures.add).not.toHaveBeenCalled()
expect(logPreparedReflection).not.toHaveBeenCalled()

await act(async () => {
stopped.resolve(prepared)
await stopped.promise
})
await waitFor(() => expect(screen.getByText('realtime transcript')).toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('button', { name: 'Log' })).not.toBeDisabled())

await userEvent.click(screen.getByRole('button', { name: 'What I heard' }))
expect(prepareReflection).not.toHaveBeenCalled()
expect(screen.getByText(/Kira heard the Realtime session/)).toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: 'Log' }))
await waitFor(() => expect(logPreparedReflection).toHaveBeenCalledTimes(1))
expect(game.state.captures.entries[0]).toMatchObject({
text: 'realtime transcript',
backendMirrorEntryId: 88,
syncStatus: 'synced',
})
})

it('shows the listening state inside a white You bubble before transcription lands', async () => {
class MockRTCPeerConnection {}
// @ts-expect-error happy-dom does not provide RTCPeerConnection.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,15 @@ describe('OnboardingFlow (React)', () => {
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('Hi, Mei.')
})

it('keeps the live world visible behind the first greeting screen', async () => {
const onboarding = makeOnboarding({ stage: 'greeting' })
const game = makeGame({ onboarding })
renderFlow(game)
const greeting = await screen.findByTestId('onboarding-greeting')
expect(greeting.className).toContain('bg-transparent')
expect(greeting.className).not.toContain('bg-(--color-onb-bg-cream)')
})

it('falls back to "there" when no profile name is available', async () => {
const onboarding = makeOnboarding({ stage: 'greeting' })
const game = makeGame({ onboarding, studentName: '' })
Expand Down
Loading