Skip to content

Record in-flight and cancelled transcriptions in history - #31

Merged
bojieli merged 24 commits into
mainfrom
history-transcribing-cancelled
Sep 3, 2026
Merged

Record in-flight and cancelled transcriptions in history#31
bojieli merged 24 commits into
mainfrom
history-transcribing-cancelled

Conversation

@bojieli

@bojieli bojieli commented Sep 3, 2026

Copy link
Copy Markdown
Owner

What

When a recording ends and transcription starts, a history entry is created immediately with status transcribing (audio stored alongside). The placeholder is then updated in place:

  • completed — filled with the real transcription on success
  • failed — on error, now including the missing-API-key case, which previously left no entry
  • pending — offline when recorded (unchanged behavior, now an update instead of an insert)
  • cancelled — user cancels mid-transcription; the recording stays in history, marked cancelled

No-speech results delete the placeholder, matching the existing too-short behavior.

Retry semantics

  • Cancelled rows keep their audio and work with the existing per-row retry button (canRetry).
  • They are excluded from the launch/reconnect auto-drain, the "Retry N failed dictations" menu item, and Retry-all — a deliberate cancellation never re-sends itself.
  • A row persisted as transcribing (app quit/crash mid-flight) is normalized to cancelled on store load, so nothing is stuck as "ongoing" across launches.

Scope

  • macOS app + shared DoNotTypeCore; dnt history shows » (transcribing) / (cancelled).
  • iOS compiles against the new enum but its pipeline doesn't write these statuses yet — noted in docs/PARITY.md.
  • Windows/Android ports untouched.

Verification

swift build clean; swift test — 602 tests, 0 failures, including 6 new HistoryStore tests (placeholder audio retention, completed releases audio unless keep-audio, cancelled stays retryable, load-time normalization, auto-drain exclusion).

bojieli and others added 24 commits August 31, 2026 19:29
The desktop block gains `translateTrigger` beside the two it already had.
Optional, so a profile written before Translate had a key of its own imports
unchanged and leaves the receiving device's binding alone rather than clearing
it — the same rule the typography block already uses for fields added late.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A target language was enough on its own to change what every key delivered.
Setting one made the main key translate — the single place the product broke
its own promise that the main key is what you said, while the panel two
sections down was still printing that promise. The second key lost its style
at the same time, which `RewriteAvailability.forSecondKey` existed to explain.

Translate now has a key, exactly as Rewrite does, and `translateTo` is what
that key writes in rather than a switch over both others. The monitor keeps
one trigger per `LiveMode` instead of a main and a "secondary": `matchingTrigger`
returns the mode, so the press hands `DictationController` a `LiveMode` and the
stage comes from `LiveMode.stage` — the resolver the phones' chip already used.
The desktop-only conditional that read the target language mid-dictation is gone.

The two availability sentences come from `LiveMode.availability` now, so a key
and a chip answer "can this run, and why not" with one rule rather than two.
The recorder takes a list of conflicting keys, because with three of them a
recorder that knew about one would let Translate steal Rewrite's key.

Stored under the old `secondaryTrigger`/`secondaryStyle` names: renaming them
would log every existing user out of their own binding to no benefit.

