Skip to content

feat(overlay): let users drag the bottom recording overlay - #974

Open
schneidermilanistvan-hash wants to merge 8 commits into
altic-dev:mainfrom
schneidermilanistvan-hash:feat/draggable-recording-overlay
Open

schneidermilanistvan-hash wants to merge 8 commits into
altic-dev:mainfrom
schneidermilanistvan-hash:feat/draggable-recording-overlay

Conversation

@schneidermilanistvan-hash

Copy link
Copy Markdown

Description

The bottom recording overlay is anchored to the centre of the screen edge, so it sits wherever the layout puts it — which is sometimes directly on top of the thing you are dictating into. Today the only controls are Overlay Position (top or bottom) and Bottom Offset (vertical only), so an overlay covering a text field, a toolbar or a send button cannot be moved aside.

This makes the overlay draggable and remembers where you put it.

  • BottomOverlayPanel gains allowsUserDragging, which bypasses AppKit's keep-on-screen clamping for user-driven moves. The panel already had this hook for offscreen parking; this reuses it so the overlay can be pushed past a screen edge and fully out of the way.
  • isMovableByWindowBackground is enabled on the panel. Clicks on the pill's own controls (prompt picker, mode menu, actions menu) still reach them — only the inert background begins a drag.
  • The resting origin is persisted to OverlayCustomOrigin. didMove fires on every step of a drag, so the write is coalesced and only the final position is stored. An isApplyingProgrammaticFrame guard stops the app's own repositioning from being recorded as a user drag.
  • positionWindow() honours a stored origin instead of re-centring. Without this the dragged position was overwritten on every presentation and on every hover-driven resize.
  • Settings gains a Reset Position row, shown only once a custom position exists.

That last point is not cosmetic: once the overlay is dragged mostly off screen, the grab area is off screen too, so there is no way to drag it back. Reset Position is the way out.

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Relates to #284 ("Free-Floating Overlay, Tiny Size, and Visibility on the Active Screen"). That issue asked for an overlay that "behaves like the bottom bar stack but is draggable (sensible default position, persisted position)" and is closed as completed — the pill and sizing landed, but the draggable/persisted part did not, which is what this PR adds. It deliberately does not touch the other two requests in that issue (tiny visualiser-only size, active-screen following).

Happy to move this to a Discussion in Ideas first if you would prefer that per CONTRIBUTING — I opened the PR because the change is small and already implemented, not to skip the process.

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.6.2 (Apple Silicon, M5 Max)
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml on the three changed files — 0 violations
  • Ran formatter locally: swiftformat --lint --config .swiftformat — no changes to any added line
  • Ran tests locally:

Verified by hand on a real build, measuring the panel's frame via CGWindowListCopyWindowInfo at each step:

Step Result
Default anchored at (586, 782)
Drag moves with the cursor; OverlayCustomOrigin written once, at the resting position
Stop and restart dictation reopens at the dragged position, not re-centred
Reset Position returns to (586, 782)

Also checked in an isolated harness reproducing the same panel setup (borderless non-activating NSPanel + NSHostingView + a SwiftUI Button): isMovableByWindowBackground does survive the hosting view, a drag past the left screen edge reaches a negative origin, didMove fires, and a click on the embedded button still registers without moving the window.

Two caveats I'd rather state than hide: I could not test on an Intel Mac, and I did not run the test suite locally — only the linter, the formatter and the manual verification above.

Screenshots / Video

  • No UI/visual changes; screenshots/video are not applicable.

Captured against a neutral backdrop so nothing unrelated is in frame.

1. Default — anchored bottom-centre

Default position

2. Dragged into the middle of the screen

Dragged mid-screen

3. Dragged up against the top edge

Dragged to top edge

4. Reopened after stopping and restarting dictation — still where it was dragged

Reopened at dragged position

