Record in-flight and cancelled transcriptions in history - #31
Merged
Conversation
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:No-speech results delete the placeholder, matching the existing too-short behavior.
Retry semantics
canRetry).transcribing(app quit/crash mid-flight) is normalized tocancelledon store load, so nothing is stuck as "ongoing" across launches.Scope
dnt historyshows»(transcribing) /⊘(cancelled).docs/PARITY.md.Verification
swift buildclean;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).