Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<when it was said>.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
Expand Down
53 changes: 53 additions & 0 deletions Sources/DoNotTypeApp/SettingsModel.swift
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -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."
Expand Down
33 changes: 31 additions & 2 deletions Sources/DoNotTypeApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions Sources/DoNotTypeCore/DictationRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
15 changes: 14 additions & 1 deletion Sources/DoNotTypeCore/TranscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, any Error> {
var updated = record
Expand All @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions Tests/DoNotTypeCoreTests/DictationJourneyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading