a control for when the input state goes wrong

`POST /reset-input` and a "clicks acting wrong?" button in the panel's status
line. It releases every modifier and mouse button the display thinks is held.

Named for the SYMPTOM rather than the mechanism, because nobody experiencing
this knows a modifier is stuck — they know their clicks stopped doing the right
thing.

── both sides, and that ordering matters ──

Releasing on the server alone leaves the panel's own button mask stale and the
next mouse move re-asserts it, so the fix would appear not to work. Local state
is cleared first, a pointer event with an empty mask says so, and only then does
the server let go.

── a fixed list, not a query ──

`xinput --query-state` would report exactly what is held, which is better. It is
also a seventh host binary to declare and to require, for a repair. Releasing a
key that was not pressed is a no-op, so the fixed list costs nothing. Modifiers
and buttons only: a stuck letter types, which is obvious and harmless; a stuck
modifier silently changes what every click means.

── not guarded like the others ──

Every other command refuses while a dialog is open, because its clicks would
fall through onto the board. This one clicks nothing, and refusing a repair in
the state that most needs it would be the wrong way round.

Verified by stranding `Shift_L` on the display with `xdotool keydown`, hitting
the route, and watching `xinput --query-state` go from one key down to none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-24 17:10:14 +00:00
co-authored by Claude Opus 5
parent 02a816c8dc
commit a51c75cc6c
4 changed files with 114 additions and 1 deletions
+32 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { Bomb, Loader2, Play, RefreshCw, Unplug } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb';
import { RfbClient, keysymFor } from './rfb';
@@ -276,6 +277,26 @@ export const MinesweeperPanel = () => {
heldKeysRef.current.clear();
};
/**
* The escape hatch: let go of everything, on both sides.
*
* Both sides is the point. Releasing on the server alone leaves this panel's own button mask stale, and
* the next mouse move re-asserts it — the fix would appear not to work. So the local state is cleared
* first, a pointer event with an empty mask is sent to say so, and only then does the server let go of
* whatever it still believes is down.
*/
const resetInput = async () => {
releaseHeldKeys();
buttonsRef.current = 0;
rfbRef.current?.pointer(0, 0, 0);
try {
await clientRef.current.post('/minesweeper/reset-input', {});
toast.success('Input reset.');
} catch {
toast.error('Could not reset the input state.');
}
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
const rfb = rfbRef.current;
if (!rfb) return;
@@ -356,6 +377,16 @@ export const MinesweeperPanel = () => {
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh
</button>
{/* Named for the SYMPTOM, not the mechanism: nobody experiencing this knows a modifier is stuck,
they know their clicks stopped doing the right thing. */}
<button
type="button"
onClick={() => void resetInput()}
title="Release every key and mouse button the session thinks is held"
className="flex items-center gap-1 hover:text-foreground"
>
<Unplug className="size-3" /> clicks acting wrong?
</button>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>