Behaviour change for anyone who had set a target language on 0.5.0 — the main
key gives verbatim again, and Translate needs its key bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AppSettings.SecondStageFor` read the target language on every dictation and
returned a translation regardless of which key was held, so setting a language
took the main key away from verbatim exactly as it did on macOS. It takes a
`LiveMode` now and defers to `LiveMode.Stage`.

`LiveMode` did not exist in C# — the desktops picked their mode with a
conditional while the phones had the type. Ported from the Swift with the
strings word-identical, and `LiveModeTests.cs` asserts the same table the
Kotlin and Swift suites do, so the three ports fail together or not at all.

The hook keeps `RewriteKey` and `TranslateKey` and reports which mode the press
belongs to. The settings form grows a Translate key beside the target language
and takes both its notes from `LiveMode.Availability`, with the translate note
held back until a key is bound — a heading that opens by warning about a
language nobody asked for reads as a fault rather than an offer.

The tray and the overlay now name the stage in flight, from the controller,
rather than re-deriving it from settings: with one key that guess was right, and
with three it would have labelled a rewrite "Translating…" for its whole second
stage.

Stored under the old `SecondaryTrigger`/`SecondaryStyle` names via
JsonPropertyName, so no existing settings.json loses its binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`forSecondKey` and the `translating` case were not a capability check — they
were a sentence explaining that a target language had taken the second key
over, on the two clients where it could. Nothing takes it over any more, so
there is nothing to explain: all four clients ask `LiveMode.availability`, and
a rewrite key with a target language set is just a rewrite key.

Swift and C# together, because the strings have to stay word-identical and a
case removed from one is drift in the other. Kotlin never had either — the
phones made the three modes exclusive by construction, which is what the
desktops now do too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PARITY's translate row was two footnotes that both described the override as
the design. Rewritten around what the clients actually do now — one key per
mode on the desktops, one chip on the phones, one `LiveMode` behind both — with
the old behaviour kept as history rather than deleted, because "the main key
stopped being verbatim" is the kind of thing somebody upgrading needs to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The keyboard's backend summary folded `.translating` in with the answers that
are not a backend problem, with a comment explaining it was a desktop answer
the phones never see. The desktops do not produce it any more either, so the
case is gone and the comment now says what is actually true of the one that
remains: a missing target language belongs to the mode, and the picker already
shows it where the mode is chosen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Swift and Kotlin headers named `CoreTests.cs` as the C# half of the trio,
which was aspirational — C# had no LiveMode at all until this series, and the
port landed in `LiveModeTests.cs`. Both now name the file that exists, and the
Swift header stops calling this the phone keyboards' picker: the desktops bind
one key per mode from the same three cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The modes table already listed Translate as a mode; the paragraph under it
still described the desktops as two keys with a setting that replaced what the
second one produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o wrap

The part files were wrapped at about 100 columns like the rest of the
repository. For a clause that is harmless — the loader joins clause parts into
one line, which is what `PromptPart.isClause` is for and why it exists — but a
host block is sent exactly as it sits on disk, so four sentences reached the
model with a hard break and a hanging indent in the middle of them. A model
shown a hard-wrapped instruction writes hard-wrapped transcripts, and putting
line breaks into the transcript is precisely what these blocks forbid
themselves from doing.

Visible in one render: `dnt prompt` printed rule 1 as a single line, because it
is a clause, and rules 3 and 4 wrapped, because they are not. Same list, two
layouts, and the wrapped ones were the model's most recent example.

Whitespace only — every file's words are byte-identical after collapsing runs
of whitespace, and blank lines and list-item boundaries are untouched, because
those breaks are the structure of the instruction rather than an artefact of
the column somebody's editor wraps at.

The clause files keep their wrapping. It provably never reaches the model, and
unwrapping them would fight the design that makes that true.

`testHostPartsBreakLinesOnlyBetweenBlocks` states the rule for the next person:
in a host part a newline is a paragraph break or a list-item boundary, and
nothing else. Asserted that way rather than as a column limit, because the
mistake is not a long line — it is a sentence continued on the next one. Re-
wrapping system.md by hand fails it with the file and both lines named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DictationStyle` was five cases the user picked from and the app stored. That
shape is what made the control unusable: the label had to compress a whole
instruction into a dash-clause, so `Chat — short lines, light punctuation` read
as a mood and behaved as a rule. Somebody who wanted the mood got line breaks
they never asked for, and could not trace them — the words actually being sent
were three files away from the only place they were described.

The instruction is the control now. The stored setting is one string, and
`DictationPreset` is a button rather than a value: it names a file whose text is
copied into that string, where it can be read and edited before it is used.
Presets can be added, renamed and reworded without migrating anybody, because
nothing stores them.

