Files
kandev/apps/web/components/task/chat/voice-model-load-indicator.test.tsx
T
João Salavisa 58dd2773a2 feat(web): show inline download progress for Whisper Web model (#1228)
* feat(web): show download progress for voice mode Whisper Web model

First-time use of voice mode with the Whisper Web engine downloads
~40–240 MB of model files before the first transcription. The existing
modelLoad progress was only surfaced via the mic-button tooltip, so the
download felt silent — users couldn't tell whether it was progressing,
stuck, or how much was left.

Adds a compact inline indicator next to the mic button that renders a
percent label and a Progress bar while the model is downloading, and a
short error label when load fails. The indicator stays hidden for the
Web Speech and server-Whisper engines (which don't download models) and
clears once the model is ready.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(web): tidy VoiceModelLoadIndicator class names and a11y

- drop unused cn() import (single-string className doesn't need it)
- drop redundant max-w-[8rem] (w-32 already equals 8rem in tailwind)
- include modelLabel in the error state's aria-label so screen readers
  identify which model failed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(web): a11y improvements for VoiceModelLoadIndicator

- Replace two separate loading/error divs with a single stable role="status"
  wrapper so AT announces the loading→error transition (newly-inserted live
  regions are silently ignored by most screen readers).
- Move numeric progress to role="progressbar" + aria-valuenow/min/max on the
  inner div; removes per-tick live-region announcements.
- Drop redundant aria-live="polite" (role="status" implies it per ARIA spec).
- Align error visible text with aria-label: both now use modelLabel
  ("Whisper Base failed to load") instead of the generic string.
- Standardize tooltip wording to match indicator: "Downloading {model}…"
  instead of "Loading model…".
- Update test: error branch expects model-specific label.

* refactor(web): fix nested progressbar and clamp tooltip percent

- Remove role="progressbar" + aria-value* from the wrapper div; <Progress>
  (Radix) already renders its own progressbar — the nested node confused AT.
  Pass aria-label directly to <Progress> for the accessible name.
- Drop outer aria-label on the loading state (Radix progressbar carries it).
- Clamp tooltip percent in voice-input-button.tsx to 0–100 and handle NaN
  (mirrors clampPercent in the indicator, keeps UI consistent on edge values
  from transformers.js).
- Update test: assert aria-label via getByRole("progressbar").getAttribute().

* fix(web): guard tooltip percent against NaN; assert error aria-label in test

- Tooltip loading percent: add Number.isFinite guard so NaN from
  transformers.js progress produces "0%" instead of "NaN%".
- Test: assert aria-label on error state root matches modelLabel.

* fix(web): add w-32 to error variant to prevent layout shift on state change

Loading branch uses w-32 to hold a stable 128px slot in the toolbar.
Error branch lacked it, so the mic button shifted on loading→error transition.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:24 +01:00

64 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { VoiceModelLoadIndicator } from "./voice-model-load-indicator";
const MODEL_LABEL = "Whisper Base";
const INDICATOR_TESTID = "voice-model-load-indicator";
function getIndicator() {
return screen.getByTestId(INDICATOR_TESTID);
}
afterEach(() => {
cleanup();
});
describe("VoiceModelLoadIndicator", () => {
it("renders nothing when state is idle", () => {
const { container } = render(
<VoiceModelLoadIndicator state="idle" progress={0} modelLabel={MODEL_LABEL} />,
);
expect(container.firstChild).toBeNull();
});
it("renders nothing when state is ready", () => {
const { container } = render(
<VoiceModelLoadIndicator state="ready" progress={1} modelLabel={MODEL_LABEL} />,
);
expect(container.firstChild).toBeNull();
});
it("renders progress bar and percent text when loading", () => {
render(<VoiceModelLoadIndicator state="loading" progress={0.42} modelLabel={MODEL_LABEL} />);
const root = getIndicator();
expect(root.getAttribute("data-state")).toBe("loading");
expect(root.textContent).toContain("Downloading Whisper Base… 42%");
expect(screen.getByRole("progressbar").getAttribute("aria-label")).toBe(
"Downloading Whisper Base, 42 percent",
);
});
it("renders an error label when state is error", () => {
render(<VoiceModelLoadIndicator state="error" progress={0} modelLabel={MODEL_LABEL} />);
const root = getIndicator();
expect(root.getAttribute("data-state")).toBe("error");
expect(root.textContent).toContain(`${MODEL_LABEL} failed to load`);
expect(root.getAttribute("aria-label")).toBe(`${MODEL_LABEL} failed to load`);
});
it("clamps percent to 0–100 when progress is out of range", () => {
render(<VoiceModelLoadIndicator state="loading" progress={1.5} modelLabel={MODEL_LABEL} />);
expect(getIndicator().textContent).toContain("100%");
cleanup();
render(<VoiceModelLoadIndicator state="loading" progress={-0.5} modelLabel={MODEL_LABEL} />);
expect(getIndicator().textContent).toContain("0%");
cleanup();
render(
<VoiceModelLoadIndicator state="loading" progress={Number.NaN} modelLabel={MODEL_LABEL} />,
);
expect(getIndicator().textContent).toContain("0%");
});
});