fix(frontend/onboarding): explain a busy mic on retry, keep starter cards still while recording

- A retry that fails because the microphone is missing or held by another
  app now says so on the typing screen, with its own "try again" link; the
  blocked notice used to vanish with nothing in its place.
- The ambient starter cards no longer swap while recording, and run no
  timer where the layer is not on screen.
- On wide screens the rest-screen "Otto might ask" question stays in the
  accessibility tree (`xl:sr-only`), since the ambient cards are hidden
  from assistive tech.

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 17:35:15 +09:00
1 parent 68cedd65a6
commit 40dc79a557
9 files changed
+102 -10

No files matched your search

@@ -1528,6 +1528,26 @@ describe("onboarding brain dump — a refused microphone", () => {
expect(screen.getByText(TYPING_HEADLINE)).toBeDefined();
});
it("says so when the retry finds the microphone busy", async () => {
stubMicrophonePermission("denied");
getUserMedia.mockRejectedValueOnce(REFUSED);
getUserMedia.mockRejectedValueOnce(
new DOMException("", "NotReadableError"),
);
await pressTalkAndGetRefused();
await userEvent.click(
screen.getByRole("button", { name: "try the microphone again" }),
);
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"We couldn't open your microphone, so write here instead.",
),
);
expect(screen.getByText(TYPING_HEADLINE)).toBeDefined();
});
it("clears the notice once the site is allowed from the address bar", async () => {
const permission = stubMicrophonePermission("denied");
getUserMedia.mockRejectedValue(REFUSED);
@@ -95,6 +95,7 @@ export function BrainDumpStep() {
announcement={dump.prompt.announcement}
onAnotherQuestion={showAnotherQuestion}
isMicBlocked={dump.isMicBlocked}
isMicUnavailable={dump.isMicUnavailable}
micPermission={dump.micPermission}
onRetryMicrophone={dump.handleRetryMicrophone}
/>
@@ -4,6 +4,7 @@ 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 { useAmbientPrompts } from "./useAmbientPrompts";
interface Props {
@@ -50,7 +51,11 @@ const HIDDEN_ON: ScreenState[] = ["recovery", "failed", "insufficient"];
// border, shadow, hover or focus, and hidden from assistive tech, so that
// nobody mistakes them for buttons. The live prompt is the accessible one.
export function AmbientPrompts({ role, screen }: Props) {
const ambient = useAmbientPrompts(role);
const isShown = useIsWideLayout() && !HIDDEN_ON.includes(screen);
const ambient = useAmbientPrompts({
role,
isRotating: isShown && screen !== "recording",
});
if (HIDDEN_ON.includes(screen)) return null;
const isTyping = screen === "typing";
@@ -8,6 +8,9 @@ import type { MicrophonePermission } from "../useMicrophonePermission";
interface Props {
isBlocked: boolean;
// No microphone, or one another app holds: fixed on the device, not in
// the browser.
isUnavailable?: boolean;
permission: MicrophonePermission;
onRetry: () => void;
}
@@ -25,6 +28,11 @@ const COPY = {
},
// The site is allowed but the operating system refused the browser, as
// Chrome on a Mac without microphone access in System Settings does.
unavailable: {
reason: "We couldn't open your microphone, so write here instead.",
howTo:
"To talk, check that it's connected and no other app is using it, then",
},
system: {
reason: "Your computer blocked the microphone, so write here instead.",
howTo:
@@ -32,9 +40,14 @@ const COPY = {
},
} as const;
export function MicBlockedNotice({ isBlocked, permission, onRetry }: Props) {
if (!isBlocked) return null;
const copy = noticeCopy(permission);
export function MicBlockedNotice({
isBlocked,
isUnavailable = false,
permission,
onRetry,
}: Props) {
if (!isBlocked && !isUnavailable) return null;
const copy = isBlocked ? noticeCopy(permission) : COPY.unavailable;
return (
<div
@@ -58,8 +58,10 @@ export function StepHeader({
</Text>{" "}
A minute is plenty, and you can type instead.
</Text>
{/* On wide screens the ambient cards carry these questions. */}
<OttoMightAsk question={question} compact className="xl:hidden" />
{/* On wide screens the ambient cards carry these questions, and
they are hidden from assistive tech, so this one stays in the
accessibility tree. */}
<OttoMightAsk question={question} compact className="xl:sr-only" />
</RevealItem>
)}
{screen === "typing" && (
@@ -16,6 +16,7 @@ interface Props {
announcement: string;
onAnotherQuestion: () => void;
isMicBlocked: boolean;
isMicUnavailable: boolean;
micPermission: MicrophonePermission;
onRetryMicrophone: () => void;
}
@@ -29,10 +30,12 @@ export function TypingComposer({
announcement,
onAnotherQuestion,
isMicBlocked,
isMicUnavailable,
micPermission,
onRetryMicrophone,
}: Props) {
const isShortScreen = useIsShortScreen();
const hasNotice = isMicBlocked || isMicUnavailable;
return (
<div
@@ -40,6 +43,7 @@ export function TypingComposer({
>
<MicBlockedNotice
isBlocked={isMicBlocked}
isUnavailable={isMicUnavailable}
permission={micPermission}
onRetry={onRetryMicrophone}
/>
@@ -57,7 +61,7 @@ export function TypingComposer({
onChange={onChange}
isTooLong={isTooLong}
onSubmit={onSubmit}
rows={(isShortScreen ? 4 : 6) - (isMicBlocked ? 1 : 0)}
rows={(isShortScreen ? 4 : 6) - (hasNotice ? 1 : 0)}
/>
</div>
);
@@ -2,8 +2,13 @@ import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { motionPreference } = vi.hoisted(() => ({
const { motionPreference, device } = vi.hoisted(() => ({
motionPreference: { reduced: false },
device: { isWide: true },
}));
vi.mock("../../useIsWideLayout", () => ({
useIsWideLayout: () => device.isWide,
}));
// Exit animations keep the outgoing card mounted until a frame that fake
@@ -39,6 +44,7 @@ function shownQuestions() {
beforeEach(() => {
vi.useFakeTimers();
motionPreference.reduced = false;
device.isWide = true;
});
afterEach(() => {
@@ -119,6 +125,24 @@ describe("AmbientPrompts", () => {
},
);
it("holds its questions still while recording", async () => {
render(<AmbientPrompts role="Operations" screen="recording" />);
const before = shownQuestions();
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(shownQuestions()).toEqual(before);
});
it("runs no timer where the layer does not fit", () => {
device.isWide = false;
render(<AmbientPrompts role="Operations" screen="rest" />);
expect(vi.getTimerCount()).toBe(0);
});
it.each(["recovery", "failed", "insufficient"] as const)(
"is gone on the %s screen",
(screenState) => {
@@ -58,6 +58,21 @@ describe("MicBlockedNotice", () => {
expect(alert.textContent).toBe(BLOCKED);
});
it("explains a microphone that could not be opened", () => {
render(
<MicBlockedNotice
isBlocked={false}
isUnavailable
permission="granted"
onRetry={vi.fn()}
/>,
);
expect(screen.getByRole("alert").textContent).toBe(
"We couldn't open your microphone, so write here instead. To talk, check that it's connected and no other app is using it, then try the microphone again.",
);
});
it("renders nothing while the microphone is not blocked", () => {
const { container } = render(
<MicBlockedNotice
@@ -7,13 +7,21 @@ const REDUCED_MOTION_SWAP_MS = 14_000;
// Four cards carry questions; the two quotes never change.
const QUESTION_CARDS = 4;
export function useAmbientPrompts(role: string) {
interface Args {
role: string;
// Off while recording, so nothing changes under someone mid-answer, and
// wherever the layer is not on screen.
isRotating: boolean;
}
export function useAmbientPrompts({ role, isRotating }: Args) {
const promptSet = promptSetNameForRole(role);
const reduceMotion = useReducedMotion() === true;
const [rotation, setRotation] = useState({ promptSet, swaps: 0 });
const swaps = rotation.promptSet === promptSet ? rotation.swaps : 0;
useEffect(() => {
if (!isRotating) return;
const timer = setInterval(
() => {
if (document.visibilityState !== "visible") return;
@@ -25,7 +33,7 @@ export function useAmbientPrompts(role: string) {
reduceMotion ? REDUCED_MOTION_SWAP_MS : SWAP_MS,
);
return () => clearInterval(timer);
}, [promptSet, reduceMotion]);
}, [promptSet, reduceMotion, isRotating]);
const { questions, examples } = BRAIN_DUMP_PROMPT_SETS[promptSet];
return {