`dictationStyleClause(_:custom:)` collapses into `dictationExampleClause(_:)`.
There is no preset path and custom path any more — a preset's text arrives
having already been put in the box, so it goes through the same host block, the
same sanitiser and the same 500-character cap as something typed by hand. That
is what makes "press Chat, then edit it" an offer rather than a mode.

Empty is the default and sends nothing, exactly as `.spoken` did, so a fresh
install's request is still the one every measured number in `docs/PROMPT.md`
describes.

`DictationExample.migrating` is the one named rule for turning a pre-example
setting into text, shared by the app's launch migration and both transfer
importers rather than written four times. It is a rule and not a default
because nobody's dictations may change on upgrade: Chat's words were already in
every request and afterwards they are in the box, byte for byte.
`testMigrationPreservesTheRequestItReplaces` asserts exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…omise

The style dropdown is gone. In its place a text box with preset buttons above
it: pressing Chat fills the box with chat.md's words, which you then read, edit
or clear. Buttons rather than a picker, because pressing one is not choosing a
mode — it fills a field, and a picker would show a selection that stops being
true the moment somebody types in it.

Typography splits into "Write it like this" and "Always", and the second is
grouped by who keeps the promise rather than by what the setting is about.
Spacing is arithmetic done on this Mac, so it says *a guarantee*; script is a
sentence added to the request, so it says *a request, not a guarantee*. That
distinction has been true in the code since typography shipped and invisible in
the window, and it is the line that decides what can be a setting at all: both
are things an example cannot carry, which is why they survive as settings while
the style dropdown does not.

The launch migration runs before the first dictation can read the setting, once,
clearing the retired keys behind it — and refuses to overwrite a box that
already has something in it, which is the one unforgivable outcome. `dnt` falls
back to the same shared rule rather than trusting the app to have run first:
two clients disagreeing about the request is what that directory exists to
prevent.

A profile still writes the retired pair beside the new field, so a profile made
here imports correctly into a build that predates the box — an example arrives
there as `custom` with the same text, which is the same request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Settings loses the style picker and the Custom-only text field beneath it, and
gains one box with preset buttons above it. The header is "Write it like this"
and the typography section becomes "Always", grouped by who keeps the promise —
spacing on this phone, script asked of the model — because that is the line that
decides what can stay a setting at all.

The launch migration runs after the prompt store exists and before the first
request can read the setting, once, clearing the retired keys behind it.

The UI test changes shape with the control. It used to open a picker and assert
the row still read Chat; it now presses Chat and asserts the box has readable
text in it, which is the actual claim being made — the instruction is visible
before it is used, not three files away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DictationStyle` and its five cases become `DictationPreset` and three buttons;
the stored setting is `DictationExample`, one string. `DictationStyleClause`
collapses into `DictationExampleClause`, which is the sanitiser and nothing
else — a preset's text arrives having already been put in the box, so there is
no longer a preset path and a custom path to keep in agreement.

`DictationExample.Migrating` is hand-ported from the Swift with the behaviour
identical, and `MigrationPreservesTheRequestItReplaces` asserts the same claim
the Swift suite does: someone who had chosen Chat gets chat.md's words in their
box, so the request they were already sending does not change.

The Typography heading splits into "Write it like this" and "Always", the second
grouped by who keeps the promise — spacing on this PC, script asked of the model
— with a caption each saying which it is. The retired settings keep their names
in settings.json via JsonPropertyName, read once at startup and cleared, so no
existing install loses what it had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DictationStyle` becomes `DictationPreset` — three buttons instead of five
stored cases — and the setting becomes `dictationExample`, one string. The
picker and the Custom-only field beneath it are replaced by one box with a
button row; `dictationStyleClause` collapses into `dictationExampleClause`,
which is the sanitiser and nothing else.

Typography splits into "Write it like this" and "Always", grouped by who keeps
the promise: spacing here on the phone, script asked of the model.

The migration runs inside `Settings.initialise`, behind the same
already-initialised guard that keeps logging from starting twice — the three
entry points include the keyboard service, which is the one nobody remembers to
wire up. `Settings` retains the application context so a preset's text can be
resolved without threading a `Context` through the migration and the importer,
neither of which has one to hand.

