Skip to content

[feature] the keyboard can throw a dictation away instead of having to finish it - #326

Merged
YJack0000 merged 1 commit into
mainfrom
feature/keyboard-cancel
Sep 2, 2026
Merged

[feature] the keyboard can throw a dictation away instead of having to finish it#326
YJack0000 merged 1 commit into
mainfrom
feature/keyboard-cancel

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

What this changes

The voice keyboard gets a ✕ beside ⏹, shown only while a dictation is
running. It ends the session and throws the transcript away: nothing is
inserted, the host's field is left exactly as it was, and the microphone goes
back to the window the user chose.

        live transcript (74pt, three lines, bottom-aligned)

   ✕ (while listening)                        ⌫
   @                    ◉  record
                                              ⏎

Why

Closes #325.

⏹ is not a way out — it means deliver. A false start, a wrong word, someone
interrupting: the sentence still had to be transcribed into the document
before it could be deleted by hand, which on this pane means holding ⌫ through
a paragraph. The keyboard already shows the words live above the record
button, so until now the pane let the user watch a mistake happen and do
nothing about it. It also spent what it did not need to: a session nobody
wanted still paid for the relay drain and a cloud polish request.

How it works

writes the existing stopRequested with a new cancelRequested beside
it. The app cuts the relay instead of finishing it — no drain, no polish —
clears the transcript, and publishes cancelled, a third terminal
Downlink.State.

Three endings rather than two plus a special case: done delivers, error
explains, cancelled says nothing. The keyboard's rule for done is "insert
what is here", so a session where nobody spoke and a session the user threw
away have to be different events — otherwise the rule needs an exception, and
the exception is the bug.

cancel() is not stop() with the text blanked afterwards. stop is the
delivery path: it drains the relay for one more utterance, folds the partial
in, and spends up to six seconds polishing text that is about to be discarded
— with the drain standing as a network wait between the user's finger and the
microphone going quiet. It is not fail() either: nothing went wrong, so the
microphone window survives and the next tap is still served in place, instead
of an error's "microphone visibly off".

The race, closed on both sides

✕ stays reachable through finishing (the polish round trip), which is where
done gets published — so the two can cross in flight. Both halves are needed
because the app may answer slowly, or, killed, not at all:

  • App: a polish result is already dropped unless the session is still
    finishing, and a cancelled one is cancelled.
  • Keyboard: cancelledSession remembers the id and refuses every later
    downlink for it, so a done published a beat before the tap can never be
    drained and pasted afterwards.

While in here, relay segments now also stop rebuilding the transcript once a
session is no longer active — the same rule the two ending branches beside
them already followed. Without it a segment from a socket that is still dying
could put the discarded words straight back into a cancelled downlink.

Wire compatibility

cancelRequested is Bool? on purpose: a synthesized init(from:) requires
every non-optional key, and a mailbox that fails to decode reads as "nothing
there" — which for the uplink would mean a stop the app never hears. An uplink
left behind by the previous build has to keep working. There is a test for
exactly that.

Placement

Top-left was the slot the pane deliberately kept empty ("where the pane
breathes"), which is what lets ✕ arrive without the deck reflowing: the two
ways out of a dictation end up at the same height, one disc apart, and nothing
else moves. On devices that draw their own globe, @ is in that slot and a
session borrows it — @ is a shortcut for something nobody is doing in the
middle of speaking. The globe below is never touched.

Drawn as an ordinary control disc, not in the recording red: the pane keeps
its one colour on the record button, which is already red during a session,
and a second red disc would read as the more dangerous of the two rather than
the smaller one. It gets a third haptic — .rigid, on the press — because it
is a third outcome; the success pattern would celebrate a delivery that did
not happen, and .warning would say something went wrong when nothing did.

How it was verified

The desktop checklist below does not apply — this is ios/ only, which ci.yml
excludes from CI, and this machine has no Xcode (Command Line Tools only,
no simulator runtimes), so the app and keyboard targets could not be compiled
or run here. Stating that plainly rather than ticking boxes:

  • ParleyKit builds (swift build) — the shared DictationChannel change
  • Wire-format assertions actually executed, against the built ParleyKit,
    via a scratch executable (XCTest itself needs Xcode): every
    Downlink.State round trips including cancelled; cancelled is
    distinct from done/error; a ✕ uplink carries both flags; a ⏹ uplink
    reads as not-a-cancel; an uplink written without cancelRequested
    still decodes
    . All pass. The same assertions are committed as XCTest
    cases in DictationChannelTests
  • swiftc -frontend -parse clean on all eight changed Swift files
  • New strings added to both en and zh-Hant
    (Keyboard/Localizable.xcstrings, App/Parley/Localizable.xcstrings)
  • docs/design/ios-voice-keyboard.md updated: the third ending, the deck
    diagram, the uplink mailbox
  • Not done here: xcodebuild / simulator run / device run. The two
    exhaustive switches over Downlink.State (DictationView.statusTitle,
    KeyboardViewController.drainDownlink) both got their new case, so the
    compiler should be satisfied, but that is reasoning rather than a build.
    Someone with Xcode should build once and try ✕ mid-sentence before this
    ships in a TestFlight build.

Screenshots

None — see above; the keyboard could not be run on this machine. The layout
change is one disc appearing in the previously-empty top-left slot, drawn with
the existing ControlDisc at the existing KBMetrics.roundKey size.

…o finish it

The voice pane's only way out of a live session was ⏹, and ⏹ means deliver:
the app drains the relay, folds in the last partial, spends a cloud polish
round trip, and the keyboard pastes the whole transcript. A false start or a
changed mind still had to land in the user's document before it could be
deleted by hand — on this pane, by holding ⌫ through a paragraph. The words
are visible above the record button while they are being spoken, so the pane
let someone watch a mistake happen and do nothing about it.

✕ takes the deck's top-left slot while a session is live, one disc from ⏹.
It writes `stopRequested` with `cancelRequested` beside it; the app cuts the
relay instead of finishing it (no drain, no polish request), clears the
transcript, and publishes `cancelled` — a third terminal state the keyboard
never inserts from. The microphone goes back to the window the user chose,
exactly as after ⏹, because nothing failed.

The two can cross in flight: ✕ stays reachable through `finishing`, which is
up to six seconds of polish during which the pane still reads as live. Both
sides are made safe rather than merely unlikely — the app drops a polish
result unless the session is still `finishing`, and the keyboard remembers the
id it cancelled and refuses every later downlink for it.

`cancelRequested` is optional on the wire so an uplink written by the previous
build still decodes; a mailbox that fails to decode reads as "nothing there",
which here would be a stop the app never hears. Relay segments now also stop
rebuilding the transcript once a session is no longer active, so a socket that
is still dying cannot put the discarded words back into a `cancelled`
downlink.

Closes #325
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 open issues on this PR.

@YJack0000
YJack0000 merged commit 5f241b4 into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A dictation in flight can only be finished, never abandoned: the voice pane has no ✕

1 participant