mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 17:48:43 -10:00
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:
1 parent
801c44f9ba
commit
d6abdc63d6
7 files changed
+199
-58
No files matched your search
+23
@@ -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] }));
|
||||
|
||||
+69
@@ -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(() => {
|
||||
|
||||
+75
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
-51
@@ -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>
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+5
-2
@@ -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 {
|
||||
|
||||
+6
-4
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user