fix(frontend/onboarding): keep a typed brain dump on its way through a mic retry

- A retry refused while the typed text is being sent no longer reopens the
  composer, and a second submit is ignored, so the wizard cannot advance
  twice (Sentry, Capy).
- The recorder asks `shouldRecord` again after its first metadata write, so
  a submit that starts during the write still stops the take (Capy).
- A retry after an unavailable mic keeps that notice instead of flashing the
  blocked copy (Sentry).
- `AmbientCard` is its own component, and the cards do not drift where the
  layer is hidden (CodeRabbit, PR reviewer).

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
Krzysztof CzerwinskiandClaude Opus 5.5 committed 2026-10-27 18:32:59 +09:00
1 parent 801c44f9ba
commit d6abdc63d6
7 files changed
+199 -58

No files matched your search

@@ -396,6 +396,29 @@ describe("useBrainDumpRecorder", () => {
expect(onMicAnswer).toHaveBeenCalledExactlyOnceWith("unavailable");
});
it("starts no take when the caller moves on during the first write", async () => {
const track = { stop: vi.fn() };
stubGetUserMedia(vi.fn().mockResolvedValue({ getTracks: () => [track] }));
const shouldRecord = vi
.fn()
.mockReturnValueOnce(true)
.mockReturnValue(false);
const { result } = renderHook(() => useBrainDumpRecorder());
let started: boolean | undefined;
await act(async () => {
started = await result.current.start({ shouldRecord });
});
expect(started).toBe(false);
expect(shouldRecord).toHaveBeenCalledTimes(2);
expect(track.stop).toHaveBeenCalled();
expect(trackBrainDump).not.toHaveBeenCalledWith(
"brain_dump_started",
expect.anything(),
);
});
it("starts no take when the caller no longer wants one", async () => {
const track = { stop: vi.fn() };
stubGetUserMedia(vi.fn().mockResolvedValue({ getTracks: () => [track] }));
@@ -26,6 +26,7 @@ const { captureException, recorderState } = vi.hoisted(() => ({
audioStream: null as MediaStream | null,
hasSpoken: false,
permissionDenied: false,
micUnavailable: false,
recordingId: null as string | null,
mimeType: "audio/webm",
clearPermissionDenied: vi.fn(),
@@ -137,6 +138,7 @@ beforeEach(() => {
audioStream: null,
hasSpoken: false,
permissionDenied: false,
micUnavailable: false,
recordingId: null,
mimeType: "audio/webm",
});
@@ -1247,6 +1249,73 @@ describe("useBrainDumpStep — a refused microphone", () => {
expect(result.current.screen).toBe("processing");
});
it("keeps the typed text on its way when a retry is refused meanwhile", async () => {
let refuse!: () => void;
recorderState.start.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
refuse = () => {
recorderState.permissionDenied = true;
resolve(false);
};
}),
);
finalizeBrainDump.mockReturnValue(
deferred<ReturnType<typeof completed>>().promise,
);
const { result, rerender } = await renderRefused();
act(() => {
void result.current.handleRetryMicrophone();
result.current.setTypedText("I run a bakery.");
});
act(() => {
void result.current.handleSubmitTyped();
});
await waitFor(() => expect(result.current.screen).toBe("processing"));
await act(async () => {
refuse();
});
rerender();
expect(result.current.screen).toBe("processing");
});
it("sends the typed text once however fast it is submitted", async () => {
finalizeBrainDump.mockReturnValue(
deferred<ReturnType<typeof completed>>().promise,
);
const { result } = await renderRefused();
act(() => {
result.current.setTypedText("I run a bakery.");
});
act(() => {
void result.current.handleSubmitTyped();
void result.current.handleSubmitTyped();
});
await waitFor(() => expect(finalizeBrainDump).toHaveBeenCalledTimes(1));
});
it("keeps the unavailable notice, not the blocked one, while that retry runs", async () => {
recorderState.permissionDenied = false;
recorderState.micUnavailable = true;
recorderState.start.mockReturnValue(new Promise<boolean>(() => {}));
const { result } = await renderStep();
act(() => {
result.current.showTyping();
});
act(() => {
void result.current.handleRetryMicrophone();
});
expect(result.current.isMicBlocked).toBe(false);
expect(result.current.isMicUnavailable).toBe(true);
});
it("goes back to the orb from record instead when nothing was refused", async () => {
const { result } = await renderStep();
act(() => {
@@ -0,0 +1,75 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
interface Props {
position: string;
label: string;
text: string;
isQuote: boolean;
isHidden: boolean;
// Null where the card should hold still: reduced motion, or a screen too
// narrow to show the layer at all.
drift: { durationSecs: number; delaySecs: number } | null;
reduceMotion: boolean;
}
export function AmbientCard({
position,
label,
text,
isQuote,
isHidden,
drift,
reduceMotion,
}: Props) {
return (
<motion.div
className={cn(
"absolute w-[196px] rounded-xl bg-surface-hover px-3 py-2.5 transition-opacity duration-500",
position,
isHidden && "opacity-0",
)}
animate={drift ? { y: [0, -6, 0] } : undefined}
transition={
drift
? {
duration: drift.durationSecs,
delay: drift.delaySecs,
repeat: Infinity,
ease: "easeInOut",
}
: undefined
}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={text}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.4 }}
className={cn(isQuote && "italic")}
>
<Text
variant="small-semibold"
as="span"
className="block text-caption-sm uppercase tracking-eyebrow text-accent-strong"
>
{label}
</Text>
<Text
variant="body"
as="span"
tone="secondary"
className="block text-body-sm"
>
{text}
</Text>
</motion.div>
</AnimatePresence>
</motion.div>
);
}
@@ -1,10 +1,9 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import type { ScreenState } from "../useBrainDumpStep";
import { useIsWideLayout } from "../useIsWideLayout";
import { AmbientCard } from "./AmbientCard";
import { useAmbientPrompts } from "./useAmbientPrompts";
interface Props {
@@ -70,58 +69,27 @@ export function AmbientPrompts({ role, screen }: Props) {
>
{CARDS.map((card) => {
const isQuote = "example" in card;
const text = isQuote
? `“${ambient.examples[card.example]}”`
: ambient.questions[card.question].text;
return (
<motion.div
<AmbientCard
key={card.position}
className={cn(
"absolute w-[196px] rounded-xl bg-surface-hover px-3 py-2.5 transition-opacity duration-500",
card.position,
isTyping && "besideComposer" in card && "opacity-0",
)}
animate={ambient.reduceMotion ? undefined : { y: [0, -6, 0] }}
transition={
ambient.reduceMotion
? undefined
: {
duration: card.driftSecs,
delay: card.delaySecs,
repeat: Infinity,
ease: "easeInOut",
}
position={card.position}
label={
isQuote ? "For example" : ambient.questions[card.question].label
}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={text}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: ambient.reduceMotion ? 0 : 0.4 }}
className={cn(isQuote && "italic")}
>
<Text
variant="small-semibold"
as="span"
className="block text-caption-sm uppercase tracking-eyebrow text-accent-strong"
>
{isQuote
? "For example"
: ambient.questions[card.question].label}
</Text>
<Text
variant="body"
as="span"
tone="secondary"
className="block text-body-sm"
>
{text}
</Text>
</motion.div>
</AnimatePresence>
</motion.div>
text={
isQuote
? `“${ambient.examples[card.example]}”`
: ambient.questions[card.question].text
}
isQuote={isQuote}
isHidden={isTyping && "besideComposer" in card}
drift={
isShown && !ambient.reduceMotion
? { durationSecs: card.driftSecs, delaySecs: card.delaySecs }
: null
}
reduceMotion={ambient.reduceMotion}
/>
);
})}
</div>
@@ -185,7 +185,8 @@ export function useBrainDumpRecorder({ promptSet }: Args = {}) {
durationSecs: 0,
finalized: false,
});
if (unmountedRef.current) {
// The caller can move on during the write too.
if (unmountedRef.current || shouldRecord?.() === false) {
stopTracks();
return false;
}
@@ -60,7 +60,7 @@ export function useBrainDumpStep() {
});
const microphone = useMicrophoneRetry({
recorder,
canRecord: () => screenRef.current === "typing",
canRecord: () => screenRef.current === "typing" && !isSubmittingRef.current,
onRecording: () => setScreen("recording"),
});
// A refused microphone is one fallback to typing per visit, however many
@@ -120,8 +120,11 @@ export function useBrainDumpStep() {
// Permission denial is never a dead end: the typing composer takes over
// under the same headline.
// A retry refused while the typed text is on its way leaves the submission
// alone: reopening the composer would let it be sent a second time.
useEffect(() => {
if (!recorder.permissionDenied) return;
if (isSubmittingRef.current || screenRef.current === "processing") return;
setScreen("typing");
if (typedFallbackTrackedRef.current) return;
typedFallbackTrackedRef.current = true;
@@ -358,7 +361,7 @@ export function useBrainDumpStep() {
async function handleSubmitTyped() {
const text = typedText.trim();
if (!text) return;
if (!text || isSubmittingRef.current) return;
isSubmittingRef.current = true;
setIsTypedTooLong(false);
try {
@@ -32,7 +32,9 @@ export function useMicrophoneRetry({ recorder, canRecord, onRecording }: Args) {
});
// A retry clears the refusal while the browser is asked again; the
// notice stays up through it and is refreshed in place if it is refused.
const [isRetrying, setIsRetrying] = useState(false);
// Only a retry after a refusal keeps the blocked notice up; one after an
// unavailable mic keeps that notice instead.
const [isRetryingRefusal, setIsRetryingRefusal] = useState(false);
const isRetryingRef = useRef(false);
function handlePermissionChange(state: MicrophonePermission) {
@@ -51,7 +53,7 @@ export function useMicrophoneRetry({ recorder, canRecord, onRecording }: Args) {
if (isRetryingRef.current) return;
isRetryingRef.current = true;
const permissionState = permission.state;
setIsRetrying(true);
setIsRetryingRefusal(recorder.permissionDenied);
recorder.clearPermissionDenied();
let started = false;
try {
@@ -67,14 +69,14 @@ export function useMicrophoneRetry({ recorder, canRecord, onRecording }: Args) {
});
} finally {
isRetryingRef.current = false;
setIsRetrying(false);
setIsRetryingRefusal(false);
}
if (started) onRecording();
else await permission.refresh();
}
return {
isBlocked: recorder.permissionDenied || isRetrying,
isBlocked: recorder.permissionDenied || isRetryingRefusal,
permissionState: permission.state,
retry,
};