Snap and Snap & Talk: ⌘Z in a text field undoes the typing, and a narration whose recorder stops by itself is kept
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 22/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- swift
- Domain
- audio-video-rtc, desktop-dev
Research direction
Two separate bugs in the Mac app. For the first, start at the Undo and Redo buttons in SnapEditorView.swift and at undo() in ImageWorkspaceEditing.swift. For the second, start at the meter timer and keepNarration in ReadbackModel.swift. Checks live in ImageWorkspaceChecks.swift and ReadbackChecks.swift, run via LocalVoice --check-image-workspace and --check-readback. Done means the new checks pass on native macOS, not only in the offscreen harness.
Written by the indexing model from the issue text.
Description
Outcome: Snap and Snap & Talk: ⌘Z in a text field undoes the typing, and a narration whose recorder stops by itself is kept.
Verified by an independent skeptic against the release train head d7cea35 (round four of the 15–16 October pass: energy, leaks, first run, keyboard, concurrency, settings propagation, public docs).
Snap editor: ⌘Z in Title, Tags or Notes undoes the last mark on the image, not the typing
What a person sees: After any image edit (an arrow, a crop, a rotate or a text box), someone types a title or note and presses ⌘Z to fix a typo. The last annotation disappears from the image and the typo stays. Each further ⌘Z removes another mark, and while any image edit remains, typing in these fields cannot be undone from the keyboard.
Evidence (d7cea35): SnapEditorView.swift:46-49: Undo carries .keyboardShortcut("z", modifiers: .command) and Redo ("z", [.command, .shift]), disabled only when their stacks are empty. ImageWorkspaceEditing.swift:91, :109-121: undoStack holds SnapEdit only, and undo() restores draft.edit. SnapModel.swift:5-14: title, notes and tags are outside edit. SnapEditorView.swift:128-133: the Title and Tags TextFields and the Notes TextEditor bind to draft.title, draft.tags and draft.notes. CaptureImagePreview.swift:405-416: performKeyEquivalent handles Escape, arrows and zoom keys (command(for:) :418-424 has no 'z') and otherwise calls super, where the hosting view's SwiftUI key equivalent matches before the main menu. main.swift:455-456: Edit › Undo/Redo send undo:/redo: to the first responder, which is the field's own text undo. No check sends ⌘Z while a text field has focus.
Change: Make the change in SnapEditorView.swift only.
- Add
private enum DetailsField: Hashable { case title, tags, notes }and@FocusState private var detailsField: DetailsField?. Keep this separate from the existingtextFocused, which the "Text on image" caption uses to end its undo run. - Add
.focused($detailsField, equals: .title)to the Title TextField,.tagsto the Tags TextField and.notesto the Notes TextEditor. - Change Undo to
.keyboardShortcut(detailsField == nil ? KeyboardShortcut("z", modifiers: .command) : nil). Change Redo the same way with[.command, .shift]. The optional-shortcut form is available on the macOS 14 deployment target, and the same pattern is used at Sources/StageKit/PersonaLibraryView.swift:136. - Leave these as they are:
- the buttons, their
.disabledand their help text; - CaptureImagePreviewPanel;
- the canvas, which keeps image undo;
- the caption TextEditor, which keeps image undo as #331 decided.
- the buttons, their
- Add one sentence to docs/snap.md:22: in Title, Tags and Notes, ⌘Z and ⇧⌘Z belong to the text field, not to the image.
PR wording should say "⌘Z in Title, Tags or Notes no longer removes marks from the image". It should not promise that the field's own undo reaches the saved Snap. That separate problem should be recorded with its own evidence: on macOS 26.5, TextKit 2 field and TextEditor undo posts no did-change notification, so SwiftUI bindings stay stale. Do not widen this fix to handle it.
Acceptance: This goes in LocalVoice --check-image-workspace (run by scripts/test.sh:91), inside the existing live-panel block at ImageWorkspaceChecks.swift:156. At that point addText() has already left a mark, so Undo is enabled.
- Record
liveEditor.draft.editandliveEditor.undoStack.count. - Find the editable NSTextField whose placeholder is "Title" under
livePanel.contentView. CalllivePanel.makeFirstResponderon it, thensettle(preview)so FocusState updates. - Build a ⌘Z keyDown (keyCode 6, characters "z", windowNumber
livePanel.windowNumber) and assert:livePanel.performKeyEquivalent(with:)returns false;- draft.edit and the undo count are unchanged.
- Do the same for ⇧⌘Z and the redo stack. Repeat step 3 with the "Image notes" TextEditor's NSTextView as first responder.
- Make
liveCanvasfirst responder, settle, send ⌘Z, and assert that performKeyEquivalent returns true and the undo count drops by exactly one. - Select the text mark, focus its caption NSTextView, settle, send ⌘Z, and assert that the image undo still runs. This keeps the #331 contract.
Step 3 fails on d7cea35: the scratch program showed a SwiftUI button shortcut firing from a non-key
Value: 3 of 5 · Grammar: Quality · Risk: Low for the change itself: one view, an optional shortcut, and a pattern already used in the repo.
Behaviour after the fix
- ⌘Z in Title, Tags or Notes goes to AppKit's window undo manager. That manager is shared, so after its own typing has been undone, ⌘Z in one field can undo earlier typing in Notes or the caption TextEditor. This is standard Mac behaviour.
- On macOS 26.5, TextKit 2 undo did not notify SwiftUI in my offscreen program. The field can show the reverted text while draft.tit
Snap & Talk: a narration whose recorder stops by itself is kept, not left 'recording' at 0:00 and then deleted on Stop
What a person sees: If the recorder stops under someone mid-narration (a full disk, or an input device that drops), the page and toolbar keep saying narration is live, the timer reads 0:00 and the 5-minute auto-stop can never fire. Pressing Stop then deletes the WAV holding everything said before the failure and reports 'No clear speech was captured'. Cancel also skips its 'Discard this narration?' question. Report a Problem's voice note already ends and keeps its audio in the same situation.
Evidence (d7cea35): ReadbackModel.swift:402 declares AVAudioRecorderDelegate. :1059-1062 sets capture.delegate = self. Neither audioRecorderDidFinishRecording nor audioRecorderEncodeErrorDidOccur exists outside BugReportModel.swift:42-47. The meter Timer (:1046-1055) sets recordingElapsed = recorder.currentTime (:1049); AVAudioRecorder reports currentTime as 0 when not recording, so the >= 300 stop (:1052) never fires and the UI shows 0:00. The Timer is invalidated only in keepNarration (:1071) and cancelNarration (:1123). keepNarration computes duration = recorder?.currentTime ?? recordingElapsed (:1070). With 0, the guard at :1074 fails, :1075 removes context.pendingURL and :1076 restores with 'No clear speech was captured…'. requestCancelNarration uses cancelAsksFirst(elapsed: recordingElapsed) (:1109), which is false at 0. For comparison, BugReportRecorder forwards both callbacks to onFinish, and BugReportModel wires that to stopRecording, which reads the file.
Change: In Sources/LocalVoice/ReadbackModel.swift:
- Meter tick (:1049): change to
self.recordingElapsed = max(self.recordingElapsed, recorder.currentTime). A stopped recorder can then never reset the take's length, the on-screen time or the Cancel question to 0. - keepNarration (:1070): change to
let duration = max(recordingElapsed, recorder?.currentTime ?? 0). - Add two methods:
nonisolated func audioRecorderDidFinishRecording(_ r: AVAudioRecorder, successfully: Bool)nonisolated func audioRecorderEncodeErrorDidOccur(_ r: AVAudioRecorder, error: Error?)
Each doesTask { @MainActor in self.narrationRecorderEnded(r) }, the same pattern as BugReportModel.swift:42-47.
- Add an internal
func narrationRecorderEnded(_ ended: AnyObject). It returns unlessisRecordingis true andended === recorder, so the callback that follows our own stop(), Cancel or Quit is a no-op, including after a new take has started. Otherwise it calls keepNarration(cue: true). If the take was kept (the section is now queued), it replaces the notice with one plain sentence, for example: "The microphone stopped, so narration ended. What was recorded was kept." A too-short or quiet take keeps the existing "No clear speech" path.
No new UI, settings or doors. No other files change except the check.
Acceptance: Add these to LocalVoice --check-readback, next to the cancel checks (ReadbackChecks.swift:945-1009), using SyntheticNarrationRecorder(seconds: 1.0, peak: -10).
(a) Self-stop with the callback:
- Start a take and wait for
recordingElapsed == 1.0. - Set
take.reportedTime = 0(a stopped AVAudioRecorder), then sleep at least 300 ms so several meter ticks run. - Assert
recordingElapsedis still 1.0 (this guards against the race). - Call
model.narrationRecorderEnded(take!). - Assert:
!isRecording; the section is.queuedwithaudio == dir/narration.wavmatchingtake.written; no narration-pending file remains;onNarrationNotHearddid not fire; the notice names the microphone stopping. After another 300 ms,recordingElapsedand the section are unchanged.
(b) Self-stop with no callback, then Stop:
- Same setup, but call stopNarration() instead.
- Assert the take is kept, not "No clear speech was captured".
(c) Long self-stopped take, then Cancel:
- reportedTime 74, then 0, then several ticks.
- Assert
requestCancelNarration()still asks first.
(d) A late callback after a normal Stop:
- Call narrationRecorderEnded with the old take after Stop, and again after a new
Value: 2 of 5 · Grammar: Quality · Risk: Low to medium.
- The trigger itself (how often AVAudioRecorder stops on its own on macOS) is unproven natively. The fix is sound whether or not it happens; only its frequency is uncertain.
- The candidate as written (callback plus max in keepNarration only) still deletes the take whenever a meter tick lands before the callback, and on every Stop without a callback. The monotonic meter change is required.
- The identity guard is required. AVAudioRecorder also calls audioRecorderDidFinishRecor
Refs #63
Build-ready record from round four of the overnight lead pass, verified by an independent skeptic per item. Closes only after native acceptance.
- Dominant language
- Swift
- Stars
- 0
- Forks
- 1
- Avg merge
- 8h 5m
- Merged PRs (30d)
- 215
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Ship-Work/workbench
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day
-
documentation good first issue help wanted
Difficulty 2/5 Half a day Newbie friendliness 74/100
Ship-Work/workbench#29 · 1 comment ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 55/100
Maintainers usually reply within 1 day
All issues in Ship-Work/workbench
Similar issues
-
bug good first issue help wanted
Difficulty 2/5 1-3 hours Newbie friendliness 63/100
marcovisona/arca#5 ·
-
enhancement good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
luis-guillen/airy#2 ·
-
accessibility bug medium-priority mobile
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
lognorman20/monaco#6247 ·
-
documentation windows
Difficulty 1/5 Under an hour Newbie friendliness 85/100
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
521xueweihan/HelloGitHub#4060 ·