mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 04:41:59 -10:00
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:
1 parent
68cedd65a6
commit
40dc79a557
9 files changed
+102
-10
No files matched your search
+20
@@ -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);
|
||||
|
||||
+1
@@ -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}
|
||||
/>
|
||||
|
||||
+6
-1
@@ -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";
|
||||
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
+4
-2
@@ -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" && (
|
||||
|
||||
+5
-1
@@ -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>
|
||||
);
|
||||
|
||||
+25
-1
@@ -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) => {
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user