Decouple transcripts from note text; end-to-end watch transfer acks

Schema v3: transcription attempts accumulate on AudioInfo (text, locale,
confidence, forced flag) — latest attempt is displayed, payload.text is
purely user writing, v2 notes migrate at read time. Editor shows the
transcript in its own live-updating section with insert-into-note; search
matches transcripts weighted by confidence. Watch recordings are now
deleted only on the phone's durable-ingest ack (transferUserInfo), closing
every phone-side loss window; failed sidecar writes unstage instead of
orphaning, and unreadable inbox recordings are surfaced in logs.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
This commit is contained in:
2026-07-16 12:29:49 -04:00
parent c29d25748a
commit c5c90e9441
15 changed files with 546 additions and 55 deletions
+33
View File
@@ -44,6 +44,10 @@ struct NoteEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var text = ""
/// The note text as last loaded from the entity, so an external change
/// (a transcription landing while this editor is open) can be told apart
/// from the user's own typing: refresh only while `text == loadedText`.
@State private var loadedText = ""
@State private var tagsText = ""
@State private var saveError: String?
@State private var recordedAudio: AudioRecorderService.Recording?
@@ -115,9 +119,18 @@ struct NoteEditorView: View {
await contextService.refresh()
case .edit(let note):
text = note.text
loadedText = note.text
tagsText = note.tags.joined(separator: ", ")
}
}
// A transcription finishing while this editor is open updates the
// entity underneath us; mirror it into the text field as long as the
// user hasn't typed (their edits always win over a live refresh).
.onChange(of: editedNote?.text) { _, newValue in
guard let newValue, text == loadedText else { return }
text = newValue
loadedText = newValue
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 420)
#endif
@@ -190,6 +203,21 @@ struct NoteEditorView: View {
transcriptionStatusRow(for: note)
retranscribeMenu(for: note)
}
// The transcript is machine output, kept apart from the user's own
// text: shown read-only here (it updates live as attempts land) with
// a one-tap copy into the editable note body.
if let transcript = note.transcriptText, !transcript.isEmpty {
Section("Transcript") {
Text(transcript)
.textSelection(.enabled)
.foregroundStyle(.secondary)
Button {
text = text.isEmpty ? transcript : text + "\n\n" + transcript
} label: {
Label("Insert into Note", systemImage: "text.insert")
}
}
}
}
@ViewBuilder
@@ -263,6 +291,11 @@ struct NoteEditorView: View {
return false
}
private var editedNote: NoteEntity? {
if case .edit(let note) = mode { return note }
return nil
}
private var canSave: Bool {
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
if recordedAudio != nil { return true }
+24 -7
View File
@@ -56,7 +56,12 @@ struct SearchView: View {
private var results: [NoteEntity] {
guard !searchText.isEmpty else { return [] }
return notes.filter { $0.matches(search: searchText) }
return notes
.compactMap { note in note.searchScore(for: searchText).map { (note, $0) } }
.sorted { lhs, rhs in
lhs.1 != rhs.1 ? lhs.1 > rhs.1 : lhs.0.createdAt > rhs.0.createdAt
}
.map(\.0)
}
private func delete(_ note: NoteEntity) {
@@ -68,12 +73,24 @@ struct SearchView: View {
}
extension NoteEntity {
/// Case-insensitive match against text, tags, and capture location.
func matches(search: String) -> Bool {
/// Case-insensitive relevance of this note to a search, or nil when nothing
/// matches. The score is the strongest matching field: the user's own text
/// and tags outrank transcript matches, and a transcript match is weighted
/// by the engine's confidence in it a shaky transcription can still be
/// found, it just ranks below solid matches. Confidence-less engines get a
/// mid-scale weight rather than sinking to the bottom.
func searchScore(for search: String) -> Double? {
let needle = search.lowercased()
return text.lowercased().contains(needle)
|| tags.contains { $0.lowercased().contains(needle) }
|| (placeName?.lowercased().contains(needle) ?? false)
|| (locality?.lowercased().contains(needle) ?? false)
var score = 0.0
if text.lowercased().contains(needle) { score = max(score, 1.0) }
if tags.contains(where: { $0.lowercased().contains(needle) }) { score = max(score, 0.9) }
if let transcript = transcriptText, transcript.lowercased().contains(needle) {
score = max(score, 0.5 + 0.4 * (transcriptConfidence ?? 0.5))
}
if placeName?.lowercased().contains(needle) == true
|| locality?.lowercased().contains(needle) == true {
score = max(score, 0.4)
}
return score > 0 ? score : nil
}
}