From 3d517c86a18883b62e0ba91f76011b86a4811d26 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:33:08 +0800 Subject: [PATCH 1/7] core: let a completed dictation be transcribed again, and named for saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retry answers "the words never arrived". It cannot answer "the words arrived wrong", which is the more common complaint — a name misheard, a provider that was the wrong one for the accent — because that row is completed and `canRetry` is gated on the status. `canRedo` asks the only question that actually decides it: is the recording still on disk. For a completed dictation that means the keep-audio setting was on when it was made. `audioExportName` names the recording for when it was said. On disk it is the record's UUID, which is the right name for a file the store owns and a useless one in a downloads folder next to twenty others. Fixed format in all three languages, not a cultural one: a filename with a slash in it is not a filename. The re-transcription path now also drops the rewrite beside the transcript it just replaced. Keeping it would be worse than losing it — `deliveredText` prefers the styled version, so redoing a rewritten dictation would replace the words and still show the old ones, a button that appears to do nothing. Failed rows never carry a rewrite, so this changes nothing for Retry. Mirrored four ways as usual: Swift, C#, Kotlin, and the same three test suites. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- Sources/DoNotTypeCore/DictationRecord.swift | 25 ++++++ .../DoNotTypeCore/TranscriptionService.swift | 15 +++- .../DictationJourneyTests.swift | 81 +++++++++++++++++++ .../kotlin/app/donottype/core/Dictation.kt | 15 +++- .../main/kotlin/app/donottype/core/History.kt | 26 ++++++ .../donottype/core/DictationJourneyTest.kt | 46 +++++++++++ windows/DoNotType.App/DictationController.cs | 13 ++- .../DictationJourneyTests.cs | 44 ++++++++++ windows/DoNotType.Core/History.cs | 23 ++++++ 9 files changed, 285 insertions(+), 3 deletions(-) diff --git a/Sources/DoNotTypeCore/DictationRecord.swift b/Sources/DoNotTypeCore/DictationRecord.swift index 84d838c..127e1b9 100644 --- a/Sources/DoNotTypeCore/DictationRecord.swift +++ b/Sources/DoNotTypeCore/DictationRecord.swift @@ -156,6 +156,31 @@ public struct DictationRecord: Codable, Sendable, Identifiable, Equatable { /// Retryable only while the recording still exists. public var canRetry: Bool { status.isRetryable && audioFileName != nil } + /// Whether the transcription can be run again, and the recording saved out of the history. + /// + /// A superset of `canRetry`, and a different question. Retry is about words that never + /// arrived; redoing is about words that arrived wrong — a name misheard, a provider that was + /// the wrong one for the accent — and that case is a *completed* dictation, which keeps its + /// audio only when the keep-audio setting was on when it was made. + public var canRedo: Bool { audioFileName != nil } + + /// What to call the recording when it is saved somewhere the user chose. + /// + /// Named for when it was said. On disk it is the record's UUID, which is the right name for a + /// file the store owns and a useless one in a downloads folder next to twenty others. + public var audioExportName: String { + "donottype-\(Self.exportStamp.string(from: createdAt)).wav" + } + + private static let exportStamp: DateFormatter = { + let formatter = DateFormatter() + // Fixed format, so the name does not change shape with the user's region — a filename + // with a slash in it is not a filename. + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter + }() + /// What gets inserted: the styled version when one exists, otherwise the transcript. public var deliveredText: String { styledText ?? text } diff --git a/Sources/DoNotTypeCore/TranscriptionService.swift b/Sources/DoNotTypeCore/TranscriptionService.swift index 0e61594..3e42429 100644 --- a/Sources/DoNotTypeCore/TranscriptionService.swift +++ b/Sources/DoNotTypeCore/TranscriptionService.swift @@ -510,7 +510,11 @@ public struct RetryCoordinator: Sendable { self.store = store } - /// Retries a single record and writes the result back to the store. + /// Transcribes a stored recording again and writes the result back to the store. + /// + /// Both the retry of a dictation that failed and the redo of one the user thinks came back + /// wrong: the request is the same either way, and only the caller knows whether the text is + /// owed to a cursor somewhere. @discardableResult public func retry(_ record: DictationRecord) async -> Result { var updated = record @@ -525,6 +529,15 @@ public struct RetryCoordinator: Sendable { updated.status = .completed updated.text = text updated.errorMessage = nil + updated.errorDetail = nil + // The rewrite beside it was derived from the transcript that has just been replaced, + // so it goes with it. Keeping it would be worse than losing it: `deliveredText` + // prefers the styled version, so a redo of a rewritten dictation would replace the + // words and still show the old ones — a button that appears to do nothing. + updated.styledText = nil + updated.style = nil + updated.mode = .verbatim + updated.rewriteFailed = nil await store.update(updated) return .success(text) } catch { diff --git a/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift b/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift index afd6340..4531226 100644 --- a/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift +++ b/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift @@ -178,6 +178,87 @@ final class DictationJourneyTests: XCTestCase { ProviderError.missingAPIKey(envVar: "STUB_KEY"))) } + // MARK: - Redoing a transcript that arrived wrong + + /// The other reason to keep a recording: a dictation that *succeeded* and still came back + /// wrong. Retry cannot reach it — the row is completed — so the offer is keyed to the audio + /// being there rather than to the status. + func testACompletedDictationCanBeRedoneWhileItsAudioIsKept() async throws { + await store.configure(retention: .forever, keepAudioForCompleted: true) + var record = DictationRecord( + status: .completed, provider: "stub", model: "stub-model", fidelity: .light, + durationSeconds: 1) + record.text = "meet Bo Jelly at four" + let stored = await store.insert(record, audio: wav().data) + + XCTAssertFalse(stored.canRetry, "a completed dictation has nothing to retry") + XCTAssertTrue(stored.canRedo, "but its recording is still there to transcribe again") + + let outcome = await RetryCoordinator( + service: service(text: "meet Bojie at four"), store: store).retry(stored) + + guard case .success = outcome else { return XCTFail("expected the redo to succeed") } + let after = await store.record(id: stored.id) + XCTAssertEqual(after?.text, "meet Bojie at four") + XCTAssertTrue(after?.canRedo ?? false, "keep-audio is on, so it can be redone again") + } + + /// Without the audio there is nothing to send, so nothing is offered — the default for a + /// completed dictation, which discards its recording. + func testADiscardedRecordingCannotBeRedone() async { + var record = DictationRecord( + status: .completed, provider: "stub", model: "stub-model", fidelity: .light) + record.text = "already delivered" + let stored = await store.insert(record, audio: wav().data) + + XCTAssertNil(stored.audioFileName) + XCTAssertFalse(stored.canRedo) + } + + /// A redo replaces the transcript, so the rewrite derived from the *old* one cannot stay: + /// `deliveredText` prefers the styled version, and leaving it would show the old words under + /// a button that had just replaced them. + func testARedoDropsTheRewriteDerivedFromTheReplacedTranscript() async throws { + await store.configure(retention: .forever, keepAudioForCompleted: true) + var record = DictationRecord( + status: .completed, provider: "stub", model: "stub-model", fidelity: .light, + durationSeconds: 1) + record.text = "so basically we should just ship it" + record.styledText = "We should proceed with the release." + record.style = .formal + record.mode = .rewrite(.formal) + let stored = await store.insert(record, audio: wav().data) + + _ = await RetryCoordinator( + service: service(text: "so basically we should just ship it today"), store: store) + .retry(stored) + + let after = await store.record(id: stored.id) + XCTAssertEqual(after?.text, "so basically we should just ship it today") + XCTAssertNil(after?.styledText, "the rewrite described a transcript that is now gone") + XCTAssertEqual( + after?.deliveredText, "so basically we should just ship it today", + "the row must show the words the redo produced") + XCTAssertEqual(after?.resolvedMode, .verbatim) + } + + /// The recording is saved under the time it was said, not under the UUID it has on disk. + func testASavedRecordingIsNamedForWhenItWasSaid() { + var components = DateComponents() + components.year = 2026 + components.month = 8 + components.day = 28 + components.hour = 14 + components.minute = 32 + components.second = 5 + let created = Calendar.current.date(from: components)! + let record = DictationRecord( + id: UUID(), createdAt: created, status: .completed, provider: "stub", + model: "stub-model", fidelity: .light) + + XCTAssertEqual(record.audioExportName, "donottype-20260828-143205.wav") + } + // MARK: - Rewriting never costs the verbatim transcript /// The claim the whole product rests on: a rewrite is stored *beside* what was said, never diff --git a/android/app/src/main/kotlin/app/donottype/core/Dictation.kt b/android/app/src/main/kotlin/app/donottype/core/Dictation.kt index 724ef14..18ffd64 100644 --- a/android/app/src/main/kotlin/app/donottype/core/Dictation.kt +++ b/android/app/src/main/kotlin/app/donottype/core/Dictation.kt @@ -485,7 +485,12 @@ class DictationService(private val context: Context) { } } - /** Reissues a stored dictation. */ + /** + * Transcribes a stored recording again. + * + * Both the retry of a dictation that failed and the redo of one the user thinks came back + * wrong: the request is the same either way. + */ suspend fun retry(record: DictationRecord): Result { val key = Settings.apiKey if (key.isNullOrBlank()) { @@ -534,6 +539,14 @@ class DictationService(private val context: Context) { record.status = DictationRecord.Status.COMPLETED record.text = result.transcript.transcript.trim() record.errorMessage = null + record.errorDetail = null + // The rewrite beside it was derived from the transcript that has just been replaced, + // so it goes with it. Keeping it would be worse than losing it: deliveredText prefers + // the styled version, so a redo of a rewritten dictation would replace the words and + // still show the old ones — a button that appears to do nothing. + record.styledText = null + record.mode = TranscriptMode.Verbatim.id + record.rewriteFailed = false history.update(record) Result.success(record) } catch (error: Exception) { diff --git a/android/app/src/main/kotlin/app/donottype/core/History.kt b/android/app/src/main/kotlin/app/donottype/core/History.kt index 2620b3e..81d2303 100644 --- a/android/app/src/main/kotlin/app/donottype/core/History.kt +++ b/android/app/src/main/kotlin/app/donottype/core/History.kt @@ -10,6 +10,9 @@ import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.UUID /** @@ -119,6 +122,29 @@ data class DictationRecord( val canRetry: Boolean get() = status.isRetryable && audioFileName != null + /** + * Whether the transcription can be run again, and the recording saved out of the history. + * + * A superset of [canRetry], and a different question. Retry is about words that never arrived; + * redoing is about words that arrived wrong — a name misheard, a provider that was the wrong + * one for the accent — and that case is a *completed* dictation, which keeps its audio only + * when the keep-audio setting was on when it was made. + */ + val canRedo: Boolean get() = audioFileName != null + + /** + * What to call the recording when it is saved somewhere the user chose. + * + * Named for when it was said. On disk it is the record's UUID, which is the right name for a + * file the store owns and a useless one in a downloads folder next to twenty others. The + * format is fixed rather than locale-dependent: a filename with a slash in it is not a + * filename. + */ + val audioExportName: String + get() = "donottype-" + + SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date(createdAt)) + + ".wav" + /** True when this came from a recording on disk rather than the microphone. */ val isFromFile: Boolean get() = sourceFileName != null diff --git a/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt b/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt index e10c831..9ef53e3 100644 --- a/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt +++ b/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt @@ -130,6 +130,52 @@ class DictationJourneyTest { assertNull("completed and retention is off, so it goes", completed.audioFileName) } + // ---- Redoing a transcript that arrived wrong --------------------------------------------- + + /** + * The other reason to keep a recording: a dictation that *succeeded* and still came back + * wrong. Retry cannot reach it — the row is completed — so the offer is keyed to the audio + * being there rather than to the status. + */ + @Test + fun `a completed dictation can be redone while its audio is kept`() { + store.configure(RetentionPolicy.FOREVER, keepAudioForCompleted = true) + val entry = record(DictationRecord.Status.COMPLETED) + .apply { text = "meet Bo Jelly at four" } + + val stored = store.insert(entry, wav()) + + assertFalse("a completed dictation has nothing to retry", stored.canRetry) + assertTrue("but its recording is still there to transcribe again", stored.canRedo) + assertNotNull(store.audioFor(stored)) + } + + /** + * Without the audio there is nothing to send, so nothing is offered — the default for a + * completed dictation, which discards its recording. + */ + @Test + fun `a discarded recording cannot be redone`() { + val stored = store.insert(record(DictationRecord.Status.COMPLETED), wav()) + + assertNull(stored.audioFileName) + assertFalse(stored.canRedo) + } + + /** The recording is saved under the time it was said, not the UUID it has on disk. */ + @Test + fun `a saved recording is named for when it was said`() { + val calendar = java.util.Calendar.getInstance() + calendar.set(2026, java.util.Calendar.AUGUST, 28, 14, 32, 5) + val entry = DictationRecord( + createdAt = calendar.timeInMillis, + status = DictationRecord.Status.COMPLETED, + model = "stub-model", + ) + + assertEquals("donottype-20260828-143205.wav", entry.audioExportName) + } + // ---- Provider behaviour ------------------------------------------------------------------ @Test diff --git a/windows/DoNotType.App/DictationController.cs b/windows/DoNotType.App/DictationController.cs index 110d466..d9836ee 100644 --- a/windows/DoNotType.App/DictationController.cs +++ b/windows/DoNotType.App/DictationController.cs @@ -1097,7 +1097,11 @@ private async Task TranscribeAsync( // ---- Retry ------------------------------------------------------------------------------- - /// Reissues one stored dictation. + /// Transcribes one stored recording again. + /// + /// Both the retry of a dictation that failed and the redo of one the user thinks came back + /// wrong: the request is the same either way. + /// public async Task RetryAsync(DictationRecord record) { var key = _settings.ResolvedApiKey(); @@ -1135,6 +1139,13 @@ public async Task RetryAsync(DictationRecord record) record.Status = DictationStatus.Completed; record.Text = result.Transcript.Text.Trim(); record.ErrorMessage = null; + record.ErrorDetail = null; + // The rewrite beside it was derived from the transcript that has just been replaced, + // so it goes with it. Keeping it would be worse than losing it: DeliveredText prefers + // the styled version, so a redo of a rewritten dictation would replace the words and + // still show the old ones -- a button that appears to do nothing. + record.StyledText = null; + record.Mode = TranscriptMode.Verbatim.Id; _history.Update(record); HistoryChanged?.Invoke(); return true; diff --git a/windows/DoNotType.Core.Tests/DictationJourneyTests.cs b/windows/DoNotType.Core.Tests/DictationJourneyTests.cs index 3082221..c6ad6af 100644 --- a/windows/DoNotType.Core.Tests/DictationJourneyTests.cs +++ b/windows/DoNotType.Core.Tests/DictationJourneyTests.cs @@ -126,6 +126,50 @@ public void RetentionOffStillKeepsAudioForAnythingRetryable() Assert.Null(completed.AudioFileName); } + // ---- Redoing a transcript that arrived wrong --------------------------------------------- + + /// + /// The other reason to keep a recording: a dictation that succeeded and still came + /// back wrong. Retry cannot reach it -- the row is completed -- so the offer is keyed to the + /// audio being there rather than to the status. + /// + [Fact] + public void ACompletedDictationCanBeRedoneWhileItsAudioIsKept() + { + _store.Configure(RetentionPolicy.Forever, keepAudioForCompleted: true); + var entry = Record(DictationStatus.Completed); + entry.Text = "meet Bo Jelly at four"; + + var stored = _store.Insert(entry, Wav()); + + Assert.False(stored.CanRetry, "a completed dictation has nothing to retry"); + Assert.True(stored.CanRedo, "but its recording is still there to transcribe again"); + Assert.NotNull(_store.AudioFor(stored)); + } + + /// + /// Without the audio there is nothing to send, so nothing is offered -- the default for a + /// completed dictation, which discards its recording. + /// + [Fact] + public void ADiscardedRecordingCannotBeRedone() + { + var stored = _store.Insert(Record(DictationStatus.Completed), Wav()); + + Assert.Null(stored.AudioFileName); + Assert.False(stored.CanRedo); + } + + /// The recording is saved under the time it was said, not the GUID it has on disk. + [Fact] + public void ASavedRecordingIsNamedForWhenItWasSaid() + { + var entry = Record(DictationStatus.Completed); + entry.CreatedAt = new DateTimeOffset(2026, 8, 28, 14, 32, 5, TimeSpan.Zero); + + Assert.Equal("donottype-20260828-143205.wav", entry.AudioExportName); + } + // ---- The fallback is recorded honestly --------------------------------------------------- /// A hedged dictation must name the backend that answered, not the one asked. diff --git a/windows/DoNotType.Core/History.cs b/windows/DoNotType.Core/History.cs index 5a664f3..82909fc 100644 --- a/windows/DoNotType.Core/History.cs +++ b/windows/DoNotType.Core/History.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -128,6 +129,28 @@ public sealed class DictationRecord [JsonIgnore] public bool CanRetry => IsRetryable && AudioFileName is not null; + /// + /// Whether the transcription can be run again, and the recording saved out of the history. + /// + /// + /// A superset of , and a different question. Retry is about words that + /// never arrived; redoing is about words that arrived wrong -- a name misheard, a provider + /// that was the wrong one for the accent -- and that case is a completed dictation, + /// which keeps its audio only when the keep-audio setting was on when it was made. + /// + [JsonIgnore] + public bool CanRedo => AudioFileName is not null; + + /// What to call the recording when it is saved somewhere the user chose. + /// + /// Named for when it was said. On disk it is the record's GUID, which is the right name for a + /// file the store owns and a useless one in a downloads folder next to twenty others. The + /// format is fixed rather than cultural: a filename with a slash in it is not a filename. + /// + [JsonIgnore] + public string AudioExportName => + $"donottype-{CreatedAt.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.wav"; + [JsonIgnore] public string Summary => Status switch { From c6925ed348c57e50bbe04feade773cb53658b9a6 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:34:08 +0800 Subject: [PATCH 2/7] macOS: redo a transcription and save the original recording from History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two buttons on a history row, both keyed to `canRedo` — the recording still being on disk — rather than to the status. The circular arrow already meant "Retry" on a failed row; on a completed one it now means "Redo the transcription". Deliberately not the same ending as Retry: a retry is recovering words that never reached a cursor, so it types them, and nothing is owed a cursor when the user is reading their own history. The row updates and Copy is one button away. The down-arrow saves the recording itself. It is the evidence behind the row — what a wrong transcript should be judged against, and the only thing here that cannot be reconstructed. A copy, not a move: saving it does not cost the ability to redo it. Keep audio's tooltip now says what keeping it buys beyond Retry, because that setting is the whole reason a completed row has either button. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- Sources/DoNotTypeApp/SettingsModel.swift | 53 ++++++++++++++++++++++++ Sources/DoNotTypeApp/SettingsView.swift | 33 ++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Sources/DoNotTypeApp/SettingsModel.swift b/Sources/DoNotTypeApp/SettingsModel.swift index 07a237d..9336c69 100644 --- a/Sources/DoNotTypeApp/SettingsModel.swift +++ b/Sources/DoNotTypeApp/SettingsModel.swift @@ -1,6 +1,8 @@ +import AppKit import DoNotTypeCore import Foundation import Observation +import UniformTypeIdentifiers /// Observable façade over `Settings` and `HistoryStore` for the settings window. /// @@ -1014,6 +1016,57 @@ final class SettingsModel { await refresh() } + /// Transcribes a stored recording again, for a dictation that arrived and arrived wrong. + /// + /// The same request Retry makes, and deliberately not the same ending: a retry is recovering + /// words that never reached a cursor, so it types them. Nothing is owed a cursor here — the + /// user is reading the history — and typing into whatever is behind this window would be a + /// surprise. The row updates, and Copy is one button away. + func redo(_ record: DictationRecord) async { + guard let coordinator = makeCoordinator() else { + lastRetrySummary = "No API key set." + return + } + retryingIDs.insert(record.id) + defer { retryingIDs.remove(record.id) } + + switch await coordinator.retry(record) { + case .success(let text): + lastRetrySummary = "Transcribed again: \(text.prefix(60))" + case .failure(let error): + lastRetrySummary = error.localizedDescription + } + await refresh() + } + + /// Copies the recording out of the history to somewhere the user chose. + /// + /// A copy rather than a move: the history keeps its own file, so saving a recording is not a + /// way to lose the ability to redo it. + func saveAudio(_ record: DictationRecord) async { + guard let source = await store.audioURL(for: record) else { + lastRetrySummary = HistoryStore.StoreError.audioMissing(record.id).localizedDescription + return + } + + let panel = NSSavePanel() + panel.nameFieldStringValue = record.audioExportName + panel.allowedContentTypes = [.wav] + guard panel.runModal() == .OK, let target = panel.url else { return } + + do { + // Overwriting is the user's answer to the panel's own "replace?" prompt, so the + // existing file goes rather than the copy failing on it. + if FileManager.default.fileExists(atPath: target.path) { + try FileManager.default.removeItem(at: target) + } + try FileManager.default.copyItem(at: source, to: target) + lastRetrySummary = "Saved \(target.lastPathComponent)." + } catch { + lastRetrySummary = error.localizedDescription + } + } + func retryAll() async { guard let coordinator = makeCoordinator() else { lastRetrySummary = "No API key set." diff --git a/Sources/DoNotTypeApp/SettingsView.swift b/Sources/DoNotTypeApp/SettingsView.swift index e706926..60e0187 100644 --- a/Sources/DoNotTypeApp/SettingsView.swift +++ b/Sources/DoNotTypeApp/SettingsView.swift @@ -830,7 +830,9 @@ private struct HistoryTab: View { Toggle("Keep audio", isOn: $model.keepAudio) .help( - "Failed dictations always keep their audio until they succeed, so Retry works.") + "Failed dictations always keep their audio until they succeed, so Retry works." + + " Keeping it for the ones that succeeded is what lets you redo a" + + " transcription or save the recording.") Spacer() @@ -925,6 +927,10 @@ private struct HistoryRow: View { .buttonStyle(.borderless) .help("Show exactly what was sent") + // One circular arrow per row, doing the thing that row needs. On a failed dictation + // that is Retry — the words never reached a cursor, so they are typed. On a completed + // one it is a redo: the words arrived and arrived wrong, and re-running the + // transcription is the only fix that does not mean saying it all again. if model.retryingIDs.contains(record.id) { ProgressView().controlSize(.small) } else if record.canRetry { @@ -935,7 +941,17 @@ private struct HistoryRow: View { } .buttonStyle(.borderless) .help("Retry this dictation") - } else if record.status == .completed { + } else if record.canRedo { + Button { + Task { await model.redo(record) } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Redo the transcription") + } + + if record.status == .completed { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(record.text, forType: .string) @@ -946,6 +962,19 @@ private struct HistoryRow: View { .help("Copy transcript") } + // The recording is the evidence behind the row: it is what a wrong transcript should + // be judged against, and the one thing here that cannot be reconstructed. Offered + // wherever it still exists. + if record.canRedo { + Button { + Task { await model.saveAudio(record) } + } label: { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .help("Save the original audio") + } + // A failed row's summary *is* its error, and it is the thing worth pasting into an // issue. Copying the truncated label off the screen is not an option, so give it a // button of its own. From 997ea5ba61fcb4e1c5d8624684dbbcb508e94603 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:35:18 +0800 Subject: [PATCH 3/7] Windows: redo a transcription and save the original recording from History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same two actions as macOS, in the idiom the Windows history already uses: the row's right-click menu, where Retry, Copy and Delete already live. Retry becomes one item renamed for the row it is opened on — "Retry this dictation" on a failed row, "Redo the transcription" on a completed one. The request is identical; what differs is what the user is asking for. Both are gated on CanRedo, so double-click reaches a completed row too and says which of the two it is doing while it runs. "Save the original audio…" writes the recording to a file the user picks, suggested as donottype-.wav. A copy, so saving it does not cost the ability to redo it. IO failures land in the summary line uncut: the path and the reason are what tells someone to pick a different folder. The hint under the list names both, since neither is visible until a right-click. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- windows/DoNotType.App/SettingsForm.cs | 67 ++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/windows/DoNotType.App/SettingsForm.cs b/windows/DoNotType.App/SettingsForm.cs index 6ab2bd3..fa21e1c 100644 --- a/windows/DoNotType.App/SettingsForm.cs +++ b/windows/DoNotType.App/SettingsForm.cs @@ -491,12 +491,15 @@ private TabPage BuildHistoryTab() toolbar.Controls.Add(deleteAll); toolbar.Controls.Add(_historySummary); - // Per-item retry: double-clicking a failed row reissues just that dictation. + // Per-item: double-clicking a row transcribes its recording again. A retry on a failed + // row, a redo on one that succeeded and came back wrong — the same request either way. _history.DoubleClick += async (_, _) => { - if (SelectedRecord() is not { CanRetry: true } record) return; + if (SelectedRecord() is not { CanRedo: true } record) return; - _historySummary.Text = "Retrying…"; + _historySummary.Text = record.Status == DictationStatus.Completed + ? "Transcribing again…" + : "Retrying…"; await _controller.RetryAsync(record).ConfigureAwait(true); RefreshHistory(); }; @@ -506,13 +509,22 @@ private TabPage BuildHistoryTab() var rowMenu = new ContextMenuStrip(); var deleteOne = new ToolStripMenuItem("Delete this transcript"); deleteOne.Click += (_, _) => DeleteSelected(); + // One item rather than two, renamed for the row it is opened on. The request is identical; + // what differs is what the user is asking for. On a failed row it is Retry — the words + // never arrived. On a completed one it is a redo — they arrived wrong, and re-running the + // transcription is the only fix that does not mean saying it all again. var retryOne = new ToolStripMenuItem("Retry this dictation"); retryOne.Click += async (_, _) => { - if (SelectedRecord() is not { CanRetry: true } record) return; + if (SelectedRecord() is not { CanRedo: true } record) return; await _controller.RetryAsync(record).ConfigureAwait(true); RefreshHistory(); }; + + // The recording is the evidence behind the row: what a wrong transcript should be judged + // against, and the one thing here that cannot be reconstructed. + var saveAudioOne = new ToolStripMenuItem("Save the original audio…"); + saveAudioOne.Click += (_, _) => SaveSelectedAudio(); // The point of the whole grounding argument: if the app reads your screen, you can read // what it read. One click from the row it belongs to, rather than a separate screen you // have to know exists. @@ -532,12 +544,16 @@ private TabPage BuildHistoryTab() rowMenu.Opening += (_, _) => { var record = SelectedRecord(); - retryOne.Enabled = record?.CanRetry == true; + retryOne.Text = record?.Status == DictationStatus.Completed + ? "Redo the transcription" + : "Retry this dictation"; + retryOne.Enabled = record?.CanRedo == true; + saveAudioOne.Enabled = record?.CanRedo == true; copyOne.Enabled = record?.Text.Length > 0; deleteOne.Enabled = record is not null; }; rowMenu.Items.AddRange( - [inspectOne, retryOne, copyOne, new ToolStripSeparator(), deleteOne]); + [inspectOne, retryOne, saveAudioOne, copyOne, new ToolStripSeparator(), deleteOne]); _history.ContextMenuStrip = rowMenu; _history.KeyDown += (_, e) => @@ -558,6 +574,42 @@ private TabPage BuildHistoryTab() ? null : _history.SelectedItems[0].Tag as DictationRecord; + /// + /// Writes the selected row's recording somewhere the user chose. A copy rather than a move: + /// the history keeps its own file, so saving a recording does not cost the ability to redo it. + /// + private void SaveSelectedAudio() + { + if (SelectedRecord() is not { CanRedo: true } record) return; + + var wav = _controller.History.AudioFor(record); + if (wav is null) + { + _historySummary.Text = "The recording for this dictation is no longer on disk."; + return; + } + + using var dialog = new SaveFileDialog + { + FileName = record.AudioExportName, + Filter = "WAV audio (*.wav)|*.wav", + DefaultExt = "wav", + AddExtension = true, + }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + + try + { + File.WriteAllBytes(dialog.FileName, wav); + _historySummary.Text = $"Saved {Path.GetFileName(dialog.FileName)}."; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + // Uncut: the path and the reason are what the user needs to pick a different folder. + _historySummary.Text = error.Message; + } + } + private void DeleteSelected() { // Multi-select is enabled, so a range delete is one action rather than N. @@ -613,7 +665,8 @@ private void RefreshHistory() _historySummary.Text = $"{shown} · {retryable} to retry · {bytes / 1024} KB audio" - + " (double-click to retry · Delete key or right-click to remove)" + + " (double-click to transcribe again · right-click to save the audio" + + " · Delete key or right-click to remove)" + performance; } From fa1c317e77c4043f32a8f91881e7a996e18f3e24 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:40:02 +0800 Subject: [PATCH 4/7] Android: redo a transcription and save the original recording from History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second line under the row, present only when the recording still is — which for a dictation that succeeded means the keep-audio setting was on when it was made, so on most rows this line does not exist rather than sitting there disabled. Four buttons beside the transcript would have left it a column too narrow to read, and reading it is what the row is for. "Redo transcription" appears only on completed rows: on a failed one, Retry above already transcribes the very same recording, and two names for one action is worse than one. "Save audio" writes the recording through the document picker, suggested as donottype-.wav. The picker holds the row's id across the trip rather than a megabyte of audio, and the store is re-read when the answer comes back — which is also what makes a row deleted in the meantime say so instead of writing a file from a stale copy. Keep audio's own description in Settings now says what keeping it buys beyond Retry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- .../kotlin/app/donottype/HistoryActivity.kt | 95 ++++++++++++++++++- .../kotlin/app/donottype/SettingsActivity.kt | 4 +- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt b/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt index 600fd60..760bc25 100644 --- a/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt +++ b/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt @@ -21,6 +21,7 @@ import android.widget.ScrollView import android.widget.Spinner import android.widget.TextView import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope @@ -62,6 +63,40 @@ class HistoryActivity : AppCompatActivity() { private val service by lazy { DictationService(this) } private var query = HistoryQuery() + /** + * The row whose recording the document picker was opened for. + * + * The id rather than the record or its bytes: the picker is a trip through another app, and a + * megabyte of audio held across it is a megabyte held for as long as the user browses. The + * store is re-read when the answer comes back, which is also what makes a row deleted in the + * meantime say so instead of writing a file from a stale copy. + */ + private var pendingAudioRecordId: String? = null + + private val saveAudio = registerForActivityResult( + ActivityResultContracts.CreateDocument("audio/wav"), + ) { uri -> + val id = pendingAudioRecordId + pendingAudioRecordId = null + if (uri == null || id == null) return@registerForActivityResult + + val wav = service.history.all().firstOrNull { it.id == id } + ?.let { service.history.audioFor(it) } + if (wav == null) { + summary.text = "The recording for this dictation is no longer on disk." + return@registerForActivityResult + } + runCatching { + contentResolver.openOutputStream(uri, "wt")?.use { it.write(wav) } + ?: error("Could not open that file for writing.") + }.onSuccess { + Toast.makeText(this, "Recording saved.", Toast.LENGTH_SHORT).show() + }.onFailure { + // Uncut: the reason is what tells someone to pick a different folder. + summary.text = it.message ?: "Could not save the recording." + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Settings.initialise(this) @@ -337,9 +372,67 @@ class HistoryActivity : AppCompatActivity() { contentDescription = "Delete this transcript" }, ) - return row + + // Nothing more to offer unless the recording is still here, which for a dictation that + // succeeded means the keep-audio setting was on when it was made — so on most rows this + // second line does not exist at all rather than sitting there disabled. + if (!record.canRedo) return row + + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(row) + addView(audioActions(record)) + } } + /** + * What the kept recording makes possible, under the row it belongs to. + * + * A line of its own rather than more buttons beside the transcript: four actions on one line + * leave the transcript a column too narrow to read, which is the thing the row is for. + */ + private fun audioActions(record: DictationRecord): View = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + val padding = resources.getDimensionPixelSize(R.dimen.space_m) + setPadding(padding, 0, padding, resources.getDimensionPixelSize(R.dimen.space_s)) + + // Only where Retry is not already the same button. On a failed row the words never + // arrived and Retry above transcribes the very same recording; offering "redo" beside it + // would be two names for one action. + if (record.status == DictationRecord.Status.COMPLETED) { + addView( + textButton("Redo transcription") {}.apply { + layoutParams = shareWidth() + setOnClickListener { + isEnabled = false + summary.text = "Transcribing again…" + lifecycleScope.launch { + service.retry(record) + refresh() + } + } + }, + ) + } + + // The recording is the evidence behind the row: what a wrong transcript should be judged + // against, and the one thing here that cannot be reconstructed. A copy — the history keeps + // its own file, so saving it does not cost the ability to redo it. + addView( + textButton("Save audio") { + pendingAudioRecordId = record.id + saveAudio.launch(record.audioExportName) + }.apply { layoutParams = shareWidth() }, + ) + } + + /** Even shares of the row, so a long label ellipsizes rather than pushing the next one off. */ + private fun shareWidth() = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f, + ) + private fun wrapContent() = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, diff --git a/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt b/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt index 243c4a7..f24b0f4 100644 --- a/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt +++ b/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt @@ -406,7 +406,9 @@ class SettingsActivity : AppCompatActivity() { controlRow("Keep for", buildRetentionPicker()), switchRow( "Keep audio for successful dictations", - "Failed dictations always keep theirs until they succeed.", + "Failed dictations always keep theirs until they succeed. Keeping it for the " + + "ones that worked is what lets you redo a transcription or save the " + + "recording.", checked = Settings.keepAudio, ) { Settings.keepAudio = it From 65362fb87452a899ceb5a99aa048b275cf62e16d Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:43:02 +0800 Subject: [PATCH 5/7] iOS: redo a transcription and save the original recording from History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same two row buttons as macOS, keyed to the recording still being on disk rather than to the status. The circular arrow already meant "Retry" on a failed row; on a completed one it now means "Redo the transcription", and deliberately does not end the way Retry does. A retry is recovering words that never reached the keyboard, so it delivers them; nothing is waiting on a redo, and putting its text on the clipboard would quietly replace whatever is there. The down-arrow saves the recording through Files rather than a share sheet: it is evidence somebody wants to keep, and Save to Files is the ending that leaves it somewhere they can find again. The bytes are read into the export document so the picker can offer donottype-.wav — the store's own copy is named for a UUID, which is an implementation detail to put in front of a user. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- ios/App/DictationModel.swift | 60 ++++++++++++++++++++++++++++++++++++ ios/App/SettingsView.swift | 46 +++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/ios/App/DictationModel.swift b/ios/App/DictationModel.swift index 667a6b3..14ab4b5 100644 --- a/ios/App/DictationModel.swift +++ b/ios/App/DictationModel.swift @@ -3,6 +3,7 @@ import DoNotTypeCore import Foundation import SwiftUI import UIKit +import UniformTypeIdentifiers /// Records, transcribes, and hands the result to the keyboard through the App Group. /// @@ -50,6 +51,8 @@ final class DictationModel { /// and a silent one should not look different. private(set) var levels = DictationModel.silentMeter private(set) var retryingIDs: Set = [] + /// The recording waiting for the user to say where to put it. Nil unless the picker is open. + var audioExport: AudioExport? private(set) var audioBytes: Int64 = 0 private(set) var connectionStatus: String? private(set) var isCheckingConnection = false @@ -1477,6 +1480,38 @@ final class DictationModel { await refresh() } + /// Transcribes a stored recording again, for a dictation that arrived and arrived wrong. + /// + /// The same request Retry makes, and deliberately not the same ending: a retry is recovering + /// words that never reached the keyboard, so it delivers them. Nothing is waiting on these — + /// the user is reading their own history — and putting them on the clipboard would quietly + /// replace whatever is there. The row updates; Copy is one button away. + func redo(_ record: DictationRecord) async { + guard let coordinator = makeCoordinator() else { + state = .failed("Add your API key in Settings.") + return + } + retryingIDs.insert(record.id) + defer { retryingIDs.remove(record.id) } + + _ = await coordinator.retry(record) + await refresh() + } + + /// Loads a recording for the export sheet, which is what asks the user where it goes. + /// + /// Read here rather than handed to the sheet as a file URL: the store's copy lives in the app + /// container under a UUID, and a share sheet offering `A1B2….wav` names the file after an + /// implementation detail. + func prepareAudioExport(_ record: DictationRecord) async { + do { + let audio = try await history.audioFile(for: record) + audioExport = AudioExport(data: audio.data, name: record.audioExportName) + } catch { + state = .failed(error.localizedDescription) + } + } + func retryAll() async { guard let coordinator = makeCoordinator() else { return } let pending = await history.retryable() @@ -1603,6 +1638,31 @@ final class DictationModel { }() } +/// A recording on its way out of the history, named for when it was said. +/// +/// Write-only. The history is written by dictating, not by importing a file, so there is no +/// direction in which this document is ever read back. +struct AudioExport: FileDocument, Identifiable { + static var readableContentTypes: [UTType] { [.wav] } + + let id = UUID() + let data: Data + let name: String + + init(data: Data, name: String) { + self.data = data + self.name = name + } + + init(configuration: ReadConfiguration) throws { + throw CocoaError(.fileReadUnsupportedScheme) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + /// Keychain wrapper. The key never goes in `UserDefaults` — this is a bring-your-own-key app, so /// the key is the whole privacy story. enum KeychainStore { diff --git a/ios/App/SettingsView.swift b/ios/App/SettingsView.swift index 4dc9910..73f99ea 100644 --- a/ios/App/SettingsView.swift +++ b/ios/App/SettingsView.swift @@ -315,7 +315,8 @@ struct SettingsView: View { } footer: { Text( "Failed dictations always keep their audio until they succeed, whatever this is " - + "set to — otherwise Retry could not work." + + "set to — otherwise Retry could not work. Keeping it for the ones that " + + "worked is what lets you redo a transcription or save the recording." ) } } @@ -775,6 +776,18 @@ struct HistoryView: View { .navigationTitle("History") // Searching is the point of keeping history at all; a log you cannot search is storage. .searchable(text: $model.query.text, prompt: "Transcripts, errors, apps") + // Files rather than a share sheet: the recording is evidence somebody wants to keep, and + // Save to Files is the ending that leaves it somewhere they can find again. + .fileExporter( + isPresented: Binding( + get: { model.audioExport != nil }, + set: { if !$0 { model.audioExport = nil } }), + document: model.audioExport, + contentType: .wav, + defaultFilename: model.audioExport?.name + ) { _ in + model.audioExport = nil + } .overlay { if model.records.isEmpty { ContentUnavailableView( @@ -816,6 +829,10 @@ private struct HistoryRow: View { Spacer() + // One circular arrow per row, doing the thing that row needs. On a failed dictation + // that is Retry — the words never reached the keyboard, so they are delivered. On a + // completed one it is a redo: the words arrived and arrived wrong, and re-running the + // transcription is the only fix that does not mean saying it all again. if model.retryingIDs.contains(record.id) { ProgressView() } else if record.canRetry { @@ -825,13 +842,38 @@ private struct HistoryRow: View { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) - } else if record.status == .completed { + .accessibilityLabel("Retry this dictation") + } else if record.canRedo { + Button { + Task { await model.redo(record) } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .accessibilityLabel("Redo the transcription") + } + + if record.status == .completed { Button { UIPasteboard.general.string = record.text } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.borderless) + .accessibilityLabel("Copy transcript") + } + + // The recording is the evidence behind the row: it is what a wrong transcript should + // be judged against, and the one thing here that cannot be reconstructed. Offered + // wherever it still exists. + if record.canRedo { + Button { + Task { await model.prepareAudioExport(record) } + } label: { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .accessibilityLabel("Save the original audio") } } } From 15e93c91539748dd48ba2de823edbbaac16c4f8c Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:44:47 +0800 Subject: [PATCH 6/7] docs: record redo and save-the-recording in the parity table and changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new rows under Other capabilities, plus the one difference worth writing down: Android puts the redo on a line of its own because a fourth control beside the transcript leaves the transcript too narrow to read, while the other three give redo and Retry one control between them — a row can only ever want one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- CHANGELOG.md | 15 +++++++++++++++ docs/PARITY.md | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dbe66..7b12177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ repository's local calendar date. ## Unreleased +### Added + +- **A history row can transcribe its recording again, and hand you the recording.** Retry only ever + answered "the words never arrived". The more common complaint is that they arrived wrong — a name + misheard, a provider that was the wrong one for the accent — and that row is completed, which + `canRetry` excludes by definition, so the only recovery was to say the whole thing again. Both new + offers are keyed to the recording still being on disk rather than to the status, which for a + successful dictation means Keep audio was on when it was made; that setting now says so on all + four clients. A redo is not Retry renamed: Retry types the words it recovers, and nothing is owed + a cursor when you are reading your own history, so a redo updates the row and leaves Copy one + button away. It also drops the rewrite beside the transcript it replaced — the styled text is what + a row delivers, so keeping it would have replaced the words and still shown the old ones. Saved + recordings are named `donottype-.wav`, because on disk they are named for a + UUID. macOS, Windows, Android and iOS. + ## 0.3.0 - 2026-08-25 A model that quietly dropped most of a long dictation, and the three ways that is now caught. The diff --git a/docs/PARITY.md b/docs/PARITY.md index 921d3ef..bfb9872 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -140,6 +140,8 @@ dictate into, so the fallback has not been needed; it is a gap rather than an im | History with search and filters | ✅ | ✅ | ✅ | ✅ | | Retry a failed dictation | ✅ | ✅ | ✅ | ✅ | | …with the context it originally had | ✅ | ✅ | ✅ | n/a ⁷ | +| Redo a transcription that came back wrong | ✅ | ✅ | ✅ | ✅ | +| Save the recording out of History | ✅ | ✅ | ✅ | ✅ | | Retention policy and keep-audio | ✅ | ✅ | ✅ | ✅ | | Edit the prompt | ✅ | ✅ | ✅ | ✅ | | Log viewer, level, content toggle | ✅ | ✅ | ✅ | ✅ | @@ -155,6 +157,14 @@ dictate into, so the fallback has not been needed; it is a gap rather than an im | Hedge and retry on their own connection | ✅ | ✅ | — ¹¹ | ✅ | | Only type where the dictation started | ✅ | ✅ | ✅ | n/a ¹² | +Both new rows depend on the recording still being on disk, which for a dictation that *succeeded* +means keep-audio was on when it was made — so on all four clients the two offers appear per row +rather than as controls that are always there and usually disabled. Redo is not a rename of Retry: +Retry recovers words that never reached a cursor and so types them, while a redo is read in the +history and delivers nothing. macOS, Windows and iOS give the two one control between them, since +a row can only ever want one of them; Android puts the redo on a line of its own beneath the row, +because a fourth control beside the transcript leaves the transcript too narrow to read. + ⁹ Windows has no permission prompt for the microphone at all — access is a Settings toggle — so what it does instead is open the privacy page when recording is refused. @@ -249,7 +259,9 @@ one screen further in on two of them. punctuation without rephrasing. - **Grounding.** On/off, screenshot fallback, and two blocklists evaluated before capture. - **History.** Search, filters, per-item retry and delete, retention policy, per-dictation - timings, and a Context Inspector showing exactly what was sent with any dictation. + timings, and a Context Inspector showing exactly what was sent with any dictation. While a + recording is kept, its row can transcribe it again — the fix for a transcript that arrived and + arrived wrong — and save the recording itself to a file. - **Stats.** Median and p95 wait, wait per second spoken, success rate, retries, and a per-model breakdown measured on the microphone and network in use rather than on a vendor's benchmark. - **Prompt.** The contract is editable in place on any platform, validated before saving, and From fd332af7af14edb1f6f566f3d92148797f6c3825 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 28 Aug 2026 23:46:15 +0800 Subject: [PATCH 7/7] Windows: leave double-click as retry-only, and name the redo in the hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A completed row is not broken, so a double-click on one is as likely to be exploratory as intended — and every one of these spends a request. The redo now asks for itself from the row menu, which is where it was discoverable anyway. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jptSNBmsbgyPSf8FMpxpa --- windows/DoNotType.App/SettingsForm.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/windows/DoNotType.App/SettingsForm.cs b/windows/DoNotType.App/SettingsForm.cs index fa21e1c..8b17ac7 100644 --- a/windows/DoNotType.App/SettingsForm.cs +++ b/windows/DoNotType.App/SettingsForm.cs @@ -491,15 +491,15 @@ private TabPage BuildHistoryTab() toolbar.Controls.Add(deleteAll); toolbar.Controls.Add(_historySummary); - // Per-item: double-clicking a row transcribes its recording again. A retry on a failed - // row, a redo on one that succeeded and came back wrong — the same request either way. + // Per-item retry: double-clicking a failed row reissues just that dictation. Deliberately + // still only a failed one, though a completed row can now be re-transcribed as well: that + // row is not broken, a double-click on it is as likely to be exploratory as intended, and + // every one of these spends a request. The redo asks for itself, from the menu below. _history.DoubleClick += async (_, _) => { - if (SelectedRecord() is not { CanRedo: true } record) return; + if (SelectedRecord() is not { CanRetry: true } record) return; - _historySummary.Text = record.Status == DictationStatus.Completed - ? "Transcribing again…" - : "Retrying…"; + _historySummary.Text = "Retrying…"; await _controller.RetryAsync(record).ConfigureAwait(true); RefreshHistory(); }; @@ -665,7 +665,7 @@ private void RefreshHistory() _historySummary.Text = $"{shown} · {retryable} to retry · {bytes / 1024} KB audio" - + " (double-click to transcribe again · right-click to save the audio" + + " (double-click to retry · right-click to redo one or save its audio" + " · Delete key or right-click to remove)" + performance; }