fix(desktop): restore last confirmed capture area when reopening selector#1872
fix(desktop): restore last confirmed capture area when reopening selector#1872ManthanNimodiya wants to merge 10 commits into
Conversation
…nitialCrop to captureTarget bounds
|
@richiemcilroy, have a look when you get chance and lmk for any changes |
|
@greptileai please re-review |
|
@tembo please review |
done — posted a review on PR #1872. the fix is solid: i traced both flows against three points raised:
ship-able once #1 is confirmed safe or simplified and #2 is polished. |
There was a problem hiding this comment.
Review — restore last confirmed capture area
Nice fix, and the approach is sound. I traced both flows against Cropper.computeInitialBounds and createOptionsQuery, and the core behaviour is correct: computeInitialBounds() only reads initialCrop imperatively (inside init() on mount and in reset() — Cropper.tsx:478-512, 597-609), so making effectiveInitialBounds / the initialCrop thunk reactive can't trigger spurious crop resets. 👍
A few things worth a look before merge.
1. capture-area.tsx — the frozen screenId can latch onto the "0" default (medium)
const [screenId, setScreenId] = createSignal<string | null>(untrack(activeScreenId));
createEffect(() => {
const id = activeScreenId();
if (id && !untrack(screenId)) setScreenId(id);
});The whole window keys off screenId() (lookup, hint, handleConfirm's saved screen). It's frozen on the first non-null activeScreenId(). The catch is what's synchronously available:
createOptionsQueryseeds_state.captureTargetto the hardcoded default{ variant: "display", id: "0" }(queries.ts:166).makePersisted(localStorage)(queries.ts:225) hydrates synchronously over that if therecording-options-query-2key exists.- The real value from
recordingSettingsStore(TauriStore.load, fully async —store.ts:13-19) only arrives later.
So untrack(activeScreenId) is always non-null at signal-creation time (the default is "display"/"0"). In the happy path localStorage has already sync-hydrated the real area target, so it freezes on the correct id and everything works. But if localStorage is empty/migrated/cleared while the Tauri store still has the real target (first open after the -2 key rename, a wiped webview cache, etc.), it freezes on "0" and the async update can never correct it — the createEffect is dead in that path because screenId() is already truthy. Result: blank cropper and handleConfirm persists screen: "0", which won't match the real display.
Worth confirming this edge case, or reconsidering whether the freeze is needed at all — if the concern is purely the async Tauri load overwriting mid-session, note that initialCrop is read once at mount (before that load lands), so a plain reactive activeScreenId() would give the same value at mount and stay consistent with what's displayed. If you keep the freeze, initializing to null won't help (the sync default is still non-null), so the comment's "frozen on first non-null from localStorage" framing doesn't quite hold — it freezes on whatever is sync-present, default included.
2. hasStoredSelection doesn't mirror the initialCrop area fallback (low — Greptile already flagged)
initialCrop now falls back to rawOptions.captureTarget (area variant) when there's no lastSelectedBounds entry, but hasStoredSelection (capture-area.tsx:100-106) still only checks lastSelectedBounds. Since crop() starts at CROP_ZERO until the Cropper fires its first onCropChange, showSelectionHint can briefly flash "draw a selection" on reopen when the area was confirmed via target-select-overlay.tsx (which never writes lastSelectedBounds). Greptile's suggested fallback fixes it. Cosmetic / sub-frame, but cheap to close.
3. target-select-overlay.tsx — looks correct
effectiveInitialBounds gating on target.screen === params.displayId keeps the restore scoped to the right display (blank on other displays), and wiring it into both shouldShowSelectionHint and initialCrop is consistent. No concerns here.
Overall: ship-able once #1 is either confirmed safe or simplified, and #2 is a one-line polish. The added comment in capture-area.tsx:90-91 is the genuinely-non-obvious kind, so it's fine per the repo's comment policy.
…ion for area variant
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
| const hasStoredSelection = createMemo(() => { | ||
| const id = screenId(); | ||
| if (!id) return false; | ||
| if (rawOptions.captureTarget.variant === "area") return true; |
There was a problem hiding this comment.
Minor: hasStoredSelection returns true for any area captureTarget, which can suppress the "draw a selection" hint even when the stored bounds are effectively empty (e.g. 0x0 / 1x1).
Maybe key this off the area bounds being non-empty, similar to the showSelectionHint logic:
| if (rawOptions.captureTarget.variant === "area") return true; | |
| const hasStoredSelection = createMemo(() => { | |
| const id = screenId(); | |
| if (!id) return false; | |
| const target = rawOptions.captureTarget; | |
| if (target.variant === "area" && target.screen === id) { | |
| return target.bounds.size.width > 1 && target.bounds.size.height > 1; | |
| } | |
| return ( | |
| state.lastSelectedBounds?.some((entry) => entry.screenId === id) ?? false | |
| ); | |
| }); |
…s and wrong screen
| params.displayId && | ||
| target.screen === params.displayId | ||
| ) { | ||
| return { |
There was a problem hiding this comment.
If captureTarget.bounds can ever be a zero/placeholder crop, effectiveInitialBounds() becomes non-undefined and shouldShowSelectionHint won’t show. Might be worth treating <=1x1 as undefined here (matches the size check you added in capture-area.tsx).
| return { | |
| if ( | |
| target.variant === "area" && | |
| params.displayId && | |
| target.screen === params.displayId | |
| ) { | |
| const { width, height } = target.bounds.size; | |
| if (width <= 1 || height <= 1) return undefined; | |
| return { | |
| x: target.bounds.position.x, | |
| y: target.bounds.position.y, | |
| width, | |
| height, | |
| }; | |
| } |
…o keep selection hint visible
|
|
||
| const hasStoredSelection = createMemo(() => { | ||
| const id = screenId(); | ||
| if (!id) return false; | ||
| const target = rawOptions.captureTarget; | ||
| if (target.variant !== "display") return false; | ||
| if (target.variant === "area" && target.screen === id) { | ||
| return target.bounds.size.width > 1 && target.bounds.size.height > 1; | ||
| } | ||
| return ( | ||
| state.lastSelectedBounds?.some((entry) => entry.screenId === target.id) ?? | ||
| false | ||
| state.lastSelectedBounds?.some((entry) => entry.screenId === id) ?? false | ||
| ); |
There was a problem hiding this comment.
Minor: this early-return means we ignore lastSelectedBounds whenever captureTarget.variant === "area". If lastSelectedBounds has a value but captureTarget.bounds is still placeholder (or temporarily cleared), the hint can reappear. I'd check the stored bounds first, then fall back to captureTarget.
| const hasStoredSelection = createMemo(() => { | |
| const id = screenId(); | |
| if (!id) return false; | |
| const target = rawOptions.captureTarget; | |
| if (target.variant !== "display") return false; | |
| if (target.variant === "area" && target.screen === id) { | |
| return target.bounds.size.width > 1 && target.bounds.size.height > 1; | |
| } | |
| return ( | |
| state.lastSelectedBounds?.some((entry) => entry.screenId === target.id) ?? | |
| false | |
| state.lastSelectedBounds?.some((entry) => entry.screenId === id) ?? false | |
| ); | |
| const hasStoredSelection = createMemo(() => { | |
| const id = screenId(); | |
| if (!id) return false; | |
| if (state.lastSelectedBounds?.some((entry) => entry.screenId === id)) | |
| return true; | |
| const target = rawOptions.captureTarget; | |
| if (target.variant === "area" && target.screen === id) { | |
| return target.bounds.size.width > 1 && target.bounds.size.height > 1; | |
| } | |
| return false; | |
| }); |
…in hasStoredSelection
|
@tembo, @greptileai please re-review |
re-reviewed pr #1872. the earlier rounds are all resolved now — screenid is a plain memo (no reactive effect), initialcrop falls back to capturetarget bounds, effectiveinitialbounds guards ≤1×1 bounds, and hasstoredselection checks lastselectedbounds before the capturetarget fallback. one regression is still open: in capture-area.tsx, reset() clears the persisted lastselectedbounds but leaves rawoptions.capturetarget untouched. since this pr added the capturetarget fallback to both hasstoredselection and initialcrop, reset is now only half-effective — in-session the draw hint stays suppressed, and on reopen the just-cleared area reappears. before this pr reset fully cleared the selection, so it's a regression scoped to exactly what this pr touches. i posted the details + a suggested fix (also zero out the area bounds on capturetarget in reset()) as a pr comment, and offered to push the fix if they want it. |
|
Re-review — looks close. The earlier rounds are resolved; one regression in ✅ Verified resolved on
|
…from resurrecting cleared selection
|
@tembo please re-review |
done re-reviewing #1872 (at commit dc13f2b) — posted the review on the pr. everything from my last pass plus greptile's concern is now resolved:
two minor non-blocking notes (both pre-existing, not regressions): net: lgtm, shippable. |
| (m) => m.screenId === id, | ||
| )?.bounds; | ||
| if (stored) return stored; | ||
| const target = rawOptions.captureTarget; |
There was a problem hiding this comment.
This currently prefers lastSelectedBounds over captureTarget.bounds. If the user last confirmed an area via the overlay flow (which updates captureTarget but not lastSelectedBounds), this can restore a stale selection.
| const target = rawOptions.captureTarget; | |
| const id = screenId(); | |
| if (!id) return CROP_ZERO; | |
| const target = rawOptions.captureTarget; | |
| if (target.variant === "area" && target.screen === id) { | |
| const { width, height } = target.bounds.size; | |
| if (width > 1 && height > 1) { | |
| return { | |
| x: target.bounds.position.x, | |
| y: target.bounds.position.y, | |
| width, | |
| height, | |
| }; | |
| } | |
| } | |
| return ( | |
| state.lastSelectedBounds?.find((m) => m.screenId === id)?.bounds ?? | |
| CROP_ZERO | |
| ); |
…initialCrop for freshest selection
|
@tembo please re-review |
done re-reviewing #1872 at the latest commit (9ed93e9) — posted the review on the pr. the only change since my last pass was one commit reordering
everything from earlier rounds still holds (plain net: lgtm, shippable. left two pre-existing non-blocking notes (cosmetic animate-back on reset; a reset-without-confirm persists a 0×0 area target worth a sanity-check at the recording entry point) — neither introduced by this commit. |
There was a problem hiding this comment.
Re-review — restore last confirmed capture area
Re-reviewed at dc13f2b9. Traced both flows again. The three items from my last pass and Greptile's reset() concern are all resolved. 👍
Resolved
- #1 frozen
screenIdlatching onto the"0"default — gone.screenIdis now a plain reactive alias of theactiveScreenIdmemo (capture-area.tsx:75-82), so there's no freeze and no path where the async Tauri store load can't correct the id. ✅ - #2
hasStoredSelectionnot mirroring theinitialCroparea fallback — fixed (capture-area.tsx:84-96). It now falls back to thecaptureTargetarea variant (guarded onwidth/height > 1), so the "draw a selection" hint no longer flashes on reopen after the area was confirmed via the overlay (which never writeslastSelectedBounds). ✅ - Greptile —
reset()leftcaptureTargetintact, resurrecting old bounds on next open — fixed (capture-area.tsx:171-184).reset()now zeros thecaptureTargetarea bounds, and the read sites agree on the convention:hasStoredSelectionrequires> 1, whileinitialCrop/effectiveInitialBoundstreat<= 1as no selection. So the cleared state now persists across a reopen. ✅ - Overlay (
target-select-overlay.tsx) still scopes the restore correctly totarget.screen === params.displayId, wired into bothshouldShowSelectionHintandinitialCrop. ✅
Minor / non-blocking
reset()callscropperRef?.reset()before clearinglastSelectedBounds/captureTarget, so the cropper's imperativecomputeInitialBounds()reads the still-present bounds and animates back to the old selection (the box stays visible, and the hint stays hidden becausecrop()is non-zero). This is pre-existing — the base did the same for the display variant and the PR just extends it to area, so it's not a regression. If you want Reset to visually clear the box, movecropperRef?.reset()to after the state clears.- After a reset-without-confirm, the persisted
captureTargetis now anareawith0×0bounds. The reopen guards handle this, but worth a quick sanity check that the main recording entry point won't attempt a 0×0 area capture if the user starts recording without re-selecting. Optional.
Net: LGTM — shippable. The two notes above are polish, not blockers.
There was a problem hiding this comment.
Re-review at 9ed93e9 — lgtm, shippable.
since my last pass (dc13f2b), the only change is one commit reordering initialCrop precedence in capture-area.tsx to prefer captureTarget area bounds over lastSelectedBounds. i traced it through and it's correct:
Consistency holds across all read sites. with id truthy, let L = a lastSelectedBounds entry exists for id, and C = captureTarget is an area for id with width/height > 1:
| state | hasStoredSelection |
initialCrop |
|---|---|---|
L && C |
true | captureTarget (freshest) |
L && !C |
true | lastSelectedBounds |
!L && C |
true | captureTarget |
!L && !C |
false | CROP_ZERO |
so hasStoredSelection() === true ⟺ initialCrop() is non-zero in every case — no flash-of-hint or blank-cropper mismatch.
The reorder is a real improvement, not just a shuffle. the overlay flow (target-select-overlay.tsx) writes captureTarget on confirm but never touches the capture-area window's persisted lastSelectedBounds. so after selecting an area via the overlay, captureTarget is fresher than lastSelectedBounds; preferring it shows the just-confirmed area instead of a stale one. within capture-area.tsx itself the two stay in sync (handleConfirm writes both, reset() clears both), so preferring captureTarget is always correct-or-equal.
everything resolved in prior rounds still holds: screenId is a plain memo (no latching effect), reset() zeroes the area bounds so the cleared state survives reopen, and the ≤1×1 guards are consistent everywhere.
two pre-existing minor notes carry over (both non-blocking, neither introduced or worsened by this commit):
reset()callscropperRef.reset()before clearing state, so the box briefly animates back to the old selection.- a reset-without-confirm persists a
0×0areacaptureTarget — worth a one-time sanity-check that the recording entry point treats a zero-size area as "no area" rather than capturing nothing.
CI is green (typecheck / biome lint / format all pass). net: ship it.
…Target to display on reset
|
@tembo please re-review |
Problem
When a user had previously selected a capture area and reopened the area selector, it always started blank, forcing them to redraw their selection every time.
This was reported by multiple users in the community.
The root cause was in
target-select-overlay.tsx:initialAreaBounds(which seeds the Cropper's starting position) was always initialized toundefined, even when an existing area target was already set.Solution
Added an
effectiveInitialBoundsmemo that falls back to the currentcaptureTargetarea bounds when no explicitinitialAreaBoundsis set.Both
initialCropandshouldShowSelectionHintnow use this memo, so:Also fixed
capture-area.tsx(the alternate area selection flow) with the same pattern,activeScreenIdnow handles both"display"and"area"capture target variants.Test Plan
Greptile Summary
This PR restores previously confirmed capture-area bounds when the area selector is reopened, fixing a UX regression where users had to redraw their selection every time. Both the standalone
capture-area.tsxwindow and thetarget-select-overlay.tsxoverlay are updated to fall back tooptions.captureTarget.boundswhen no in-session bounds are stored.target-select-overlay.tsx: neweffectiveInitialBoundsmemo readsoptions.captureTargetwheninitialAreaBoundsis unset, wired to bothinitialCropandshouldShowSelectionHint.capture-area.tsx: newactiveScreenIdmemo handles both"display"and"area"target variants;initialCropandhasStoredSelectiongain the samecaptureTargetfallback.Confidence Score: 4/5
Safe to merge for the overlay flow; the standalone capture-area window has an incomplete reset that lets old bounds reappear after the user explicitly clears the selection.
The reset() function in capture-area.tsx clears lastSelectedBounds but leaves rawOptions.captureTarget untouched. Because hasStoredSelection and initialCrop now fall back to captureTarget, the reset is visually effective in the current session but the cleared state does not persist — the old area bounds reappear the next time the selector window opens.
apps/desktop/src/routes/capture-area.tsx — specifically the reset() function and how it interacts with the new captureTarget fallback logic.
Important Files Changed
activeScreenIdmemo to support both "display" and "area" capture target variants, and falls back tocaptureTarget.boundswhenlastSelectedBoundsis empty — butreset()only clearslastSelectedBounds, leavingcaptureTargetintact so the fallback still supplies the old bounds on next open.effectiveInitialBoundsmemo that seeds the Cropper fromoptions.captureTargetwhen no explicitinitialAreaBoundsis set; wires it toinitialCropandshouldShowSelectionHint. Logic is correct and consistent with the existinginitialAreaBoundssignal flow.Reviews (3): Last reviewed commit: "fix(desktop): check lastSelectedBounds b..." | Re-trigger Greptile