Files
kandev/apps/web/components/task/chat/voice-model-load-indicator.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

66 lines
2.0 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.
"use client";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Progress } from "@kandev/ui/progress";
export type VoiceModelLoadIndicatorProps = {
state: "idle" | "loading" | "ready" | "error";
/** 0–1 fraction; clamped before rendering. */
progress: number;
modelLabel: string;
};
function clampPercent(progress: number): number {
if (!Number.isFinite(progress)) return 0;
const pct = Math.round(progress * 100);
if (pct < 0) return 0;
if (pct > 100) return 100;
return pct;
}
export function VoiceModelLoadIndicator({
state,
progress,
modelLabel,
}: VoiceModelLoadIndicatorProps) {
if (state === "idle" || state === "ready") return null;
const pct = clampPercent(progress);
// Single stable role="status" wrapper so AT announces the loading→error
// transition (a newly-inserted live region would be silently ignored).
// <Progress> (Radix) already renders its own role="progressbar" — the
// wrapper div intentionally has no role to avoid nested progressbar nodes.
return (
<div
data-testid="voice-model-load-indicator"
data-state={state}
role="status"
aria-label={state === "error" ? `${modelLabel} failed to load` : undefined}
className={
state === "error"
? "flex items-center gap-1 text-xs text-destructive w-32"
: "flex items-center gap-1.5 w-32"
}
>
{state === "error" ? (
<>
<IconAlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">{modelLabel} failed to load</span>
</>
) : (
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="hidden sm:inline text-[10px] leading-none text-muted-foreground truncate">
Downloading {modelLabel}… {pct}%
</span>
<Progress
value={pct}
className="h-1"
aria-label={`Downloading ${modelLabel}, ${pct} percent`}
/>
</div>
)}
</div>
);
}