diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/brain-dump.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/brain-dump.test.tsx
index 11f6450fb0c1..d80dbde05c30 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/brain-dump.test.tsx
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/brain-dump.test.tsx
@@ -195,6 +195,40 @@ class FakeMediaRecorder {
}
}
+class FakeAudioContext {
+ state: AudioContextState = "running";
+ sampleRate = 48_000;
+
+ createAnalyser() {
+ return {
+ fftSize: 256,
+ frequencyBinCount: 128,
+ smoothingTimeConstant: 0,
+ getByteTimeDomainData(samples: Uint8Array) {
+ samples.fill(128);
+ },
+ getByteFrequencyData(samples: Uint8Array) {
+ samples.fill(0);
+ },
+ } as unknown as AnalyserNode;
+ }
+
+ createMediaStreamSource() {
+ return {
+ connect() {},
+ disconnect() {},
+ } as unknown as MediaStreamAudioSourceNode;
+ }
+
+ resume() {
+ return Promise.resolve();
+ }
+
+ close() {
+ return Promise.resolve();
+ }
+}
+
const getUserMedia = vi.fn();
function installBrowserRecordingAPIs() {
@@ -203,6 +237,11 @@ function installBrowserRecordingAPIs() {
writable: true,
value: FakeMediaRecorder,
});
+ Object.defineProperty(globalThis, "AudioContext", {
+ configurable: true,
+ writable: true,
+ value: FakeAudioContext,
+ });
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
writable: true,
@@ -308,12 +347,41 @@ describe("onboarding brain dump — flag gating", () => {
render();
expect(await screen.findByText(DUMP_HEADLINE)).toBeDefined();
- expect(
- screen.getByRole("button", { name: "Start recording" }),
- ).toBeDefined();
+ expect(screen.getByRole("button", { name: "Start talking" })).toBeDefined();
+ expect(screen.queryByTestId("orb-progress-ring")).toBeNull();
expect(screen.queryByText(PILLBOX_HEADING)).toBeNull();
});
+ it("renders the original orb with reactive audio bars", async () => {
+ mockFlags = { "onboarding-brain-dump": true };
+ landOnPainPointsStep();
+
+ render();
+
+ expect(await screen.findByTestId("orb-current")).toBeDefined();
+ expect(screen.getByTestId("orb-frame").style.width).toBe("184px");
+ expect(screen.getByTestId("orb-decorative-ring")).toBeDefined();
+ expect(screen.getByTestId("orb-audio-bars")).toBeDefined();
+ const audioBars = screen.getAllByTestId("orb-audio-bar");
+ expect(audioBars).toHaveLength(5);
+ expect(audioBars.map((bar) => bar.style.height)).toEqual([
+ "22px",
+ "34px",
+ "46px",
+ "34px",
+ "22px",
+ ]);
+ expect(audioBars.map((bar) => bar.style.transform)).toEqual([
+ "scaleY(0.48)",
+ "scaleY(0.58)",
+ "scaleY(0.72)",
+ "scaleY(0.58)",
+ "scaleY(0.48)",
+ ]);
+ expect(screen.queryByRole("combobox", { name: "Orb style" })).toBeNull();
+ expect(screen.getByRole("button", { name: "Start talking" })).toBeDefined();
+ });
+
it("leaves the pillboxes untouched and makes no brain-dump request when the flag is off", async () => {
const calls = recordBrainDumpTraffic();
mockFlags = {};
@@ -326,9 +394,7 @@ describe("onboarding brain dump — flag gating", () => {
screen.getByText("Pick the tasks you'd love to hand off to AutoPilot"),
).toBeDefined();
expect(screen.queryByText(DUMP_HEADLINE)).toBeNull();
- expect(
- screen.queryByRole("button", { name: "Start recording" }),
- ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Start talking" })).toBeNull();
expect(screen.queryByText("Skip for now")).toBeNull();
// Give any stray effect a chance to fire before declaring silence.
@@ -366,7 +432,7 @@ describe("onboarding brain dump — typed fallback", () => {
await screen.findByText(DUMP_HEADLINE);
await userEvent.click(
- screen.getByRole("button", { name: "Start recording" }),
+ screen.getByRole("button", { name: "Start talking" }),
);
expect(
@@ -376,9 +442,7 @@ describe("onboarding brain dump — typed fallback", () => {
).toBeDefined();
// Same headline, not a dead end.
expect(screen.getByText(DUMP_HEADLINE)).toBeDefined();
- expect(
- screen.queryByRole("button", { name: "Start recording" }),
- ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Start talking" })).toBeNull();
// Offering a way back to the orb would be a dead end here: the browser
// has already refused the microphone.
expect(screen.queryByRole("button", { name: "record instead" })).toBeNull();
@@ -454,13 +518,13 @@ describe("onboarding brain dump — finishing a take", () => {
await screen.findByText(DUMP_HEADLINE);
await userEvent.click(
- screen.getByRole("button", { name: "Start recording" }),
+ screen.getByRole("button", { name: "Start talking" }),
);
+ expect(await screen.findByTestId("orb-progress-ring")).toBeDefined();
await waitFor(() => expect(partUploads).toHaveLength(1));
- const doneButtons = await screen.findAllByRole("button", {
- name: "I'm done",
- });
- await userEvent.click(doneButtons[doneButtons.length - 1]);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Send recording" }),
+ );
expect(await screen.findByTestId("step-preparing")).toBeDefined();
expect(bodies).toHaveLength(1);
@@ -501,17 +565,101 @@ describe("onboarding brain dump — finishing a take", () => {
expect(screen.getByRole("button", { name: "Skip for now" })).toBeDefined();
await userEvent.click(
- screen.getByRole("button", { name: "Start recording" }),
+ screen.getByRole("button", { name: "Start talking" }),
);
await waitFor(() => expect(partUploads).toHaveLength(1));
- const doneButtons = await screen.findAllByRole("button", {
- name: "I'm done",
- });
- await userEvent.click(doneButtons[doneButtons.length - 1]);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Send recording" }),
+ );
expect(await screen.findByText("Got it. One second…")).toBeDefined();
expect(screen.queryByRole("button", { name: "Skip for now" })).toBeNull();
});
+
+ it("shows immediate progress while canceling a recording", async () => {
+ let finishDiscard: (() => void) | undefined;
+ recordBrainDumpTraffic();
+ server.use(
+ getDiscardBrainDumpMockHandler200(async () => {
+ await new Promise((resolve) => {
+ finishDiscard = resolve;
+ });
+ return { status: null };
+ }),
+ );
+ mockFlags = { "onboarding-brain-dump": true };
+ landOnPainPointsStep();
+
+ render();
+ await screen.findByText(DUMP_HEADLINE);
+ await userEvent.click(
+ screen.getByRole("button", { name: "Start talking" }),
+ );
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Cancel recording" }),
+ );
+
+ expect(await screen.findByText("Discard recording?")).toBeDefined();
+ expect(
+ screen.getByText(/This permanently deletes your current take/),
+ ).toBeDefined();
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Keep recording" }),
+ );
+ expect(screen.queryByText("Discard recording?")).toBeNull();
+ expect(finishDiscard).toBeUndefined();
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Cancel recording" }),
+ );
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Discard recording" }),
+ );
+
+ expect(screen.getByTestId("recording-feedback-slot")).toBeDefined();
+ const cancelingButton = (await screen.findByRole("button", {
+ name: "Canceling recording",
+ })) as HTMLButtonElement;
+ expect(cancelingButton.getAttribute("aria-busy")).toBe("true");
+ expect(await screen.findByTestId("recording-control-loader")).toBeDefined();
+ expect(await screen.findByText("Discarding this take…")).toBeDefined();
+ expect(
+ (
+ screen.getByRole("button", {
+ name: "Send recording",
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false);
+ expect(
+ screen
+ .getByRole("button", { name: "Send recording" })
+ .getAttribute("aria-disabled"),
+ ).toBe("true");
+ expect(
+ (
+ screen.getByRole("button", {
+ name: "Retry recording",
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false);
+ expect(
+ screen
+ .getByRole("button", { name: "Retry recording" })
+ .getAttribute("aria-disabled"),
+ ).toBe("true");
+ expect(cancelingButton.disabled).toBe(false);
+ expect(cancelingButton.getAttribute("aria-disabled")).toBe("true");
+ await waitFor(() => expect(document.activeElement).toBe(cancelingButton));
+ await userEvent.click(cancelingButton);
+ expect(screen.queryByText("Discard recording?")).toBeNull();
+
+ await waitFor(() => expect(finishDiscard).toBeDefined());
+ finishDiscard!();
+ expect(
+ await screen.findByRole("button", { name: "Start talking" }),
+ ).toBeDefined();
+ });
});
describe("onboarding brain dump — recovery", () => {
@@ -576,9 +724,7 @@ describe("onboarding brain dump — recovery", () => {
await waitFor(() =>
expect(screen.queryByText("Pick up where you left off?")).toBeNull(),
);
- expect(
- screen.getByRole("button", { name: "Start recording" }),
- ).toBeDefined();
+ expect(screen.getByRole("button", { name: "Start talking" })).toBeDefined();
});
});
@@ -607,16 +753,15 @@ describe("onboarding brain dump — failure", () => {
await screen.findByText(DUMP_HEADLINE);
await userEvent.click(
- screen.getByRole("button", { name: "Start recording" }),
+ screen.getByRole("button", { name: "Start talking" }),
);
- // Wait for the first chunk to reach the server so "I'm done" is not
+ // Wait for the first chunk to reach the server so sending is not
// racing the upload queue.
await waitFor(() => expect(partUploads).toHaveLength(1));
- const doneButtons = await screen.findAllByRole("button", {
- name: "I'm done",
- });
- await userEvent.click(doneButtons[doneButtons.length - 1]);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Send recording" }),
+ );
expect(await screen.findByText("That didn't go through.")).toBeDefined();
// The failure has to come from finalize reporting `failed`, not from the
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/BrainDumpStep.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/BrainDumpStep.tsx
index 145967ce2ad5..dc904ca3a362 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/BrainDumpStep.tsx
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/BrainDumpStep.tsx
@@ -1,29 +1,30 @@
"use client";
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
-import { ElapsedTime } from "./components/ElapsedTime";
import { FailureState } from "./components/FailureState";
import { DEFAULT_GLASS_PARAMS } from "@/components/molecules/GlassOrb/GlassSurface";
+import { ElapsedTime } from "./components/ElapsedTime";
import { MicButton, OrbScreen } from "./components/MicButton";
import { PrivacyNote } from "./components/PrivacyNote";
-import { RecordingStatus } from "./components/RecordingStatus";
+import { RecordingControls } from "./components/RecordingControls/RecordingControls";
import { RecoveryPrompt } from "./components/RecoveryPrompt";
import { RevealGroup, RevealItem } from "@/components/atoms/Reveal/Reveal";
import { SwapFade } from "@/components/atoms/SwapFade/SwapFade";
-import { TapHint } from "./components/TapHint";
import { TypedFallback } from "./components/TypedFallback";
+import { OrbControlButton } from "./components/OrbControlButton";
import { ringProgress } from "./helpers";
import { ScreenState, useBrainDumpStep } from "./useBrainDumpStep";
-const MIC_CAPTION = "Tap and talk. Most people go 2 to 3 minutes.";
const FAILURE_HEADLINE = "That didn't go through.";
const TIME_LIMIT_CAPTION =
"That's 30 minutes — the most we record in one go. Saving all of it…";
export function BrainDumpStep() {
const dump = useBrainDumpStep();
+ const prefersReducedMotion = useReducedMotion();
const isRecording = dump.screen === "recording";
const isProcessing = dump.screen === "processing";
const isMicScreen = dump.screen === "rest" || isRecording;
@@ -36,28 +37,46 @@ export function BrainDumpStep() {
function orbClick(screen: OrbScreen) {
if (screen === "processing") return undefined;
if (screen === "failed") return dump.handleRetry;
- return screen === "recording" ? dump.handleDone : dump.handleStart;
+ return screen === "rest" ? dump.handleStart : undefined;
}
return (
<>
-
+ {isRecording && (
+
)}
+
+
+
-
- {isRecording && (
-
+ {isRecording && (
+
+
+
+ )}
+
+
{/* Skipping mid-submit would advance the wizard a second time
behind the finalize that is already in flight, landing past
the last step on a blank screen. */}
@@ -72,16 +91,21 @@ export function BrainDumpStep() {
)}
+ )}
- {showSubline && }
+ {showSubline && !isRecording && }
>
);
}
@@ -243,11 +273,5 @@ function OrbCaption({
);
}
- // The failure copy sits with its buttons in FailureState, so it is not
- // held back by the swap's exit animation.
- if (screen === "failed") return null;
-
- if (screen === "recording") return null;
-
- return ;
+ return null;
}
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/helpers.test.ts
index c71501288937..4ef78b2d0960 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/helpers.test.ts
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/helpers.test.ts
@@ -5,6 +5,7 @@ import {
headline,
isPermissionDenied,
pickMimeType,
+ recordingFeedbackAt,
RING_TARGET_SECONDS,
ringProgress,
} from "../helpers";
@@ -22,26 +23,53 @@ describe("headline", () => {
describe("encouragementAt", () => {
it("shows nothing before the first line is due", () => {
expect(encouragementAt(0)).toBeNull();
- expect(encouragementAt(9.9)).toBeNull();
+ expect(encouragementAt(19.9)).toBeNull();
});
it("shows a line for six seconds and then goes quiet", () => {
- expect(encouragementAt(10)).toBe("Keep going, this is gold");
- expect(encouragementAt(15.9)).toBe("Keep going, this is gold");
- expect(encouragementAt(16)).toBeNull();
+ expect(encouragementAt(20)).toBe("Keep going, this is gold");
+ expect(encouragementAt(25.9)).toBe("Keep going, this is gold");
+ expect(encouragementAt(26)).toBeNull();
});
- // After the last line the screen stays quiet — a nag every 20s would
- // turn encouragement into pressure.
- it("stops encouraging after the last line", () => {
- expect(encouragementAt(45)).toBe(
- "You're building AutoPilot's memory right now",
- );
- expect(encouragementAt(51)).toBeNull();
+ it("uses twenty-second milestones through the second minute", () => {
+ expect(encouragementAt(40)).not.toBeNull();
+ expect(encouragementAt(60)).not.toBeNull();
+ expect(encouragementAt(80)).not.toBeNull();
+ expect(encouragementAt(100)).not.toBeNull();
+ expect(encouragementAt(120)).not.toBeNull();
+ expect(encouragementAt(126)).toBeNull();
+ });
+
+ it("uses thirty-second milestones after the second minute", () => {
+ expect(encouragementAt(149.9)).toBeNull();
+ expect(encouragementAt(150)).not.toBeNull();
+ expect(encouragementAt(180)).not.toBeNull();
+ expect(encouragementAt(240)).not.toBeNull();
+ expect(encouragementAt(360)).not.toBeNull();
+ });
+
+ it("stays quiet after the six-minute message", () => {
+ expect(encouragementAt(366)).toBeNull();
expect(encouragementAt(600)).toBeNull();
});
});
+describe("recordingFeedbackAt", () => {
+ it("shows duration guidance only after recording has settled in", () => {
+ expect(recordingFeedbackAt(3.9)).toBeNull();
+ expect(recordingFeedbackAt(4)).toBe("Most people talk for 2 to 3 minutes.");
+ expect(recordingFeedbackAt(9.9)).toBe(
+ "Most people talk for 2 to 3 minutes.",
+ );
+ });
+
+ it("shows the first encouragement at twenty seconds", () => {
+ expect(recordingFeedbackAt(10)).toBeNull();
+ expect(recordingFeedbackAt(20)).toBe("Keep going, this is gold");
+ });
+});
+
describe("formatElapsed", () => {
it("pads the seconds and floors the fraction", () => {
expect(formatElapsed(0)).toBe("0:00");
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpRecorder.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpRecorder.test.ts
index 9858f9a08f11..3e2c5e94f23d 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpRecorder.test.ts
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpRecorder.test.ts
@@ -75,6 +75,12 @@ class SlowStoppingMediaRecorder {
}
}
+class ThrowingStopMediaRecorder extends SlowStoppingMediaRecorder {
+ stop() {
+ throw new Error("encoder failed to stop");
+ }
+}
+
// Hands a chunk over on demand, so the chunk → IndexedDB → queue
// ordering can be observed a step at a time.
class ChunkingMediaRecorder {
@@ -417,6 +423,52 @@ describe("useBrainDumpRecorder", () => {
expect(result.current.hitTimeLimit).toBe(false);
});
+ it("releases recording resources when stopping fails", async () => {
+ vi.stubGlobal("MediaRecorder", ThrowingStopMediaRecorder);
+ const track = { stop: vi.fn() };
+ const closeAudioContext = vi.fn().mockResolvedValue(undefined);
+ stubGetUserMedia(vi.fn().mockResolvedValue({ getTracks: () => [track] }));
+ vi.stubGlobal(
+ "AudioContext",
+ class {
+ close = closeAudioContext;
+ createAnalyser() {
+ return {
+ fftSize: 0,
+ frequencyBinCount: 8,
+ getByteTimeDomainData: vi.fn(),
+ };
+ }
+ createMediaStreamSource() {
+ return { connect: vi.fn() };
+ }
+ },
+ );
+ const { result } = renderHook(() => useBrainDumpRecorder());
+
+ await act(async () => {
+ await result.current.start();
+ });
+
+ let stopError: unknown;
+ await act(async () => {
+ try {
+ await result.current.stop();
+ } catch (error) {
+ stopError = error;
+ }
+ });
+
+ const elapsedAfterFailure = result.current.elapsedSeconds;
+ act(() => vi.advanceTimersByTime(1_000));
+
+ expect(stopError).toEqual(new Error("encoder failed to stop"));
+ expect(track.stop).toHaveBeenCalledOnce();
+ expect(closeAudioContext).toHaveBeenCalledOnce();
+ expect(result.current.phase).toBe("stopped");
+ expect(result.current.elapsedSeconds).toBe(elapsedAfterFailure);
+ });
+
// The regression: the meta row was written with `durationSecs: 0` at
// start and only refreshed by `stop()`. A crash or a refresh — the exact
// case recovery exists for — never gets there, so the prompt offered
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpStep.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpStep.test.ts
index 5cc4cd7ddf68..9f1157a8772e 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpStep.test.ts
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/__tests__/useBrainDumpStep.test.ts
@@ -6,7 +6,8 @@ import type { RecordingPart } from "../recordingStore";
// `recorderState` after a render does NOT reach the handlers already
// closed over — which is why the hook passes ids and durations around by
// argument instead of reading them off the recorder.
-const { recorderState } = vi.hoisted(() => ({
+const { captureException, recorderState } = vi.hoisted(() => ({
+ captureException: vi.fn(),
recorderState: {
phase: "idle" as "idle" | "recording" | "stopping" | "stopped",
hitTimeLimit: false,
@@ -56,6 +57,8 @@ vi.mock("@/services/onboarding/brain-dump-analytics", () => ({
trackBrainDump: (...args: unknown[]) => trackBrainDump(...args),
}));
+vi.mock("@sentry/nextjs", () => ({ captureException }));
+
import { useOnboardingWizardStore } from "../../../store";
import { useBrainDumpStep } from "../useBrainDumpStep";
@@ -168,6 +171,99 @@ describe("useBrainDumpStep — recording", () => {
expect(result.current.screen).toBe("recording");
});
+ it("tracks cancel and discards the active take", async () => {
+ recorderState.recordingId = "rec-1";
+ const { result } = await renderStep();
+
+ await act(async () => {
+ await result.current.handleStop();
+ });
+
+ expect(events()).toContain("brain_dump_canceled");
+ expect(clearRecording).toHaveBeenCalledWith("rec-1");
+ expect(discardBrainDump).toHaveBeenCalledWith({ recording_id: "rec-1" });
+ expect(result.current.screen).toBe("rest");
+ });
+
+ it("returns to rest and discards the take when stopping fails", async () => {
+ recorderState.recordingId = "rec-1";
+ recorderState.stop.mockRejectedValueOnce(new Error("stop failed"));
+ const { result } = await renderStep();
+
+ await act(async () => {
+ await result.current.handleStop();
+ });
+
+ expect(result.current.screen).toBe("rest");
+ expect(discardBrainDump).toHaveBeenCalledWith({ recording_id: "rec-1" });
+ expect(captureException).toHaveBeenCalled();
+ });
+
+ it("does not submit a take while cancellation owns it", async () => {
+ const stopping = deferred();
+ recorderState.recordingId = "rec-1";
+ recorderState.stop.mockReturnValue(stopping.promise);
+ const { result, rerender } = await renderStep();
+
+ await act(async () => {
+ await result.current.handleStart();
+ });
+
+ let cancelPromise!: Promise;
+ act(() => {
+ cancelPromise = result.current.handleStop();
+ });
+ act(() => {
+ recorderState.hitTimeLimit = true;
+ rerender();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(finalizeBrainDump).not.toHaveBeenCalled();
+ expect(result.current.screen).toBe("rest");
+
+ await act(async () => {
+ stopping.resolve(30 * 60);
+ await cancelPromise;
+ });
+
+ expect(discardBrainDump).toHaveBeenCalledWith({ recording_id: "rec-1" });
+ expect(result.current.screen).toBe("rest");
+ });
+
+ it("does not discard a take while time-limit submission owns it", async () => {
+ const finalizing = deferred>();
+ recorderState.recordingId = "rec-1";
+ finalizeBrainDump.mockReturnValue(finalizing.promise);
+ const { result, rerender } = await renderStep();
+
+ await act(async () => {
+ await result.current.handleStart();
+ });
+ act(() => {
+ recorderState.hitTimeLimit = true;
+ rerender();
+ });
+ await waitFor(() => expect(finalizeBrainDump).toHaveBeenCalled());
+
+ await act(async () => {
+ await result.current.handleStop();
+ });
+
+ expect(discardBrainDump).not.toHaveBeenCalled();
+ expect(recorderState.stop).not.toHaveBeenCalled();
+
+ await act(async () => {
+ finalizing.resolve(completed());
+ await Promise.resolve();
+ });
+ await waitFor(() =>
+ expect(useOnboardingWizardStore.getState().currentStep).toBe(2),
+ );
+ });
+
// The nudge is for someone who has not started talking yet, so it keys
// on whether the mic has heard anything — not on elapsed time alone.
it("nudges only while recording, past the threshold, and still silent", async () => {
@@ -284,6 +380,20 @@ describe("useBrainDumpStep — the 30-minute cap", () => {
});
describe("useBrainDumpStep — finishing a take", () => {
+ it("shows the failure state when stopping the recorder fails", async () => {
+ recorderState.recordingId = "rec-1";
+ recorderState.stop.mockRejectedValueOnce(new Error("stop failed"));
+ const { result } = await renderStep();
+
+ await act(async () => {
+ await result.current.handleDone();
+ });
+
+ expect(result.current.screen).toBe("failed");
+ expect(finalizeBrainDump).not.toHaveBeenCalled();
+ expect(captureException).toHaveBeenCalled();
+ });
+
it("finalizes with the duration reported by stop(), not the last render", async () => {
// The render's value is stale by however long stopping took, and the
// backend splits long recordings on this number.
@@ -461,6 +571,23 @@ describe("useBrainDumpStep — restart", () => {
expect(result.current.screen).not.toBe("failed");
});
+ it("starts a fresh take when stopping the previous recorder fails", async () => {
+ recorderState.recordingId = "rec-old";
+ recorderState.stop.mockRejectedValueOnce(new Error("stop failed"));
+ const { result } = await renderStep();
+
+ await act(async () => {
+ await result.current.handleRestart();
+ });
+
+ expect(discardBrainDump).toHaveBeenCalledWith({
+ recording_id: "rec-old",
+ });
+ expect(recorderState.start).toHaveBeenCalled();
+ expect(result.current.screen).toBe("recording");
+ expect(captureException).toHaveBeenCalled();
+ });
+
it("drops back to rest when the mic does not reopen", async () => {
recorderState.recordingId = "rec-old";
recorderState.start.mockResolvedValue(false);
@@ -824,6 +951,35 @@ describe("useBrainDumpStep — recovery", () => {
expect(events()).toContain("brain_dump_recovery_used");
});
+ it("does not leave recovery while its submission owns the take", async () => {
+ const finalizing = deferred>();
+ recorderState.findRecoverable.mockResolvedValue(recovered);
+ getParts.mockResolvedValue([part(0, "rec-crashed")]);
+ finalizeBrainDump.mockReturnValue(finalizing.promise);
+ const { result } = await renderStep();
+ await waitFor(() => expect(result.current.screen).toBe("recovery"));
+
+ act(() => {
+ void result.current.handleResumeRecovered();
+ });
+ await waitFor(() => expect(finalizeBrainDump).toHaveBeenCalled());
+
+ await act(async () => {
+ await result.current.handleTypeInsteadOfRecovered();
+ });
+
+ expect(result.current.screen).toBe("processing");
+ expect(result.current.recoverable).toEqual(recovered);
+
+ await act(async () => {
+ finalizing.resolve(completed());
+ await Promise.resolve();
+ });
+ await waitFor(() =>
+ expect(useOnboardingWizardStore.getState().currentStep).toBe(2),
+ );
+ });
+
it("releases the server buffer when the take is abandoned", async () => {
recorderState.findRecoverable.mockResolvedValue(recovered);
getParts.mockResolvedValue([part(0, "rec-crashed")]);
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/MicButton.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/MicButton.tsx
index 80ea74ccdfd7..8af6975bb677 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/MicButton.tsx
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/MicButton.tsx
@@ -1,34 +1,13 @@
-"use client";
-
-import { Icon } from "@/components/atoms/Icon/Icon";
-import {
- ArrowReloadHorizontalIcon,
- HandIcon,
- Mic01Icon,
-} from "@hugeicons/core-free-icons";
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { AudioWaveform } from "@/app/(platform)/copilot/components/ChatInput/components/AudioWaveform";
import { GlassParams } from "@/components/molecules/GlassOrb/GlassSurface";
import { OrbFrame } from "./OrbFrame";
export type OrbScreen = "rest" | "recording" | "processing" | "failed";
-const GLYPH_CLASS =
- "text-white/95 drop-shadow-[0_2px_12px_rgba(90,40,180,0.5)]";
-
-const ARIA_LABEL: Record = {
- rest: "Start recording",
- recording: "I'm done",
- processing: undefined,
- failed: "Try again",
-};
-
interface Props {
screen: OrbScreen;
progress: number;
audioStream: MediaStream | null;
glassParams: GlassParams;
- onClick?: () => void;
}
export function MicButton({
@@ -36,82 +15,13 @@ export function MicButton({
progress,
audioStream,
glassParams,
- onClick,
}: Props) {
- const prefersReducedMotion = useReducedMotion();
-
return (
- {/* Only the glyph inside the orb changes as the step advances — the
- orb, the ring and the button itself stay mounted throughout. */}
-
-
-
-
-
-
+ />
);
}
-
-function OrbGlyph({
- screen,
- audioStream,
-}: {
- screen: OrbScreen;
- audioStream: MediaStream | null;
-}) {
- if (screen === "processing") {
- return ;
- }
-
- if (screen === "failed") {
- return (
-
- );
- }
-
- if (screen === "recording") {
- return (
-
- );
- }
-
- return ;
-}
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbControlButton.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbControlButton.tsx
new file mode 100644
index 000000000000..1db9296f52c3
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbControlButton.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { Icon } from "@/components/atoms/Icon/Icon";
+import { SwapFade } from "@/components/atoms/SwapFade/SwapFade";
+import {
+ ArrowReloadHorizontalIcon,
+ Mic01Icon,
+} from "@hugeicons/core-free-icons";
+
+interface Props {
+ screen: "rest" | "failed";
+ onClick?: () => void;
+}
+
+export function OrbControlButton({ screen, onClick }: Props) {
+ const ariaLabel = screen === "failed" ? "Try again" : "Start talking";
+
+ return (
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbFrame.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbFrame.tsx
index 4c28bc019e97..745d5723ac37 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbFrame.tsx
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbFrame.tsx
@@ -1,10 +1,10 @@
"use client";
-import { ReactNode } from "react";
-import { GlassOrb } from "@/components/molecules/GlassOrb/GlassOrb";
import { GlassParams } from "@/components/molecules/GlassOrb/GlassSurface";
+import { OrbVisual } from "./OrbVisual";
+import { useAudioBars } from "./useAudioBars";
-export const ORB_SIZE = 160;
+export const ORB_SIZE = 184;
const STROKE = 6;
const RADIUS = (ORB_SIZE - STROKE) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -14,30 +14,32 @@ const LOADER_ARC = CIRCUMFERENCE * 0.25;
interface Props {
glassParams: GlassParams;
+ audioStream: MediaStream | null;
// Omitted when there is nothing to meter — the arc is not rendered at all.
progress?: number;
// Indeterminate: a single arc chasing the ring while work is in flight.
isLoading?: boolean;
- onClick?: () => void;
- ariaLabel?: string;
- children?: ReactNode;
}
// The orb as it appears everywhere in this step: the same glass ball seated
// in the same neumorphic ring, whether or not it is interactive.
export function OrbFrame({
glassParams,
+ audioStream,
progress,
isLoading,
- onClick,
- ariaLabel,
- children,
}: Props) {
- const orb = {children};
+ const isRecording = progress !== undefined;
+ const audioBars = useAudioBars(isRecording ? audioStream : null);
return (
-
+
- {progress !== undefined && (
+ {progress !== undefined && (
+
-
+
+ )}
{isLoading && (
)}
- {/* Always a button, even when inert: swapping the element type would
- tear down the orb and replay its animation on every state change. */}
-
+
+
+
);
}
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbVisual.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbVisual.tsx
new file mode 100644
index 000000000000..3e212b0ae7fd
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/BrainDumpStep/components/OrbVisual.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { GlassOrb } from "@/components/molecules/GlassOrb/GlassOrb";
+import { GlassParams } from "@/components/molecules/GlassOrb/GlassSurface";
+import {
+ motion,
+ type MotionValue,
+ useReducedMotion,
+ useTransform,
+} from "framer-motion";
+import { type AudioBarLevels } from "./useAudioBars";
+
+interface Props {
+ glassParams: GlassParams;
+ audioBars: AudioBarLevels;
+ isRecording: boolean;
+}
+
+export function OrbVisual({ glassParams, audioBars, isRecording }: Props) {
+ return (
+