Merge remote-tracking branch 'origin/main' into fix/small-batch-1

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-09 15:03:10 -07:00
3 changed files with 89 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- A generation timeout now points at the compute-time budget in Settings rather than an environment variable (#1808)
- The Accessibility prompt no longer floats over first-run setup and every other app until you grant it (#1845, #1886)
- The last onboarding step offers to install a speech-to-text model instead of failing three times when none is installed (#1856)
- A download that fails because the folder sits behind a mount point Windows will not cross now says so, and where to move it (#1957)
- A GPU that is merely short on free memory is no longer told to reinstall its drivers (#1812) — thanks @michaelhuamanflores!
+24
View File
@@ -122,6 +122,20 @@ const IDLE_VISIBLE_POLL_MS = 600;
// Reconcile only while the Accessibility blocker is visible.
const A11Y_SETUP_RECHECK_MS = 1000;
// How long the Accessibility prompt may hold the always-on-top pill on screen.
//
// The pill is created always-on-top, and the setup state had no time limit at
// all: it sat above every application, including the first-run setup window it
// was covering, until Accessibility was granted or the user dismissed it by
// hand (#1845, #1886). A permission the user has not granted yet is not urgent
// enough to outrank whatever they are actually doing, and on a clean install
// they are usually mid-setup and cannot grant it yet anyway.
//
// Polling does NOT stop when the window hides. The check keeps running, so
// granting Accessibility later still returns the widget to idle on its own —
// what expires is the pill's claim on the screen, not the reconciliation.
const A11Y_SETUP_VISIBLE_MS = 20_000;
// A dictation model id is a sherpa-onnx live model when it carries the
// `sherpa-` prefix the backend assigns (see services/sherpa_dictation.py). Only
// then do we open the low-latency raw-PCM streaming path. Other models use a
@@ -652,6 +666,10 @@ export default function CaptureWidget({ onDismiss }) {
let cancelled = false;
let timerId;
// Wall-clock start of this setup episode, so the budget covers the whole
// time the prompt has been up rather than one poll interval.
const shownAt = Date.now();
let hiddenForBudget = false;
const reconcileAccessibility = async () => {
const ok = await checkAccessibility();
if (cancelled || stateRef.current !== 'setup') return;
@@ -661,6 +679,12 @@ export default function CaptureWidget({ onDismiss }) {
await hideWidgetWindow();
return;
}
if (!hiddenForBudget && Date.now() - shownAt >= A11Y_SETUP_VISIBLE_MS) {
// Stop covering the screen, but stay in `setup` so the label is right
// if something shows the window again, and keep polling below.
hiddenForBudget = true;
await hideWidgetWindow();
}
timerId = setTimeout(() => {
void reconcileAccessibility();
}, A11Y_SETUP_RECHECK_MS);
@@ -295,3 +295,67 @@ describe('the widget window never strands empty', () => {
expect(mocks.holder.hide).not.toHaveBeenCalled();
});
});
describe('the Accessibility prompt does not own the screen forever', () => {
// #1845 / #1886: the pill is created always-on-top and the setup state had
// no time limit, so on a clean macOS install it sat over the first-run setup
// window — covering the disk-space line and the Start installation button —
// and over every other application, until Accessibility was granted or the
// user dismissed it by hand. A permission not yet granted does not outrank
// what the user is actually doing, and mid-setup they usually cannot grant
// it yet anyway.
it('hides the pill once its on-screen budget is spent, and keeps polling', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mocks.holder.a11y = false;
renderWidget();
// Settle the mount, where the idle-visibility reconcile legitimately
// hides a window that was already open. Measure from there.
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
const baseline = mocks.holder.hide.mock.calls.length;
// Still up a few seconds in — the prompt has something to say.
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(mocks.holder.hide.mock.calls.length).toBe(baseline);
// Past the budget it stops covering the screen.
await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
});
await waitFor(() => expect(mocks.holder.hide.mock.calls.length).toBeGreaterThan(baseline));
const hidesAfterBudget = mocks.holder.hide.mock.calls.length;
// Hiding is not giving up: granting Accessibility later must still settle
// the widget back to idle on its own.
mocks.holder.a11y = true;
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
await waitFor(() =>
expect(mocks.holder.hide.mock.calls.length).toBeGreaterThan(hidesAfterBudget),
);
});
it('hides once, not on every poll', async () => {
// The budget check is latched; re-issuing hide() every second would fight
// anything that legitimately shows the window again.
vi.useFakeTimers({ shouldAdvanceTime: true });
mocks.holder.a11y = false;
renderWidget();
await act(async () => {
await vi.advanceTimersByTimeAsync(25_000);
});
await waitFor(() => expect(mocks.holder.hide).toHaveBeenCalled());
const afterFirst = mocks.holder.hide.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(mocks.holder.hide.mock.calls.length).toBe(afterFirst);
});
});