Notes

  • Scope is the bottom overlay only. The notch overlay is a separate controller and is untouched.
  • Default behaviour is unchanged: with no stored OverlayCustomOrigin, positioning is exactly as before, so existing users see no difference until they drag.
  • The new Settings row is extracted into its own OverlayCustomPositionRow view rather than added inline, because inlining pushed SettingsView past the type_body_length limit of 2000 lines.
  • Bottom Offset still applies while no custom position is set; the new row explains that resetting restores it.
  • One pre-existing legacy_swiftui_aspect_ratio violation in BottomOverlayView.swift is present on main as well and is not touched here.

🤖 Generated with Claude Code

The bottom overlay is anchored to the centre of the screen edge, so it can
cover whatever is underneath it — a text field, a toolbar, a button — with no
way to move it aside. Overlay Position only offers top or bottom, and Bottom
Offset only shifts it vertically.

This makes the overlay draggable and remembers where it was put:

- BottomOverlayPanel gains `allowsUserDragging`, which bypasses AppKit's
  keep-on-screen clamping for user-driven moves, so the overlay can be pushed
  past a screen edge and out of the way.
- `isMovableByWindowBackground` is enabled on the panel. Clicks on the pill's
  own controls still reach them; only the inert background starts a drag.
- The resting origin is persisted to `OverlayCustomOrigin`. `didMove` fires on
  every step of a drag, so the write is coalesced and only the final position
  is stored. An `isApplyingProgrammaticFrame` guard keeps the app's own
  repositioning from being recorded as a user drag.
- `positionWindow()` honours a stored origin instead of re-centring, which
  otherwise overwrote the dragged position on every presentation.
- Settings grows a "Reset Position" row, shown only once a custom position
  exists. Without it, dragging the overlay fully off screen would be
  unrecoverable, since the grab area is then off screen too.

The row lives in its own view so SettingsView stays within the
type_body_length limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

Greptile Summary

The latest changes pair the in-memory dragged origin with the display arrangement on which it was selected, preventing an in-progress drag from being evaluated against stale persisted screen metadata.

  • Uses the live origin’s own screen frames when deciding whether the position remains usable.
  • Preserves deliberate off-screen placement when the display arrangement is unchanged.
  • Falls back to anchored placement when the arrangement changes and the overlay no longer intersects an attached display.
  • The three previous findings are resolved, and no new actionable issues were identified.
Confidence Score: 5/5

The PR appears safe to merge; the latest placement changes address the previous race and multi-display concerns without introducing a new actionable failure.

The current implementation synchronously cancels pending saves on external position changes, keeps each live origin paired with the arrangement sampled during its drag, and validates visibility against individual screens when that arrangement changes. All previous threads are resolved, and no blocking or non-blocking findings remain.

Reviews (8): Last reviewed commit: "fix(overlay): judge the live origin by t..." | Re-trigger Greptile

Comment thread Sources/Fluid/Views/BottomOverlayView.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 739fdbb571

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +509 to +513
SettingsStore.shared.overlayCustomOrigin = origin
}
}
self.pendingOriginSave = save
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: save)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the live drag origin before debouncing persistence

When transcription changes the dynamic preview size while the user is dragging, refreshDynamicPreviewSizeIfNeeded can run positionWindow() after 80 ms, while this work item does not update overlayCustomOrigin until the pointer has been still for 200 ms. Consequently positionWindow() reads the previous origin—or nil on the first drag—and snaps the panel back to its old/anchored position during the drag. Track the current drag origin in memory immediately and debounce only the UserDefaults write, or avoid programmatic repositioning while a drag is active.

Useful? React with 👍 / 👎.

Comment on lines +2186 to +2188
/// Origin the user dragged the recording overlay to, in screen coordinates.
/// `nil` means the overlay uses its default anchored placement.
var overlayCustomOrigin: CGPoint? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the custom origin in settings backup restoration

When a user exports settings after positioning the overlay and later imports that backup, SettingsBackupPayload, makeBackupPayload(), and restore(from:) omit this newly persisted preference even though they handle the adjacent overlay position, offset, and size settings. A fresh installation therefore loses the saved placement, while importing over an existing installation can retain an unrelated local origin—including an off-screen one—despite reporting that settings were restored. Add an optional backward-compatible backup field, or explicitly clear the local origin during restore if coordinates should not migrate.

Useful? React with 👍 / 👎.

The first commit stored the dragged origin as an absolute point and always
reused it. Drag the overlay onto an external display, unplug that display, and
the overlay reopens outside the visible desktop — where the grab area is also
off screen, so the only way back is knowing that Reset Position exists.

Clamping the stored origin onto the visible desktop would be the obvious fix,
but it would also undo a position the user deliberately dragged past a screen
edge to get the overlay out of the way, which is the point of the feature.

So the display arrangement is stored next to the origin:

- `OverlayCustomOriginDesktopFrame` records the union of all screen frames at
  the moment the position is saved.
- On the same arrangement the stored origin is honoured exactly, including a
  position dragged fully past an edge.
- Once the arrangement differs, the origin is only reused while the overlay
  would still intersect the visible desktop; otherwise positioning falls back
  to the anchored default.
- The stored origin is kept rather than cleared, so reconnecting the display
  restores the chosen position.

Also mentions dragging in the Bottom Offset description, since nothing else
signals that the overlay can be moved.

Verified against the decision table: same-display on-screen, same-display
dragged past the edge, external unplugged and stranded, external unplugged but
still visible, no stored arrangement (on and off screen), and external
reattached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schneidermilanistvan-hash

Copy link
Copy Markdown
Author

Pushed a follow-up commit (dce5665) fixing a gap in the first one, plus a discoverability nit.

The gap: the dragged origin was stored as an absolute point and always reused. Drag the overlay onto an external display, unplug it, and the overlay reopens outside the visible desktop — where the grab area is off screen too, so the only way back is already knowing that Reset Position exists.

Clamping the stored origin onto the visible desktop would be the obvious fix, but it would also undo a position deliberately dragged past a screen edge, which is the point of the feature. So the display arrangement is stored next to the origin:

  • OverlayCustomOriginDesktopFrame records the union of all screen frames when the position is saved.
  • Same arrangement → the origin is honoured exactly, including a position dragged fully past an edge.
  • Arrangement changed → the origin is reused only while the overlay would still intersect the visible desktop; otherwise it falls back to the anchored default.
  • The origin is kept rather than cleared, so reconnecting the display restores the chosen position.

Verified against this decision table:

Case Stored position reused?
Same display, on-screen position yes
Same display, deliberately dragged past the edge yes
External unplugged, position stranded off-desktop no — falls back to default
External unplugged, position still visible yes
No stored arrangement, on-screen yes
No stored arrangement, off-screen no
External reattached, position on the external display yes

Also added a mention of dragging to the Bottom Offset description, since nothing else signalled the overlay could be moved.

Still true from the original PR: builds clean, swiftlint --strict reports 0 violations on the changed files, swiftformat --lint touches none of the added lines, untested on Intel, and I have not run the test suite.

🤖 Generated with Claude Code

Comment thread Sources/Fluid/Views/BottomOverlayView.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dce5665ea0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +519 to +522
/// The whole desktop area, i.e. the union of every attached screen's frame.
private static func currentDesktopFrame() -> CGRect {
NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track individual screens instead of their bounding union

currentDesktopFrame() discards the actual screen topology, so the recovery check can still restore the overlay into an area where no display exists. For example, with staggered displays, unplugging a monitor that occupied an interior gap may leave the bounding union unchanged; framesMatch then returns true unconditionally, and even when it differs, desktop.intersects treats that gap as visible. Persist the individual screen frames and require intersection with at least one currently attached screen before restoring the origin.

Useful? React with 👍 / 👎.

Three problems the automated reviews on altic-dev#974 found, all real.

**The overlay snapped back mid-drag.** Only the write to settings was meant to
be coalesced, but the origin itself lived nowhere else, so anything that
repositioned the panel during a drag read the pre-drag origin — or nil, on the
first drag ever — and moved the overlay out from under the pointer. That is not
a corner case: the preview text growing during dictation calls
`refreshSizeForContent()` after 80 ms, well inside the 200 ms save window, and
dictation is exactly when the overlay is on screen to be dragged. The origin is
now held in memory the moment the pointer moves; only the write is debounced.

**Reset Position could undo itself.** A save queued by a drag survived the
reset that followed it and wrote the origin back afterwards. The reset now
cancels the pending save and clears the in-memory origin.

**A gap between displays counted as desktop.** The previous commit stored the
union of all screen frames, which loses the topology twice over: unplugging a
display that sat inside the bounding rectangle of the others leaves the union
unchanged, so the arrangement looked identical; and in an irregular
arrangement the union covers space no display draws, so an overlay stranded in
that gap was treated as reachable. Both left the overlay invisible with nothing
to grab. Each screen's frame is now stored separately
(`OverlayCustomOriginScreenFrames`), arrangements are compared as sets, and the
recovery check requires the overlay to overlap a screen that is actually
attached.

Also carries the custom origin and its arrangement through settings
backup/restore, which had been left out while the adjacent overlay position,
offset and size were included. Both fields are optional, so older backups still
decode, and the arrangement is restored before the origin — clearing the origin
also clears the arrangement, so the two cannot disagree.

Decision table, extended with the flagged cases and re-verified (12/12):
same display on-screen, same display dragged past the edge, external unplugged
and stranded, external unplugged but still visible, no stored arrangement (on
and off screen), external reattached, screens reported in a different order,
interior display unplugged with the union unchanged, irregular arrangement with
the position in a covered gap, irregular arrangement with the position on an
attached screen, and no screens attached. The last three fail under the union
logic this replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schneidermilanistvan-hash

Copy link
Copy Markdown
Author

Pushed 1731f83. All four review findings were real; here is what each turned into.

Snap-back mid-drag (Codex, BottomOverlayView.swift:514) — the one that actually bites. Only the write was meant to be coalesced, but the origin lived nowhere else, so any reposition during a drag read the pre-drag origin — or nil, on the first drag ever — and moved the overlay out from under the pointer. Not a corner case: preview text growing during dictation calls refreshSizeForContent() after 80 ms, well inside the 200 ms save window, and dictation is exactly when the overlay is on screen to be dragged. The origin is now held in memory the moment the pointer moves; only the write is debounced.

Reset can be undone (Greptile P2, same line) — a save queued by a drag survived the reset that followed it. Reset now cancels the pending save and clears the in-memory origin. Reaching it by hand means clicking Reset within 200 ms of the last pixel of movement, so this is insurance rather than a bug anyone would hit, but the guard costs three lines.

Display gaps appear reachable (Greptile P1) and track individual screens instead of their bounding union (Codex, :522) — the same root cause, and the union loses topology twice over. Unplugging a display that sat inside the bounding rectangle of the others leaves the union unchanged, so the arrangement looked identical and the stranded origin was honoured. And in an irregular arrangement the union covers space no display draws, so an overlay in that gap read as reachable. Each screen's frame is now stored separately (OverlayCustomOriginScreenFrames), arrangements are compared as sets so NSScreen.screens ordering does not matter, and once the arrangement differs the origin is reused only while the overlay overlaps a screen that is actually attached.

Settings backup (Codex, SettingsStore.swift:2188) — correct, it was inconsistent to persist this and leave it out while the adjacent overlay position, offset and size were included. Both the origin and the arrangement now travel in the backup as optional fields, so older backups still decode. The arrangement is restored first: clearing the origin also clears the arrangement, so the two cannot disagree, and coordinates restored onto a different machine are gated by the same reachability check rather than being trusted blindly.

Decision table re-verified against the new logic, 12/12:

Case Stored position reused?
Same display, on-screen yes
Same display, deliberately dragged past the edge yes
External unplugged, position stranded on it no — falls back to default
External unplugged, position still on the built-in yes
No stored arrangement, on-screen yes
No stored arrangement, off-screen no
External reattached, position on the external yes
Same screens, reported in a different order yes
Interior display unplugged, union unchanged, position in the gap no
Irregular arrangement, position in a gap the union covers no
Irregular arrangement, position on an attached screen yes
No screens attached at all no

The three bold-or-last rows are the ones the union logic got wrong.

Build succeeds, swiftlint --strict on the changed files reports only the pre-existing legacy_swiftui_aspect_ratio that is already on main, and swiftformat --lint flags none of the 101 added lines. Still true and still stated plainly: untested on Intel, and I have not run the test suite.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1731f83c17

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +660 to +661
if let customOrigin = self.liveCustomOrigin ?? SettingsStore.shared.overlayCustomOrigin,
Self.storedOriginIsUsable(customOrigin, size: window.frame.size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh the live origin when restoring settings

After the overlay has been dragged once in the current process, liveCustomOrigin remains non-nil and always wins over SettingsStore.shared.overlayCustomOrigin. Importing a backup with a different non-nil custom origin therefore updates UserDefaults but not the displayed position, and every subsequent presentation continues using the pre-import location until the user resets or restarts the app. Synchronize or clear the live cache when the origin-change notification represents an externally restored value.

Useful? React with 👍 / 👎.

Comment on lines +521 to +524
let save = DispatchWorkItem {
MainActor.assumeIsolated {
SettingsStore.shared.overlayCustomOriginScreenFrames = Self.currentScreenFrames()
SettingsStore.shared.overlayCustomOrigin = origin

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Capture screen frames before debouncing the save

Although individual screen frames are now persisted, they are sampled when the delayed work item executes rather than when origin is captured. If a display is disconnected or its configuration changes during this 200 ms window, the old origin is paired with the new arrangement; arrangementsMatch then accepts that arrangement unconditionally and can restore the overlay to the now-disconnected display instead of recovering it to the anchored position. Capture currentScreenFrames() alongside origin before scheduling the work item.

Useful? React with 👍 / 👎.

Two follow-ups from the second review pass.

**A restored backup did not move the overlay.** Once the overlay had been
dragged in the current process, the in-memory origin always outranked settings,
so importing a backup with a different position wrote UserDefaults and left the
overlay where it was — for the rest of the process. The origin-changed observer
now takes the stored value as the new truth rather than only clearing it on
reset. A drag in flight is unaffected: the notification is posted by the
coalesced write itself, which stores exactly what is already in memory.

**The arrangement was sampled 200 ms late.** Screen frames were read when the
debounced write ran, not when the drag happened, so a display unplugged inside
that window paired the old origin with the new arrangement. That reads as a
match, which is the one case that skips the reachability check — precisely the
stranding this is meant to prevent. Both are captured together now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schneidermilanistvan-hash

Copy link
Copy Markdown
Author

dd7f203 takes both of the second-pass findings; both were right.

Refresh the live origin when restoring settings — once the overlay had been dragged in the current process, the in-memory origin outranked settings for the rest of that process, so importing a backup wrote UserDefaults and left the overlay where it was. The origin-changed observer now takes the stored value as the new truth instead of only clearing it on reset. A drag in flight is unaffected, since the notification that reaches the observer is posted by the coalesced write itself, which stores exactly what is already in memory.

Capture screen frames before debouncing the save — worth calling out as the sharper of the two, because it lands on the one path that skips the reachability check. Frames were read when the write ran rather than when the drag happened, so a display unplugged inside the 200 ms window paired the old origin with the new arrangement, arrangementsMatch returned true, and the overlay was restored onto the display that had just gone away — the exact stranding the commit before it was written to prevent. Origin and arrangement are captured together now.

Builds clean, lint unchanged. Greptile came back 5/5 on 1731f83 with both of its findings resolved.

Unchanged and still worth a maintainer's eye rather than a bot's: this covers the bottom overlay only — the notch overlay is a separate controller and deliberately untouched — and I have not tested on Intel or run the test suite. Happy to move this to a Discussion per CONTRIBUTING if tying it to the closed #284 is not the route you want.

🤖 Generated with Claude Code

Comment thread Sources/Fluid/Views/BottomOverlayView.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd7f203750

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +83 to +84
NotificationCenter.default.addObserver(forName: NSNotification.Name("OverlayCustomOriginChanged"), object: nil, queue: .main) { [weak self] _ in
Task { @MainActor [weak self] in

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cancel pending drag saves synchronously

When Reset Position or a backup restore changes the origin during the 200 ms persistence window, this notification handler defers cancellation to an unstructured Task. The already-queued pendingOriginSave can therefore run first and write the dragged origin back, undoing the reset or restored value. The fresh evidence is that the new handler performs the intended cancellation only inside this asynchronously scheduled task; handle the main-queue notification synchronously, as the drag observer does, before returning to the run loop.

Useful? React with 👍 / 👎.

The origin-changed observer treated every notification as external, including
the one posted by its own coalesced write. Because the observer runs a turn
later, a drag that resumed in that gap had already installed a newer save,
which the observer then cancelled while rolling the in-memory origin back to
the value just stored — losing the end of the drag, or snapping the panel
backwards.

The controller now claims the single notification its own write posts and the
observer consumes one per write, so only a genuinely external change — Reset
Position, a restored backup — cancels a pending save. Nothing is inferred from
state the pointer may already have moved past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schneidermilanistvan-hash

Copy link
Copy Markdown
Author

3486515. Fair catch, and I had talked myself out of this one — I reasoned the window was a frame wide and self-healing, which is not a good enough reason to leave a save being cancelled by the save before it.

The hole was that the observer treated every notification as external, including the one its own coalesced write posts. Running a turn later, it could find a newer save already installed by a drag that had resumed, cancel it, and roll the in-memory origin back to the value just stored.

The controller now claims the single notification its own write posts, and the observer consumes one per write. Only a genuinely external change — Reset Position, a restored backup — cancels a pending save. Nothing is inferred from state the pointer may already have moved past, which was the actual defect in the previous shape rather than the width of the window.

🤖 Generated with Claude Code

The origin-changed handler deferred its work into an unstructured `Task`, so a
save scheduled for the tail of the 200 ms debounce window could run before the
cancellation did and write the dragged origin back over a Reset Position or a
restored backup — the failure the cancellation exists to prevent.

It now runs on the run loop turn that posted the notification, the way the drag
observer already does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schneidermilanistvan-hash

Copy link
Copy Markdown
Author

6eea1c3 — "cancel pending drag saves synchronously" was right, and I had missed it in the previous round; it is a separate defect from the one 3486515 fixed, not the same one restated.

The handler deferred its work into an unstructured Task, so a save scheduled for the tail of the 200 ms window could run before the cancellation and write the dragged origin back over a Reset Position or a restored backup — exactly what the cancellation is there to prevent. It now runs on the run loop turn that posted the notification, the way observeUserDrags already does.

The self-posted notification count stays. It is what makes "my own write" and "someone else's write" distinguishable rather than inferred from state, and it does not depend on the handler's timing.

Build and lint unchanged.

🤖 Generated with Claude Code

@schneidermilanistvan-hash

Copy link
Copy Markdown
Author

Correction to something I have now repeated in four comments on this PR: "I have not run the test suite" was a bad disclaimer. I have run it.

xcodebuild test with the CI workflow's own invocation, on 6eea1c3: 437 tests, 0 failures, 2.2 s. It takes eighteen seconds including the build. There was never a reason not to run it, and I should have done so before opening the PR rather than flagging the gap and moving on. Worth noting that four of those tests exercise SettingsStore.restore(from:), which this PR changes.

The remaining caveat is real and unchanged: still untested on Intel. The Build and Test workflow also appears not to have run on this branch, presumably behind first-time-contributor approval — happy for a maintainer to kick it off.

🤖 Generated with Claude Code

The previous commit told the controller's own coalesced write apart from an
external one with a counter of notifications it was about to post. That is the
wrong shape for the problem: an unbounded `Int` that assumes exactly one post
per write and one delivery per post, forever, whose drift would be silent and
whose symptom would be Reset Position quietly doing nothing.

`OverlayCustomOriginChanged` has exactly one poster and one observer, and it
exists solely to tell the controller a position was changed from somewhere
else. So the controller's own write should never post it. Drag persistence now
goes through `SettingsStore.storeDraggedOverlayOrigin(_:screenFrames:)`, which
stores the origin and the arrangement together and deliberately posts nothing —
the overlay is by definition already at that position. `objectWillChange` is
still sent, so the Reset Position row appears on the first drag.

The observer then needs no branch and no state: every notification it receives
is external, so it always cancels a pending save and takes the stored value.
Net one member and one code path fewer than before the race existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd03bee54d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +667 to +668
if let customOrigin = self.liveCustomOrigin ?? SettingsStore.shared.overlayCustomOrigin,
Self.storedOriginIsUsable(customOrigin, size: window.frame.size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate the live origin against its current arrangement

When the first continuous drag moves the overlay completely beyond every screen edge, customOrigin comes from liveCustomOrigin, but storedOriginIsUsable still validates it using the persisted screen frames, which are empty or stale until the 200 ms save executes. If transcription changes the preview size during that interval, positionWindow() rejects the live origin and snaps the panel back to its anchored position while the pointer is still dragging. The fresh evidence after the earlier live-origin fix is that this new validation path still reads the old arrangement from SettingsStore; retain the current arrangement alongside the live origin or bypass stored-origin recovery checks for an active live drag.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

f101c32. The fix is right; the reasoning attached to it needed one correction.

storedOriginIsUsable read SettingsStore.overlayCustomOriginScreenFrames no matter whose origin it was handed, which for a live origin is the arrangement before the one it was chosen on — or none at all on a first drag, since the matching write is still in the 200 ms debounce. The origin now travels with its arrangement, originIsUsable takes both and reads no global state, and the same-arrangement rule applies to a live origin from the moment it is set rather than 200 ms later.

Where I would push back is the failure it is hung on. A drag alone cannot reach it: the grab point stays under the pointer, the pointer is confined to display space, so the panel always intersects an attached screen and screens.contains { $0.intersects(overlay) } holds even against an empty stored arrangement. Reaching the snap-back needs the preview to shrink mid-drag far enough that what is left of the panel clears the edge — dictation ending while the panel is held mostly past the left edge. Narrow. The inconsistency underneath it was real on its own terms, though: a known-current position was being judged against a known-stale arrangement, and that is worth removing whether or not a user can walk into it.

Build clean, 437 tests, 0 failures.

`storedOriginIsUsable` read `SettingsStore.overlayCustomOriginScreenFrames`
regardless of which origin it was handed. For a live origin that is the wrong
arrangement: the matching write is still sitting in the 200 ms debounce, so
settings holds the previous arrangement, or none at all on the first drag.

The origin now travels with the arrangement it was chosen on. `originIsUsable`
takes both and reads no global state, so the same-arrangement rule — a position
dragged past a screen edge is honoured exactly — applies to a live origin from
the moment it is set, not 200 ms later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kirolos-esmat pushed a commit to kirolos-esmat/FluidVoice that referenced this pull request Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant