Skip to content

Show a subtle "still recording" sheen every 30s during long takes#215

Open
ojhurst wants to merge 1 commit into
zachlatta:mainfrom
ojhurst:feat/recording-reminder-sheen
Open

Show a subtle "still recording" sheen every 30s during long takes#215
ojhurst wants to merge 1 commit into
zachlatta:mainfrom
ojhurst:feat/recording-reminder-sheen

Conversation

@ojhurst

@ojhurst ojhurst commented May 27, 2026

Copy link
Copy Markdown
Contributor

The mic waveform is small. When a recording is left running by accident — forgotten toggle, started by mistake — there isn't much in the menu bar to catch the eye. This adds a periodic sheen sweep across the recording overlay every 30 seconds: a quiet "yep, still here, still recording" that's easy to ignore when the recording is intentional and hard to miss when it isn't.

The sheen widens the overlay slightly, sweeps a soft highlight across left and right wings with a small stagger, then returns. No sound, no modal, no notification. The cadence resets when recording is toggled on, so the first sheen lands at the 30-second mark and repeats from there.

recording overlay sheen sweep every 30 seconds

Implementation

  • AppState: per-recording timer, reset on toggle start/stop
  • RecordingOverlay: sheen sweep animation, staggered left/right wings, expansion and collapse around the recording overlay's shape

Notes from the design pass

A few things considered along the way:

  • Cadence. Shorter intervals (15 seconds) felt like a notification rather than an acknowledgement. 30 seconds reads as periodic reassurance, not a prompt.
  • Sheen-only. Earlier iterations tried widening the overlay or pulsing it on its own. The sheen alone, with a subtle width expansion during the sweep, was the lightest touch that still caught the eye.
  • Brightness. The peak opacity was tuned between "flashing alert" and "easy to miss." The committed value sits between — visible against the dark overlay without reading as urgent.
  • Pill variants and non-notched Macs. The sheen geometry is anchored to the recording overlay's own bounds, not absolute pixel positions, so it scales naturally to wider pill layouts and to Macs without a notch — no per-layout code path needed.
  • First-sheen timing. The 30-second timer resets on every recording toggle, so the first sheen always lands 30 seconds after start — never an immediate sheen on a fresh recording.
  • No sound, no modal. The reminder is deliberately ignorable when intentional. If the recording is on purpose, the sheen never escalates; the goal is to draw the eye, not interrupt the work.

Defaults

On by default. The sheen is gentle enough to leave running, and the 30-second cadence was tuned to feel like a periodic acknowledgement rather than a notification.

Summary by CodeRabbit

Release Notes

  • New Features
    • Enhanced recording session duration tracking and metrics
    • Toggle recording mode now includes visual reminders and notifications
    • Improved error and silence warning message display in the recording interface
    • Audio feedback on recording start
    • Visual enhancements to the recording overlay design

Review Change Stack

The mic waveform is small. When a recording is left running by accident
— forgotten toggle, started by mistake — there isn't much in the menu
bar to catch the eye. This adds a periodic sheen sweep across the
recording overlay every 30 seconds: a quiet "yep, still here, still
recording" that's easy to ignore when the recording is intentional and
hard to miss when it isn't.

The sheen widens the overlay slightly, sweeps a soft highlight across
left and right wings with a small stagger, then returns. No sound, no
modal, no notification. The cadence resets when recording is toggled
on, so the first sheen lands at the 30-second mark and repeats from
there.

Implementation:
- AppState: per-recording timer, reset on toggle start/stop
- RecordingOverlay: sheen sweep animation, staggered left/right wings,
  expansion and collapse around the recording overlay's shape

Notes from the design pass:
- Cadence: shorter intervals felt like a notification; 30 seconds reads
  as periodic reassurance.
- Sheen-only: earlier passes tried widening or pulsing the overlay on
  its own; the sweep alone was the lightest touch that still caught the
  eye.
- Brightness: the peak opacity was tuned between "flashing alert" and
  "easy to miss" — the committed value sits between.
- Pill variants and non-notched Macs: the sheen geometry is anchored to
  the recording overlay's own bounds, not absolute pixel positions, so
  it scales naturally to wider pill layouts and to Macs without a notch
  — no per-layout code path needed.
- First-sheen timing: the 30s timer resets on every recording toggle,
  so the first sheen always lands 30 seconds after start, never
  immediately.

On by default; the sheen is gentle enough to leave running.
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR implements a visual reminder system for toggle-mode recording sessions. AppState now tracks per-session dictation duration via recordingSessionStartTime, and AppState manages a DispatchSourceTimer that periodically pulses an overlay reminder. RecordingOverlay extends the UI to show error/silence warnings, render an animated sheen effect on the stop button, and adapt layout width to accommodate the expanded reminder pill.

Changes

Toggle Recording Reminder Feature

Layer / File(s) Summary
State Model and Infrastructure
Sources/AppState.swift, Sources/RecordingOverlay.swift
RecordingOverlayState adds published properties for error message, silence warning message, and sheen animation flag; RecordingOverlayManager adds reminder expansion state with timing constants; AppState adds toggleRecordingReminderTimer field and interval constant.
Session Duration Tracking
Sources/AppState.swift
isRecording didSet captures start time on recording begin, computes elapsed seconds on stop, increments wpmTrackedSeconds, persists via WordCountTracker.saveWPMTrackedSeconds, and triggers stopToggleRecordingReminder().
Reminder Control and User Notification APIs
Sources/RecordingOverlay.swift
pulseToggleRecordingReminder() expands the reminder, enables sheen, and schedules auto-collapse; clearToggleRecordingReminder() resets state; showError() and showSilenceWarning() provide message display with auto-dismiss behavior.
Reminder Timer Lifecycle Management
Sources/AppState.swift
startToggleRecordingReminderIfNeeded() creates a DispatchSourceTimer that periodically calls pulseToggleRecordingReminder() while recording in toggle mode; stopToggleRecordingReminder() cancels the timer and clears the reminder.
Audio Startup Sequence Integration
Sources/AppState.swift
First real audio path now starts the toggle reminder, plays start sound, then applies audio interruption mute (replacing the prior inline Tink alert).
Phase Transitions and Mode Reset Logic
Sources/RecordingOverlay.swift
Phase transitions (showInitializing, showRecording, transitionToRecording, setTranscribingPhase) reset the toggle reminder; trigger mode switch to .toggle starts the reminder; new isShowingToggleReminder helper and resetToggleRecordingReminder() manage expansion/sheen state.
Layout Sizing for Reminder Expansion
Sources/RecordingOverlay.swift
New visibleWingWidth computed property expands wing widths when reminder is active; overlayFrame and overlayWidth calculations account for reminder expansion delta; panel animation duration increased to 0.42.
Sheen Animation Rendering
Sources/RecordingOverlay.swift
Winged and compact overlay paths conditionally render ToggleReminderSheenView when stop button is shown and sheen flag is enabled; sheen view draws animated gradient with geometry-based horizontal travel and optional start delay.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • zachlatta/freeflow#135: Both PRs modify isRecording state handling in AppState, and the session duration tracking logic may interact with or depend on the flag-file side effects from that PR.
  • zachlatta/freeflow#89: Both PRs modify phase-transition flows in RecordingOverlayManager, particularly the transcribing/done/panel transitions that now reset the toggle reminder.

Poem

A toggle so proud, now wears sheen so bright,
Reminding each keystroke with pulsing delight,
The timer ticks softly, the wings spread wide,
Duration tracked true, with nothing to hide. 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 accurately describes the main feature added: a subtle sheen reminder that appears every 30 seconds during recording sessions to indicate ongoing recording activity.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@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: 3

🤖 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/AppState.swift`:
- Around line 530-545: Add stored properties to AppState for
recordingSessionStartTime and wpmTrackedSeconds so the isRecording.didSet block
compiles and updates state correctly: declare recordingSessionStartTime as an
optional Date (default nil) and wpmTrackedSeconds as a TimeInterval/Double
(default 0) as members of AppState, ensure their access level matches existing
properties, and keep using
WordCountTracker.saveWPMTrackedSeconds(wpmTrackedSeconds) where present to
persist the value.

In `@Sources/RecordingOverlay.swift`:
- Around line 246-271: showError races with showSilenceWarning because each
schedules a delayed dismiss that only checks its own message key and phase; fix
by clearing the opposite feedback message immediately when presenting (in
showError clear overlayState.silenceWarningMessage; in showSilenceWarning clear
overlayState.errorMessage) and update the delayed closures in both showError and
showSilenceWarning to verify both the phase is .feedback and that both
overlayState.errorMessage and overlayState.silenceWarningMessage still match the
truncated/shown values before dismissing so an earlier callback cannot stomp a
newer feedback state (adjust references to overlayState, errorMessage,
silenceWarningMessage, showError, showSilenceWarning, and their dismiss
closures).
- Around line 12-14: The view is not reading the `@Published` state so setting
errorMessage and silenceWarningMessage has no visible effect; update
RecordingOverlay's SwiftUI body to consume those properties (errorMessage and
silenceWarningMessage) and conditionally render inline Text or a small
Banner/Overlay when they are non-nil, using the existing truncation/timer logic
that sets them; ensure state updates happen on the main thread
(DispatchQueue.main.async) when you set the published vars and consider using
.onChange(of: errorMessage) / .onChange(of: silenceWarningMessage) or .animation
to drive show/hide transitions, and remove/reuse isToggleReminderSheening only
if it controls visual state so the new message UI reflects the current boolean.
🪄 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: e4513937-f034-451e-99d6-ae05c87543be

📥 Commits

Reviewing files that changed from the base of the PR and between 1eb452c and 76d484b.

📒 Files selected for processing (2)
  • Sources/AppState.swift
  • Sources/RecordingOverlay.swift

Comment thread Sources/AppState.swift
Comment on lines +530 to +545
// Build 119: capture per-session dictation duration so the WPM
// stat in MainWindow's right rail has real numerator/denominator
// material. Counts only the recording window; transcription and
// post-processing happen after isRecording flips back to false.
if isRecording {
recordingSessionStartTime = Date()
} else {
stopToggleRecordingReminder()
if let start = recordingSessionStartTime {
let elapsed = Date().timeIntervalSince(start)
if elapsed > 0 {
wpmTrackedSeconds += elapsed
WordCountTracker.saveWPMTrackedSeconds(wpmTrackedSeconds)
}
recordingSessionStartTime = nil
}

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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify symbol declarations and usages for new WPM/session tracking references.
rg -n --type=swift '\b(recordingSessionStartTime|wpmTrackedSeconds)\b'
rg -n --type=swift '\b(var|let)\s+recordingSessionStartTime\b|\b(var|let)\s+wpmTrackedSeconds\b'

Repository: zachlatta/freeflow

Length of output: 494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --type=swift '\brecordingSessionStartTime\b' Sources/AppState.swift
rg -n --type=swift '\bwpmTrackedSeconds\b' Sources/AppState.swift

# Try to find likely stored-property declarations (var/let) and computed properties
rg -n --type=swift 'recordingSessionStartTime\s*[:=]' Sources/AppState.swift || true
rg -n --type=swift 'wpmTrackedSeconds\s*[:=]' Sources/AppState.swift || true

# Wider search across repo to see if declarations live elsewhere
rg -n --type=swift '\brecordingSessionStartTime\b' .
rg -n --type=swift '\bwpmTrackedSeconds\b' .

# If found in AppState.swift, show some surrounding context for the first declaration hit
python3 - <<'PY'
import subprocess, re, sys
def get_first_line(pattern):
    p = subprocess.run(["bash","-lc",f"rg -n --type=swift '{pattern}' Sources/AppState.swift | head -n 1"],capture_output=True,text=True)
    return p.stdout.strip()
for sym, pat in [("recordingSessionStartTime", r"recordingSessionStartTime\s*[:=]"),("wpmTrackedSeconds", r"wpmTrackedSeconds\s*[:=]")]:
    line = get_first_line(pat)
    if not line:
        print(f"No likely declaration match in AppState.swift for {sym}")
        continue
    m = re.match(r"Sources/AppState\.swift:(\d+):", line)
    ln = int(m.group(1))
    # print 20 lines around
    subprocess.run(["bash","-lc",f"nl -ba Sources/AppState.swift | sed -n '{max(1,ln-20)},{ln+20}p'"])
PY

Repository: zachlatta/freeflow

Length of output: 1083


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== recordingSessionStartTime occurrences =="
rg -n --type=swift '\brecordingSessionStartTime\b' . || true

echo
echo "== wpmTrackedSeconds occurrences =="
rg -n --type=swift '\bwpmTrackedSeconds\b' . || true

echo
echo "== Potential declarations (var/let/typed) in repo =="
rg -n --type=swift '(\bvar\b|\blet\b)\s+recordingSessionStartTime\b' . || true
rg -n --type=swift '(\bvar\b|\blet\b)\s+wpmTrackedSeconds\b' . || true

echo
echo "== typed/explicit definitions =="
rg -n --type=swift 'recordingSessionStartTime\s*:' . || true
rg -n --type=swift 'wpmTrackedSeconds\s*:' . || true

echo
echo "== Count occurrences in AppState.swift =="
rg -n --type=swift '\brecordingSessionStartTime\b' Sources/AppState.swift | wc -l
rg -n --type=swift '\bwpmTrackedSeconds\b' Sources/AppState.swift | wc -l

Repository: zachlatta/freeflow

Length of output: 720


🏁 Script executed:

#!/bin/bash
nl -ba Sources/AppState.swift | sed -n '480,575p'

Repository: zachlatta/freeflow

Length of output: 103


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n Sources/AppState.swift | sed -n '480,575p'

Repository: zachlatta/freeflow

Length of output: 4508


Critical: recordingSessionStartTime / wpmTrackedSeconds have no declarations in AppState scope
Sources/AppState.swift uses recordingSessionStartTime and wpmTrackedSeconds inside isRecording.didSet, but repo-wide there are no var/let (or typed) declarations for either symbol anywhere—so this code should not compile. Add these as stored properties of AppState (or otherwise make them accessible in this scope) before using them.

🤖 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/AppState.swift` around lines 530 - 545, Add stored properties to
AppState for recordingSessionStartTime and wpmTrackedSeconds so the
isRecording.didSet block compiles and updates state correctly: declare
recordingSessionStartTime as an optional Date (default nil) and
wpmTrackedSeconds as a TimeInterval/Double (default 0) as members of AppState,
ensure their access level matches existing properties, and keep using
WordCountTracker.saveWPMTrackedSeconds(wpmTrackedSeconds) where present to
persist the value.

Comment on lines +12 to +14
@Published var errorMessage: String?
@Published var silenceWarningMessage: String?
@Published var isToggleReminderSheening = false

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Render path does not consume new error/silence message state.

errorMessage and silenceWarningMessage are set, truncated, and timed, but feedback UI still renders only generic failure icons. The inline message APIs currently have no visible effect.

Also applies to: 246-257, 279-285

🤖 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/RecordingOverlay.swift` around lines 12 - 14, The view is not reading
the `@Published` state so setting errorMessage and silenceWarningMessage has no
visible effect; update RecordingOverlay's SwiftUI body to consume those
properties (errorMessage and silenceWarningMessage) and conditionally render
inline Text or a small Banner/Overlay when they are non-nil, using the existing
truncation/timer logic that sets them; ensure state updates happen on the main
thread (DispatchQueue.main.async) when you set the published vars and consider
using .onChange(of: errorMessage) / .onChange(of: silenceWarningMessage) or
.animation to drive show/hide transitions, and remove/reuse
isToggleReminderSheening only if it controls visual state so the new message UI
reflects the current boolean.

Comment on lines +246 to +271
func showError(_ message: String, dismissAfter: TimeInterval = 6.0) {
let truncated: String = {
if message.count <= Self.maxToastMessageLength { return message }
let cutoff = message.index(message.startIndex, offsetBy: Self.maxToastMessageLength - 1)
return String(message[..<cutoff]) + "…"
}()
DispatchQueue.main.async {
self.resetToggleRecordingReminder()
self.overlayState.errorMessage = truncated
self.lockedOverlayWidth = nil
self.overlayState.phase = .feedback
self.showOverlayPanel(animatedResize: true)
// Default 6s — 3s was too brief for rotation toasts to be read
// before they auto-dismissed. Callers that surface lower-stakes
// info (e.g. "Audio too short") may pass a shorter duration.
DispatchQueue.main.asyncAfter(deadline: .now() + dismissAfter) { [weak self] in
guard let self = self else { return }
guard self.overlayState.phase == .feedback,
self.overlayState.errorMessage == truncated else {
return
}
self.overlayState.errorMessage = nil
self.dismissAll()
}
}
}

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the opposite feedback message before scheduling dismiss callbacks.

showError and showSilenceWarning can race each other because each delayed closure only checks its own message key and .feedback phase. If one is shown after the other, the earlier callback can still flip phase back to .recording and stomp the newer feedback state.

💡 Proposed fix
 func showError(_ message: String, dismissAfter: TimeInterval = 6.0) {
@@
     DispatchQueue.main.async {
         self.resetToggleRecordingReminder()
+        self.overlayState.silenceWarningMessage = nil
         self.overlayState.errorMessage = truncated
@@
 func showSilenceWarning(_ message: String) {
     DispatchQueue.main.async {
         self.resetToggleRecordingReminder()
+        self.overlayState.errorMessage = nil
         self.overlayState.silenceWarningMessage = message

Also applies to: 279-297

🤖 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/RecordingOverlay.swift` around lines 246 - 271, showError races with
showSilenceWarning because each schedules a delayed dismiss that only checks its
own message key and phase; fix by clearing the opposite feedback message
immediately when presenting (in showError clear
overlayState.silenceWarningMessage; in showSilenceWarning clear
overlayState.errorMessage) and update the delayed closures in both showError and
showSilenceWarning to verify both the phase is .feedback and that both
overlayState.errorMessage and overlayState.silenceWarningMessage still match the
truncated/shown values before dismissing so an earlier callback cannot stomp a
newer feedback state (adjust references to overlayState, errorMessage,
silenceWarningMessage, showError, showSilenceWarning, and their dismiss
closures).

@ojhurst

ojhurst commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

I have had this running in my daily driver now for a little while, and it's definitely helped me at least three different times. It's helped me catch the fact that I accidentally left it on.

@marcbodea

Copy link
Copy Markdown
Collaborator

i'm not against showing a still recording reminder but not sure the sheen matches the rest of the app

@ojhurst

ojhurst commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Have you tried the sheen? I feel like it's just a really subtle thing. Do you have any other mechanisms you would propose instead?

@marcbodea

Copy link
Copy Markdown
Collaborator

Hi, could you make sure the sheen is restricted to the shape of the actual overlay? In the GIF you added it seems to be restricted to a square
image

Could you also address the coderabbit comments?

Thanks

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants