Skip to content

Timeout fix, Edit Mode in Electron apps, cursor-aware cleanup, Prompt Mode, vocabulary mappings, custom sounds, faster stop-to-paste#254

Closed
swimmer-303 wants to merge 9 commits into
zachlatta:mainfrom
swimmer-303:improvements-2026-07
Closed

Timeout fix, Edit Mode in Electron apps, cursor-aware cleanup, Prompt Mode, vocabulary mappings, custom sounds, faster stop-to-paste#254
swimmer-303 wants to merge 9 commits into
zachlatta:mainfrom
swimmer-303:improvements-2026-07

Conversation

@swimmer-303

@swimmer-303 swimmer-303 commented Jul 5, 2026

Copy link
Copy Markdown

This PR bundles a set of fixes and features developed while dogfooding FreeFlow daily. Each change is a separate commit with a descriptive message, so it can be reviewed commit-by-commit — and I'm happy to split any subset into standalone PRs if that's easier to review or land.

Fixes

Honor configured timeouts instead of capping transfers at 30s (fixes #253)
LLMAPITransport pinned timeoutIntervalForResource = 30 at the session level, silently overriding the README-documented transcription_timeout_seconds / post_processing_timeout_seconds / context_request_timeout_seconds overrides — any transfer over 30s was killed regardless of settings, breaking slow local models. Sessions now derive their whole-transfer budget from each request's configured timeout (30s floor), cached per timeout value so TLS connection reuse is preserved. Uploads keep their fresh-session-per-call poisoning defense.

Edit Mode no longer silently overwrites selections in Electron/Chromium apps (fixes #237)
Chromium-based apps build their accessibility tree lazily, so AXSelectedText reads failed and Edit Mode fell back to dictation — overwriting the user's selection in VS Code, Gmail-in-Chrome, etc. FreeFlow now sets Electron's AXManualAccessibility opt-in on the frontmost app before reading the selection (harmless no-op elsewhere), plus Chromium's AXEnhancedUserInterface for known browsers only (scoped narrowly since that attribute can interact badly with window-manager utilities). The first read after activation gets one 100 ms retry because the tree builds asynchronously.

Features

Mishearing → correction mappings in custom vocabulary (closes #125, help wanted)
Vocabulary entries can map heard forms to the intended term with an arrow — cloud code -> Claude Code — and multiple heard forms can share one correction: cloud code | clod code -> Claude Code. Pairs are passed to the post-processing model as an explicit corrections block (with a guard against over-applying); plain entries behave exactly as before. Parser verified against plain entries, single/multi-variant mappings, mixed comma-separated lines, =>, and malformed input.

Cursor-aware spacing, capitalization, and punctuation (fixes #200)
Dictating into the middle of a sentence used to produce a stray leading capital, a trailing period, and text jammed against existing words. FreeFlow now reads up to 200 chars before / 80 after the insertion point (via AXStringForRange with an AXValue-slice fallback; secure fields are never read) and uses it three ways: the cleanup prompt gets the surrounding text with rules to continue the sentence flow; a separating space is prepended deterministically when pasting directly after a word character or closing punctuation (skipped in Edit Mode); and the auto trailing space after sentence punctuation is suppressed when the following text already provides separation.

Prompt Mode (closes #196)
Off (default) / Always / Only in AI apps. Condenses rambling dictation into a tight, intent-preserving prompt before pasting. Auto mode detects Claude, ChatGPT, Cursor, and similar tools from the frontmost app's bundle ID and window title. Condensation is a section appended to the cleanup system prompt in the same LLM pass, so it adds no latency; the instruction-execution guard still applies, and Edit Mode is unaffected.

Configurable feedback sounds (closes #107)
The hardcoded Tink/Pop/Basso start/stop/error sounds are now per-event settings with pickers and preview buttons covering all 14 built-in macOS alert sounds. Defaults match the old behavior.

Performance

Prewarm API connections while recording
Transcription uploads deliberately use a fresh URLSession per upload, which put a full DNS + TCP + TLS handshake (~100–300 ms) on the critical stop-to-paste path of every dictation. A one-shot session is now created and its connection opened when recording starts; the next upload to the same host consumes it — still exactly one session per upload, so the poisoning defense is unchanged. The shared data session is warmed the same way for post-processing/context requests.

Start transcription before capture teardown
AVCaptureSession.stopRunning() can take 100–300 ms and ran before the stop completion fired. The audio cut-off point (sample-buffer delegate removal) and the queued-tail drain are byte-for-byte unchanged; only the slow teardown now happens after the completion, so the upload starts immediately.

Latency instrumentation
Transcription uploads and LLM requests now log durations to the existing com.zachlatta.freeflow os_log subsystem, so pipeline latency is visible in Console.

UI

Menu bar panel redesign + live waveform icon
The menu bar extra now uses a window-style panel, and while recording the menu bar icon renders a small live waveform driven by the input level.

Notes

Summary by CodeRabbit

  • New Features

    • Added Prompt Mode to condense dictation before pasting, plus cursor-aware cleanup for smoother text insertion.
    • Expanded Custom Vocabulary to support mishearing corrections and multiple heard variants.
    • Added configurable start, stop, and error sounds in Settings.
    • Updated the menu bar indicator to show live recording activity more clearly.
  • Bug Fixes

    • Improved dictation in some apps by better handling text selection and accessibility.
    • Reduced stop-to-paste delay and removed an occasional timeout issue that could interrupt longer dictations.

The session-level timeoutIntervalForResource=30 silently overrode the
transcription_timeout_seconds, post_processing_timeout_seconds, and
context_request_timeout_seconds settings, killing long transfers to
slow local models. Sessions are now cached per resource timeout derived
from each request's configured timeout, preserving connection reuse.
Uploads keep their fresh-session-per-call behavior.

Fixes zachlatta#253
Vocabulary entries can now map heard forms to the intended term with
an arrow ("cloud code -> Claude Code"), with "|" separating multiple
heard forms. Pairs are passed to the post-processing model as an
explicit corrections block in both dictation and Edit Mode prompts;
plain entries behave exactly as before.

Closes zachlatta#125
- Recording start, stop, and error sounds are now configurable in
  Settings with a picker and preview per event across the built-in
  macOS alert sound catalog (zachlatta#107)
- Settings hint text documenting the new vocabulary mapping syntax
- Window-style menu bar panel redesign with live waveform icon while
  recording (in-progress work from the working tree)
- Agent guardrails in CLAUDE.md and changelog entries
Transcription uploads deliberately use a fresh URLSession per upload,
which put a full DNS + TCP + TLS handshake on the critical
stop-to-paste path of every dictation. Now a one-shot session is
created and its connection opened when recording starts, and the next
upload to the same host consumes it — still one session per upload, so
the connection-poisoning defense is unchanged. The shared data session
is warmed the same way for post-processing and context requests.
AVCaptureSession.stopRunning() can take 100-300ms and ran before the
stop completion fired, delaying the transcription upload. The audio
cut-off point (sample buffer delegate removal) and the queued-tail
drain are unchanged; only the slow session teardown now happens after
the completion.
Chromium-based apps expose no AXSelectedText until a client opts in,
so Edit Mode silently fell back to dictation and overwrote selections
in VS Code, Gmail-in-Chrome, and other Electron apps. Set Electron's
AXManualAccessibility on the frontmost app before reading the
selection (harmless where unsupported), plus Chromium's
AXEnhancedUserInterface for known browsers only, since that attribute
can interact badly with window-manager utilities. The first read after
activation gets one 100ms retry because the tree builds asynchronously.

Fixes zachlatta#237
Spacing, capitalization, and punctuation were context-blind: dictating
into the middle of a sentence produced a leading capital, a trailing
period, and jammed against the existing text. Now the focused element's
text around the insertion point (200 chars before, 80 after, read via
AXStringForRange with an AXValue-slice fallback; secure fields are
never read) is captured with the app context and:

- flows into the post-processing prompt with rules to continue the
  sentence flow at the insertion point
- drives a deterministic separating space when pasting directly after
  a word character or closing punctuation (skipped in Edit Mode)
- suppresses the auto trailing space when the text after the cursor
  already provides separation

Fixes zachlatta#200
Off (default) / Always / Only in AI apps. Auto mode detects Claude,
ChatGPT, Cursor, and similar tools from the frontmost app's bundle ID
and window title. Condensation is a section appended to the cleanup
system prompt in the same LLM pass, so it adds no latency, and the
instruction-execution guard still applies. Edit Mode is unaffected.

Closes zachlatta#196
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds Prompt Mode (dictation condensation), cursor-aware cleanup and paste spacing, custom vocabulary mishearing mappings, configurable feedback sounds, a redesigned menu bar panel with live waveform icon, connection prewarming and timeout fixes for lower latency, and an Edit Mode accessibility fix for Electron/Chromium apps.

Changes

Latency and Timeout Improvements

Layer / File(s) Summary
Session reuse and connection prewarming
Sources/LLMAPITransport.swift
Adds timeout-keyed URLSession reuse, prewarmed upload/shared connections, and one-shot prewarmed session consumption for uploads.
Prewarm wiring in AppState
Sources/AppState.swift
Calls prewarmAPIConnections() before starting the audio engine when recording begins.
Timeout configuration and latency logging
Sources/TranscriptionService.swift, Sources/PostProcessingService.swift
Timeouts derive from configuredTimeoutSeconds statics, removing the silent 30s cap; request/upload durations are logged via os_log.
AudioRecorder stop sequencing
Sources/AudioRecorder.swift
Detaches the sample buffer delegate before draining tail buffers and defers session teardown until after the completion callback.

Edit Mode Selection Fix and Cursor Context

Layer / File(s) Summary
Cursor text context model
Sources/AppContextService.swift
Adds CursorTextContext and cursorTextContext(from:), extending contextSummary and AppContext with before/after cursor text.
AX tree activation retry for selection
Sources/AppContextService.swift
Activates the accessibility tree (with Chromium handling) before reading selection, retrying once, fixing Edit Mode fallback in Electron/Chromium apps.
Cursor-aware paste formatting and system prompt rules
Sources/PostProcessingService.swift, Sources/AppState.swift
System prompt adds cursor-splice rules; paste path applies smart leading-space logic and passes textAfterCursor to writeTranscriptToPasteboard.

Prompt Mode Feature

Layer / File(s) Summary
PromptModeSetting and persisted settings
Sources/AppState.swift
Adds PromptModeSetting enum, new UserDefaults keys, and published sound/promptMode properties loaded at init.
Auto-detection and postProcess wiring
Sources/AppState.swift
Adds shouldCondenseForPrompt/isAITargetContext and passes condenseToPrompt into postProcess.
Condensation prompt and process() plumbing
Sources/PostProcessingService.swift
Adds promptCondensationSection and threads condenseToPrompt through postProcess/processWithFallback/process.
Settings UI for Prompt Mode
Sources/SettingsView.swift
Adds a "Prompt Mode" settings card.

Custom Vocabulary Mishearing Mapping

Layer / File(s) Summary
Vocabulary parsing and prompt builder
Sources/PostProcessingService.swift
Adds ParsedVocabulary/parseVocabularyEntries supporting arrow/pipe mishearing syntax and rewrites vocabularyPromptSection(for:).
Settings vocabulary instructions
Sources/SettingsView.swift
Adds instructions describing the mishearing mapping syntax.

Configurable Feedback Sounds

Layer / File(s) Summary
Sound helper methods and call sites
Sources/AppState.swift
Adds playStartSound/playStopSound/playErrorSound and replaces hardcoded sound calls.
Sound settings UI
Sources/SettingsView.swift
Adds start/stop/error sound pickers via soundPickerRow.

Menu Bar and Settings UI Redesign

Layer / File(s) Summary
Live waveform menu bar icon
Sources/App.swift, Sources/AppState.swift
Adds LiveWaveMenuBarIcon rendering a rolling waveform during recording, fed by published menuBarAudioLevel.
Menu bar panel core layout
Sources/MenuBarView.swift
Rebuilds panel with header, permission banners, dictate button, inline error, and last-transcript card.
Menu bar controls and shortcuts
Sources/MenuBarView.swift
Replaces menu-based controls with structured rows computing conflicting shortcuts.
Update banner and footer
Sources/MenuBarView.swift
Adds updateBanner and a rewritten footer with async update checking.
Reusable panel components
Sources/MenuBarView.swift
Adds PanelBannerButton, HistoryRow, PanelControlRow, PanelFooterButton.
Settings sidebar redesign
Sources/SettingsView.swift
Adds appVersion display and SettingsSidebarRow-driven sidebar.

Documentation Updates

Layer / File(s) Summary
Changelog, agent guardrails, and README updates
CHANGELOG.md, CLAUDE.md, README.md
Adds Unreleased changelog entries, a CLAUDE.md agent-guardrails document, and expanded README feature descriptions.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant AudioRecorder
  participant TranscriptionService
  participant LLMAPITransport
  participant PostProcessingService
  AppState->>LLMAPITransport: prewarmAPIConnections() at recording start
  AppState->>AudioRecorder: stopRecording(completion)
  AudioRecorder->>AudioRecorder: detach delegate, drain tail buffers
  AudioRecorder-->>AppState: completion(outputURL)
  AppState->>TranscriptionService: transcribeAudioWithURLSession
  TranscriptionService->>LLMAPITransport: upload(for:from:) using prewarmed session
  LLMAPITransport-->>TranscriptionService: response, duration logged
  AppState->>PostProcessingService: postProcess(condenseToPrompt)
  PostProcessingService->>LLMAPITransport: data(for:) via cached session
  LLMAPITransport-->>PostProcessingService: response, duration logged
  PostProcessingService-->>AppState: cleaned/condensed text
Loading
sequenceDiagram
  participant AppState
  participant AppContextService
  participant AccessibilityTree
  AppState->>AppContextService: collectContext()
  AppContextService->>AccessibilityTree: activate accessibility tree
  AppContextService->>AccessibilityTree: read selectedText
  AccessibilityTree-->>AppContextService: empty selection (Electron/Chromium)
  AppContextService->>AccessibilityTree: retry selectedText after activation
  AccessibilityTree-->>AppContextService: selection returned
  AppContextService->>AccessibilityTree: read cursorTextContext (before/after)
  AppContextService-->>AppState: AppContext with selection and cursor text
Loading

Possibly related PRs

  • zachlatta/freeflow#85: Both PRs modify the LLM request pipeline in Sources/LLMAPITransport.swift for chat/context/post-processing/transcription requests.
  • zachlatta/freeflow#216: Both PRs modify timeout logic in Sources/TranscriptionService.swift, Sources/PostProcessingService.swift, and Sources/AppContextService.swift.
  • zachlatta/freeflow#90: Both PRs modify AX-based selection/context collection in AppContextService relevant to Edit Mode command transforms.

Estimated code review effort: 4 (Complex) | ~75 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds a major menu bar UI redesign and CLAUDE.md guardrails, which are unrelated to the linked issue requirements. Split the UI overhaul and agent-guardrail docs into separate PRs, or add matching issue references if they are intended scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and covers the main changes, though it reads like a feature list rather than a polished sentence.
Linked Issues check ✅ Passed The PR addresses all linked issues: timeout handling, Electron/Chromium Edit Mode, misheard mappings, cursor context, Prompt Mode, and configurable sounds.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@swimmer-303

Copy link
Copy Markdown
Author

For what it's worth, I've been daily driving this build as my main dictation setup since making these changes — dictating into Claude Code, email, and browser text fields all day. The latency improvements are noticeable, and Prompt Mode + the vocabulary mappings have held up well in real use. Happy to split this into smaller PRs if that's easier to review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
Sources/SettingsView.swift (2)

1362-1367: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing accessibility labels on new hidden-label pickers.

Both the Prompt Mode picker and the three soundPickerRow pickers use .labelsHidden() without an .accessibilityLabel, unlike the existing overlayDisplaySection picker in this same file which explicitly sets .accessibilityLabel("Show on"). VoiceOver users will hear these controls without a description of what they control.

♿ Proposed fix
                 Picker("", selection: $appState.promptMode) {
                     Text("Off").tag(PromptModeSetting.off)
                     Text("Always").tag(PromptModeSetting.always)
                     Text("Only in AI apps").tag(PromptModeSetting.auto)
                 }
                 .labelsHidden()
+                .accessibilityLabel("Condense dictation")
                 .pickerStyle(.menu)
             Picker("", selection: selection) {
                 ForEach(options, id: \.self) { name in
                     Text(name).tag(name)
                 }
             }
             .labelsHidden()
+            .accessibilityLabel(label)
             .frame(width: 130)

Also applies to: 1392-1400

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SettingsView.swift` around lines 1362 - 1367, The hidden-label
pickers in SettingsView are missing accessibility descriptions, so add explicit
.accessibilityLabel values to the Prompt Mode picker and each soundPickerRow
picker while keeping .labelsHidden(). Use the existing overlayDisplaySection
picker’s accessibilityLabel as the pattern, and make sure the labels clearly
describe what each Picker controls by updating the relevant Picker views in
SettingsView.

351-353: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate appVersion computed property.

This appVersion (using infoDictionary?[...]) duplicates GeneralSettingsView.appVersion (Line 525-527, using object(forInfoDictionaryKey:)) in the same file. Consider extracting a single shared helper to avoid two slightly different implementations of the same lookup drifting apart.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SettingsView.swift` around lines 351 - 353, The file has two separate
`appVersion` computed properties with slightly different Bundle lookup
implementations, so consolidate them into one shared source of truth. Update
`SettingsView` and `GeneralSettingsView` to use a single helper for version
lookup, keeping only one implementation of the `Bundle.main` version access and
removing the duplicate property so both views reference the same symbol.
Sources/MenuBarView.swift (1)

3-14: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fragile private-API window matching — consider a maintained library.

Matching windows by className.contains("MenuBarExtra") depends on undocumented AppKit internals that could change across macOS releases (the comment already acknowledges this). Since .window-style MenuBarExtra has no first-party dismissal/introspection API, the community package MenuBarExtraAccess (introspectMenuBarExtraWindow) exists specifically to solve this more robustly than string-matching a private class name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/MenuBarView.swift` around lines 3 - 14, The window dismissal logic in
dismissMenuBarPanel relies on fragile private AppKit class-name matching via
NSApp.windows and window.className.contains("MenuBarExtra"), which can break
across macOS releases. Replace this string-based private-API check with the
maintained MenuBarExtraAccess approach, using its introspectMenuBarExtraWindow
helper to obtain and dismiss the MenuBarExtra panel more robustly while keeping
the status-bar window untouched.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/AppContextService.swift`:
- Around line 103-109: The first-read retry in collectSelectionSnapshot() is
blocking the main queue because it uses a synchronous sleep during the initial
selectedText fallback. Replace that firstActivation retry with an async delayed
re-read instead of usleep, keeping the logic in
AppContextService.collectSelectionSnapshot() and the rawSelectedText(from:)
fallback but moving the wait off the UI path so the main-queue shortcut start
flow stays responsive.

In `@Sources/MenuBarView.swift`:
- Around line 177-225: The dictateButton action is using a fixed DispatchQueue
delay after NSApp.hide(nil), which can start recording before focus actually
returns to the previous app. Update the start-dictation path in
dictateButton/appState.toggleRecording flow to wait for the next frontmost app
activation (or poll until NSWorkspace.shared.frontmostApplication changes)
before calling toggleRecording(), so prepareRecordingStart() and
collectSelectionSnapshot() see the correct app.

---

Nitpick comments:
In `@Sources/MenuBarView.swift`:
- Around line 3-14: The window dismissal logic in dismissMenuBarPanel relies on
fragile private AppKit class-name matching via NSApp.windows and
window.className.contains("MenuBarExtra"), which can break across macOS
releases. Replace this string-based private-API check with the maintained
MenuBarExtraAccess approach, using its introspectMenuBarExtraWindow helper to
obtain and dismiss the MenuBarExtra panel more robustly while keeping the
status-bar window untouched.

In `@Sources/SettingsView.swift`:
- Around line 1362-1367: The hidden-label pickers in SettingsView are missing
accessibility descriptions, so add explicit .accessibilityLabel values to the
Prompt Mode picker and each soundPickerRow picker while keeping .labelsHidden().
Use the existing overlayDisplaySection picker’s accessibilityLabel as the
pattern, and make sure the labels clearly describe what each Picker controls by
updating the relevant Picker views in SettingsView.
- Around line 351-353: The file has two separate `appVersion` computed
properties with slightly different Bundle lookup implementations, so consolidate
them into one shared source of truth. Update `SettingsView` and
`GeneralSettingsView` to use a single helper for version lookup, keeping only
one implementation of the `Bundle.main` version access and removing the
duplicate property so both views reference the same symbol.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ae1c5161-4394-4b5a-8cf3-da19e531aec4

📥 Commits

Reviewing files that changed from the base of the PR and between 13e2788 and 6e0bd94.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • Sources/App.swift
  • Sources/AppContextService.swift
  • Sources/AppState.swift
  • Sources/AudioRecorder.swift
  • Sources/LLMAPITransport.swift
  • Sources/MenuBarView.swift
  • Sources/PostProcessingService.swift
  • Sources/SettingsView.swift
  • Sources/TranscriptionService.swift

Comment on lines +103 to +109
if selectedText == nil && firstActivation {
// The app builds its accessibility tree asynchronously after
// activation; give it one brief chance on the first read so
// Edit Mode works on the very first dictation too.
usleep(100_000)
selectedText = rawSelectedText(from: appElement)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find call sites of collectSelectionSnapshot and inspect surrounding dispatch context.
rg -nP -C8 '\bcollectSelectionSnapshot\s*\(' Sources

Repository: zachlatta/freeflow

Length of output: 5301


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant type/actor annotations and surrounding call paths.
sed -n '1,220p' Sources/AppContextService.swift
printf '\n---- APPSTATE TOP ----\n'
sed -n '1,220p' Sources/AppState.swift
printf '\n---- APPSTATE CALL SITES ----\n'
sed -n '1848,2090p' Sources/AppState.swift

Repository: zachlatta/freeflow

Length of output: 26858


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where scheduleShortcutStart/startRecording/prepareRecordingStart are invoked
# and whether those call sites are on the main actor / main queue.
rg -n -C6 '\b(scheduleShortcutStart|startRecording|prepareRecordingStart|ensureMicrophoneAccess)\s*\(' Sources

printf '\n---- MAIN-ACTOR / DISPATCH HINTS ----\n'
rg -n -C4 '`@MainActor`|DispatchQueue\.main|MainActor\.run|Task\s*\{' Sources/AppState.swift Sources/*.swift

Repository: zachlatta/freeflow

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the hotkey callback path and thread/queue handoff.
rg -n -C6 '\bonAction\b|handle.*Shortcut|CGEventTap|NSEvent\.addGlobalMonitor|DispatchQueue\.global|DispatchQueue\.main|Task\s*\{' Sources/HotkeyManager.swift Sources/DictationShortcutSessionController.swift Sources/AppState.swift Sources/SetupTestHotkeyHarness.swift

printf '\n---- HOTKEY MANAGER ----\n'
sed -n '1,260p' Sources/HotkeyManager.swift

printf '\n---- SHORTCUT SESSION CONTROLLER ----\n'
sed -n '1,260p' Sources/DictationShortcutSessionController.swift

Repository: zachlatta/freeflow

Length of output: 29897


Move the first-read retry off the main queue collectSelectionSnapshot() is reached from AppState’s main-queue shortcut start path, so the 100 ms usleep blocks the UI on the first dictation into each app. Replace it with an async retry instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppContextService.swift` around lines 103 - 109, The first-read retry
in collectSelectionSnapshot() is blocking the main queue because it uses a
synchronous sleep during the initial selectedText fallback. Replace that
firstActivation retry with an async delayed re-read instead of usleep, keeping
the logic in AppContextService.collectSelectionSnapshot() and the
rawSelectedText(from:) fallback but moving the wait off the UI path so the
main-queue shortcut start flow stays responsive.

Comment thread Sources/MenuBarView.swift
Comment on lines +177 to +225
private var dictateButton: some View {
Button {
let shouldStop = appState.isRecording
dismissMenuBarPanel()
if shouldStop {
appState.toggleRecording()
} else {
// Hand focus back to the previous app before recording so the
// transcript pastes where the user was typing, not into
// FreeFlow's own panel.
NSApp.hide(nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
appState.toggleRecording()
}
}
.disabled(appState.isTranscribing)

if let hotkeyError = appState.hotkeyMonitoringErrorMessage {
Divider()
Text(hotkeyError)
.foregroundStyle(.red)
.font(.caption)
.padding(.horizontal, 16)
.lineLimit(3)
} label: {
HStack(spacing: 8) {
Image(systemName: appState.isRecording ? "stop.fill" : "mic.fill")
.font(.system(size: 12, weight: .semibold))
Text(appState.isRecording ? "Stop Recording" : "Start Dictating")
.font(.system(size: 13, weight: .semibold))
Spacer()
if let hint = dictateHint {
Text(hint)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.75))
}
}
.foregroundStyle(.white)
.padding(.horizontal, 14)
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(
LinearGradient(
colors: appState.isRecording
? [Color.red, Color.red.opacity(0.8)]
: [Color.accentColor, Color.accentColor.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
)
)
}
.buttonStyle(.plain)
.disabled(appState.isTranscribing)
.opacity(appState.isTranscribing ? 0.5 : 1)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

printf '\n== File list ==\n'
git ls-files | rg 'Sources/(MenuBarView|AppContextService)\.swift$'

printf '\n== Outline MenuBarView.swift ==\n'
ast-grep outline Sources/MenuBarView.swift --view expanded || true

printf '\n== Outline AppContextService.swift ==\n'
ast-grep outline Sources/AppContextService.swift --view expanded || true

printf '\n== Relevant matches ==\n'
rg -n 'didActivateApplicationNotification|frontmostApplication|collectSelectionSnapshot|toggleRecording|hide\\(nil\\)|DispatchQueue\\.main\\.asyncAfter|NSWorkspace' Sources -S

Repository: zachlatta/freeflow

Length of output: 5315


🏁 Script executed:

set -euo pipefail

printf '\n== MenuBarView around dictateButton ==\n'
sed -n '160,210p' Sources/MenuBarView.swift | cat -n

printf '\n== AppContextService collectSelectionSnapshot ==\n'
sed -n '80,130p' Sources/AppContextService.swift | cat -n

printf '\n== AppState toggleRecording and recording start path ==\n'
sed -n '1778,1888p' Sources/AppState.swift | cat -n

printf '\n== AppState frontmost app checks around context capture ==\n'
sed -n '1990,2095p' Sources/AppState.swift | cat -n

printf '\n== AppState frontmost app near line 3026 ==\n'
sed -n '3008,3045p' Sources/AppState.swift | cat -n

Repository: zachlatta/freeflow

Length of output: 18251


🏁 Script executed:

set -euo pipefail

printf '\n== Activation helper in AppContextService.swift ==\n'
rg -n 'activateAccessibilityTreeIfNeeded|didActivateApplicationNotification|frontmostApplication|sleep|usleep|retry' Sources/AppContextService.swift -S

printf '\n== Activation helper body ==\n'
sed -n '120,230p' Sources/AppContextService.swift | cat -n

printf '\n== collectContext / stop-time snapshot flow ==\n'
sed -n '150,220p' Sources/AppState.swift | cat -n
sed -n '2040,2105p' Sources/AppState.swift | cat -n

Repository: zachlatta/freeflow

Length of output: 12846


🏁 Script executed:

set -euo pipefail

printf '\n== startRecording definition ==\n'
rg -n '^    private func startRecording|^    func startRecording|startRecording\\(' Sources/AppState.swift -n -S

printf '\n== startRecording body around definition ==\n'
sed -n '1740,1875p' Sources/AppState.swift | cat -n

printf '\n== context capture task entry points ==\n'
rg -n 'contextCaptureTask|collectContext\\(|collectSelectionSnapshot\\(|fallbackContextAtStop\\(' Sources/AppState.swift Sources/AppContextService.swift -S

Repository: zachlatta/freeflow

Length of output: 390


🏁 Script executed:

set -euo pipefail

printf '\n== startRecording locations ==\n'
rg -n 'startRecording\(' Sources/AppState.swift -S

printf '\n== startRecording nearby body ==\n'
sed -n '1860,2015p' Sources/AppState.swift | cat -n

printf '\n== context capture task references ==\n'
rg -n 'contextCaptureTask|pendingSelectionSnapshot|collectContext\(' Sources/AppState.swift -S

Repository: zachlatta/freeflow

Length of output: 10056


Replace the fixed 250ms sleep with an activation check. appState.toggleRecording() reads NSWorkspace.shared.frontmostApplication immediately via prepareRecordingStart()/collectSelectionSnapshot(), so if NSApp.hide(nil) has not fully handed focus back yet, Edit Mode can capture the wrong app. Wait for the next frontmost-app activation (or poll until it changes) before starting dictation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/MenuBarView.swift` around lines 177 - 225, The dictateButton action
is using a fixed DispatchQueue delay after NSApp.hide(nil), which can start
recording before focus actually returns to the previous app. Update the
start-dictation path in dictateButton/appState.toggleRecording flow to wait for
the next frontmost app activation (or poll until
NSWorkspace.shared.frontmostApplication changes) before calling
toggleRecording(), so prepareRecordingStart() and collectSelectionSnapshot() see
the correct app.

@marcbodea

Copy link
Copy Markdown
Collaborator

Thanks for offering to split this up. Reviewing commit-by-commit helps, but independent PRs would give us clean review, merge, and revert boundaries.

Could you reduce this PR to the timeout fix for #253, then open separate PRs for:

  1. Custom vocabulary correction mappings (Map misspellings → corrections, not just a single-term dictionary #125)
  2. Configurable feedback sounds (Allow custom sounds (or more options) for recording start, stop, and error feedback #107)
  3. Electron/Chromium Edit Mode accessibility fix (Edit Mode silently falls back to dictation in Electron (VSCode) & Chromium (Gmail) — overwrites selection instead of transforming #237)
  4. Cursor-aware spacing, capitalization, and punctuation (Spacing, capitalization, and punctuation are context-blind — no awareness of text surrounding the cursor #200)
  5. Prompt Mode (Feature Request: "Prompt Mode" — summarize/condense dictated text before pasting #196)
  6. API connection prewarming and latency instrumentation, based on the timeout fix
  7. Starting transcription before capture-session teardown
  8. Menu-bar panel redesign/live waveform
  9. Settings-sidebar redesign

CLAUDE.md should also be a separate documentation-only PR, or omitted from this series. Please move each README/changelog entry into the PR that introduces that behavior.

The main concern is the Add configurable feedback sounds, menu bar panel redesign, changelog commit: it currently combines sounds, the menu-bar overhaul, the settings-sidebar redesign, vocabulary documentation, changelog changes, and agent guardrails. Those should not share one review or merge boundary.

The existing review findings can then be addressed in their corresponding PRs. This should let us land the small bug fixes quickly while reviewing the larger UI and performance changes independently.

@swimmer-303

Copy link
Copy Markdown
Author

Thanks @marcbodea — that makes sense. I've split this into independent PRs, each with clean review/merge/revert boundaries, and moved each README/changelog entry into the PR that introduces its behavior. Closing this in favor of them:

Bug fix

Performance (based on the timeout fix)

Features

Edit Mode fix

UI

Docs

The combined "sounds + menu-bar + sidebar" commit that was the main concern is now three separate PRs (#270, #271, #272) with no shared review or merge boundary. Each PR builds independently on top of main. Existing review findings can be addressed in the corresponding PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment