[BUG] Voice hold mode: resuming push-to-talk submits the partial prompt — key auto-repeat satisfies the 300 ms submit double-tap

Status Open
Reported on v2.1.235
Maintainer reply None cached
Activity 0 comments · opened Aug 22, 2026

Summary

In hold (push-to-talk) mode, once a transcript has been inserted into the composer, pressing and holding the push-to-talk key again to continue dictating submits the half-finished prompt instead of starting a new recording.

The cause is that the "double-tap to submit" check treats keyboard auto-repeat as a second discrete key press. The default PTT key is Space, and the macOS default key-repeat interval (~90 ms) falls well inside the 300 ms double-tap window — so holding the key to talk again satisfies the submit gesture.

This is not the configured behaviour: voice.autoSubmit is documented as "Submit the prompt when hold-to-talk is released (hold mode only)" and was unset (default) here, and the mode was hold, not tap.

Environment

  • Claude Code 2.1.235 (ba01fa45), native install
  • macOS Darwin 25.5.0, Apple Silicon
  • Terminal: Ghostty
  • Voice: /voice hold mode — "voice": {"enabled": true, "mode": "hold"} (no autoSubmit key)
  • Dictation language: en (system en_NZ)
  • Surface: the agents view new-agent composer

Steps to reproduce

  1. Enable voice with /voice (hold mode; leave voice.autoSubmit unset).
  2. In the agents view, open the composer to start a new agent.
  3. Hold the PTT key (Space), dictate a sentence, then stop — either release, or let the recording end on its own.
  4. The transcript is inserted into the input. awaitingVoiceSubmitDoubleTap is now armed.
  5. Press and hold the PTT key again to keep dictating.

Expected

Step 5 starts a new recording and appends to the existing text — as #88355 describes hold mode ("release inserts without sending"), and consistent with #62228 (auto-submit on Space release in hold mode) having been closed as NOT_PLANNED.

Actual

The prompt is submitted and the agent session launches with the partial text. Everything spoken afterwards is never captured.

In my case the composer was open for 162 s of dictation and the prompt that was sent was 131 characters, ending mid-utterance. Nothing longer was ever written to disk — the composer clears its draft on submit, so there is no recovery path for the lost speech.

Mechanism

From the 2.1.235 bundle, in the composer key handler (identifiers are minified; twg = 300):

if (mode !== "tap" && insertRef?.current != null && state.awaitingVoiceSubmitDoubleTap) {
  let isPttKey    = pttKey !== null && !ctrl && !meta && !shift && key[0] === pttKey;
  let cursorAtEnd = (cursorOffset ?? len) === len;
  if (!isPttKey || !cursorAtEnd) clearArmed();
  else if (pendingTimer !== null) clearArmed();
  else if (voiceState === "idle") {
    let now = Date.now(), prev = lastPress.current;
    if (prev !== 0 && now - prev <= twg) {          // twg = 300 ms
      stopImmediatePropagation();                    // preempts starting a new recording
      timer = setTimeout(() => {
        ...
        insertRef.current.submit(textWithoutTrailingPttChar, true);   // <-- submits
      }, Rpn);
      return;
    }
    if (prev !== 0) clearArmed(); else lastPress.current = now;
  }
}

Two details make auto-repeat land inside the window:

  • The first press does not immediately set voiceState = "recording" — there is a warm-up (FIRST_PRESS_FALLBACK_MS = 2000) used to tell a tap from a hold, so voiceState is still "idle" when the first repeat arrives.
  • macOS KeyRepeat defaults to 6 (≈90 ms), so repeat #1 arrives ~90 ms after the initial press — inside twg = 300.

The code already anticipates the PTT character being typed (it strips a trailing Space before submitting), which suggests real key presses and auto-repeat are not being distinguished here.

Related constants in the same module: gIc = 200 (gap after which an in-progress hold is treated as released), ZyE = 15000 / QyE = 120000 (tap silence / max duration), XyE = 5000 (focus silence).

Aggravating factor

Transcribed text is only inserted into the input when a recording ends — while you speak it is accumulated internally and shown only as an interim preview. So a recording that ends earlier than intended (e.g. a >200 ms stall in key-repeat delivery, which is easy to hit in the agents view when several jobs are streaming output) gives little signal that capture has stopped, and the next attempt to resume is what fires the submit.

Suggested fix

Any one of these would remove the failure:

  1. Don't count keyboard auto-repeat as the second discrete tap — require a gap consistent with a real key-up, or reuse the same warm-up logic already used to distinguish tap from hold.
  2. Don't arm double-tap-submit in hold mode when voice.autoSubmit is unset; require Enter.
  3. At minimum, preserve the composer draft across a voice-triggered submit so the text is recoverable.

Possibly the same root cause

#85632 reports a tap-mode toggle "double-firing" (start + stop within ~330 ms) when triggered from the agent overview. That is also key auto-repeat being read as two discrete presses inside the same ~300 ms window, so the two may share a fix.

Context for the intended contract: #62228 (closed NOT_PLANNED), #88355.

Confidence

The timeline, the settings, the 131-character truncation and the absence of any longer copy on disk are directly observed. The code path and constants are read from the shipped 2.1.235 bundle. I hit this once and did not have --debug on, so I have no [voice] log for the event and have not run a controlled repro — the sequence above is derived from the code, and ~/.claude/debug/<session>.txt would confirm it.

🤖 Generated with Claude Code

View original on GitHub ↗