The instrumentation test changes shape with the control. It used to assert the
picker offered every case and did not clobber the custom field; it now presses
Chat and asserts the box holds `chat.md`'s shipped text, which is the claim the
whole control rests on — the instruction is visible before it is used. That is
the one thing the old picker could not do, and it is why this suite is where it
belongs: resolving a part needs a Context and an APK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every control in that panel is a *cause*, and what somebody wants to know is the
*effect*. No label closes that gap — the one that read "Chat — short lines,
light punctuation" was describing its effect accurately while being read as a
mood, and the line breaks that followed were untraceable from anything on
screen. Diagnosing it meant pulling a file out of the audio folder by hand and
running it twice; this is that, as a button.

`RetryCoordinator.preview` is `retry` without the write-back. Separate rather
than a flag, because writing back is not incidental to a retry — it is what a
retry is for — and a preview that updated the row would rewrite the history
somebody is trying to compare against.

Panes side by side, because the question is always comparative: a single
"after" would need the reader to remember what they used to get, which is
exactly what nobody can do reliably about their own dictation. Selectable text,
because the difference is often one character.

"No recording to try this on" gets a sentence rather than a disabled button,
since keeping audio is off by default and that state is the common one rather
than an edge case.

`DictationRecord` also starts carrying the example, the script and the spacing
that were in force. It recorded fidelity and the rewrite style and nothing about
layout, so when a transcript came back with line breaks in it, nothing on the
row could name the setting responsible — which is why answering "what produced
this" meant reading `defaults`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by running it. The first real launch cleared the retired keys and wrote
nothing, leaving an install that had chosen Chat with an empty box and no record
of what it used to be — because the clear ran in a `defer` and fired whether or
not the preset had actually resolved.

`migrating` returns an optional now. Nil means a preset this build recognises
whose file it could not read, and every caller that owns durable state treats it
as "leave the old setting alone and try again next launch". The two callers with
nothing to preserve — the CLI, which writes nothing back, and the transfer
importer, whose document is applied once and gone — collapse it to an empty box
on the spot, which sends nothing.

Swift, C# and Kotlin together, with the case asserted in all three suites: a
preset whose file cannot be read must not be confused with the absence of a
style, because the two produce the same empty string and only one of them may
destroy what the user chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PARITY's "Writing style" bullet described a per-stage dropdown that no longer
exists, and "Typography" grouped three settings by what they were about rather
than by who keeps the promise — which is the distinction the panel is now built
on and the one that decides what can be a setting at all.

Preview gets a row and a footnote saying plainly that it is macOS-only, that it
is a gap rather than an impossibility, and that the honest version still needs a
record-a-clip path, because keeping audio is off by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The complaint was the grouping, and the grouping was the bug rather than the
labels. Fidelity sat with the hot keys, typography and the style box were two
more sections, and the preview a fourth — so each part had to end by pointing at
another ("Fidelity above is the separate dial for…"), which is what a grouping
does when it is wrong.

They are one question asked in four steps, and the steps are now the subheadings
that say so:

  How your transcript is written
    Which of your words survive        — Fidelity
    What shape they take               — Write it like this
    What holds regardless              — spacing, script
    What all of that actually produces — Preview

"Always" is gone; it named a property of two settings without saying what they
were for. The two rows keep their captions, which is where the real distinction
lives — applied on this Mac (a guarantee) against asked of the model (a request)
— and that pairing only reads as a pair once the heading above it stops trying
to be the explanation.

The hot-key section becomes "Recording", because with Fidelity gone everything
left in it is about how a recording starts, stops, cancels and submits —
whichever of the three keys began it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty box was the default because it kept the shipped request identical to
the one every measured number describes. That virtue was real and it was not
free: it also meant a fresh install's transcripts were laid out however the
model felt like that day, which is the complaint this whole series started from.
A default of "no answer" is still an answer, and it was the least predictable one
available.

`DictationExample.seeding` is written against the *absence* of the stored value
rather than its emptiness, and that distinction is the whole function: an empty
string is somebody who pressed Clear and meant it, and seeding over that would
put the default back into a box they had just emptied, on every launch, forever.
The migration now writes its result even when empty, so an install upgrading
from "As spoken" records that it has made its choice and the seed leaves it
alone — upgrading still changes no request.

Prose is also `allCases.first`, so it is the first button on all four clients.

docs/PROMPT.md gets the caveat rather than a quiet contradiction: the table was
measured against the empty request, which is now what an install sends after the
box is cleared rather than what it sends out of the box. Re-measuring against
the seeded default is owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three sections become one. Fidelity, the example box and the two guarantees were
separate headings that each ended by pointing at another, which is what a
grouping does when it is wrong; they are one question — how do my words get
written down — asked in four steps, and the steps are the subheadings.

The fourth step is new here: Record a clip, or send the last kept dictation
again. On a phone the clip is the half that matters, because keeping audio is
off by default and most people have no stored recording to resend — and it is
the more honest preview anyway, being your voice now rather than one from a week
ago. Panes stack rather than sitting side by side: a phone has no room for two
columns of prose, and each pane is labelled so the comparison survives.

`activateAudioSessionIfNeeded` is lifted out of `beginRecording` and shared, so
a preview clip takes the same route with the same category options a dictation
does. A preview recorded through a different category would be answering a
question about a different recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three sections become one heading over four named steps, matching macOS and iOS.
`StylePreview` is hand-ported so the four clients describe the same thing with
the same words, and so "which baseline" stays a rule stated once rather than a
decision made three times.

`DictationService.preview` is `retry` without the write-back, and `previewClip`
transcribes raw audio with the example passed in rather than read from Settings
— which is what makes the baseline request possible: same audio, same fidelity,
same script, one thing different.

The screen gets its own `WavRecorder` and its own permission launcher. Its own
recorder because a clip recorded here must not interrupt a dictation and a
dictation must not end a clip; its own launcher so the press that was refused
becomes the press that records, rather than making somebody grant the permission
and then find the button again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the port. `StylePreview` is hand-ported so the four clients describe
the same thing with the same words; `PreviewAsync` is `RetryAsync` without the
write-back, and `PreviewClipAsync` takes the example as a parameter rather than
reading settings, which is what makes the baseline request possible: same audio,
same fidelity, same script, one thing different.

Three headings become one over four named steps. Fidelity moves out of the key
section, which becomes "Recording" — with Fidelity gone, everything left in it
is about how a recording starts, stops, cancels and submits, whichever of the
three keys began it.

The form gets its own `AudioRecorder`, because a clip recorded in this window
must not interrupt a dictation in flight and a dictation must not end a clip.
The note under the buttons re-reads on every keystroke in the box, because the
cost changes with it: an empty box makes the baseline request pointless, so it
is not made and the sentence says one request rather than two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PARITY's preview footnote said macOS-only and that record-a-clip existed
nowhere; both are now done, and the footnote gains the part that is actually
subtle — which baseline the left pane uses, why it differs between a stored
recording and a fresh clip, and why an empty box means one request rather than
two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Create a history entry with status transcribing the moment recording
ends and transcription begins, with the audio stored, then update that
row in place: completed on success, failed on error (including a
missing API key, which previously left no entry), pending when
offline, cancelled when the user cancels mid-flight.

Cancelled rows keep their audio and can be retried from the per-row
retry button, but are excluded from the launch/reconnect auto-drain
and bulk retry so a deliberate cancellation never re-sends itself.
Placeholders persisted as transcribing (app quit or crash mid-flight)
load back as cancelled. Empty no-speech results delete the
placeholder, matching the existing too-short behavior.
@bojieli
bojieli merged commit 406bbb1 into main Sep 3, 2026
13 checks passed
@bojieli
bojieli deleted the history-transcribing-cancelled branch September 3, 2026 03:51
@bojieli bojieli mentioned this pull request Sep 3, 2026
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.

1 participant