Skip to content

fix(desktop): restore last confirmed capture area when reopening selector#1872

Open
ManthanNimodiya wants to merge 10 commits into
CapSoftware:mainfrom
ManthanNimodiya:fix/capture-area-restore-last-selection
Open

fix(desktop): restore last confirmed capture area when reopening selector#1872
ManthanNimodiya wants to merge 10 commits into
CapSoftware:mainfrom
ManthanNimodiya:fix/capture-area-restore-last-selection

Conversation

@ManthanNimodiya

@ManthanNimodiya ManthanNimodiya commented May 26, 2026

Copy link
Copy Markdown
Contributor

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 to undefined, even when an existing area target was already set.

Solution

Added an effectiveInitialBounds memo that falls back to the current captureTarget area bounds when no explicit initialAreaBounds is set.
Both initialCrop and shouldShowSelectionHint now use this memo, so:

  • Opening the selector with a previously confirmed area pre-loads those bounds
  • The "draw a selection" hint is suppressed when bounds are available
  • Re-entering area mode within the same session also restores correctly

Also fixed capture-area.tsx (the alternate area selection flow) with the same pattern, activeScreenId now handles both "display" and "area" capture target variants.

Test Plan

  • Confirm a capture area → reopen selector → previous selection is pre-loaded ✓
  • Adjust the pre-loaded area → close → reopen → shows updated bounds ✓
  • Switch to display mode and back to area mode within same session → bounds restored ✓
  • First-time use (no previous area) → blank selector with draw hint ✓

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.tsx window and the target-select-overlay.tsx overlay are updated to fall back to options.captureTarget.bounds when no in-session bounds are stored.

  • target-select-overlay.tsx: new effectiveInitialBounds memo reads options.captureTarget when initialAreaBounds is unset, wired to both initialCrop and shouldShowSelectionHint.
  • capture-area.tsx: new activeScreenId memo handles both "display" and "area" target variants; initialCrop and hasStoredSelection gain the same captureTarget fallback.

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

Filename Overview
apps/desktop/src/routes/capture-area.tsx Adds activeScreenId memo to support both "display" and "area" capture target variants, and falls back to captureTarget.bounds when lastSelectedBounds is empty — but reset() only clears lastSelectedBounds, leaving captureTarget intact so the fallback still supplies the old bounds on next open.
apps/desktop/src/routes/target-select-overlay.tsx Adds effectiveInitialBounds memo that seeds the Cropper from options.captureTarget when no explicit initialAreaBounds is set; wires it to initialCrop and shouldShowSelectionHint. Logic is correct and consistent with the existing initialAreaBounds signal flow.

Reviews (3): Last reviewed commit: "fix(desktop): check lastSelectedBounds b..." | Re-trigger Greptile

@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 26, 2026
Comment thread apps/desktop/src/routes/capture-area.tsx Outdated
Comment thread apps/desktop/src/routes/capture-area.tsx
@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@richiemcilroy, have a look when you get chance and lmk for any changes

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo please review

@tembo

tembo Bot commented Jun 16, 2026

Copy link
Copy Markdown

@tembo please review

done — posted a review on PR #1872.

the fix is solid: i traced both flows against Cropper.computeInitialBounds and confirmed the cropper reads initialCrop imperatively (only at mount/reset), so making effectiveInitialBounds reactive can't cause spurious crop resets. target-select-overlay.tsx looks correct.

three points raised:

  1. (medium) the frozen screenId in capture-area.tsx can latch onto the "0" default. createOptionsQuery seeds captureTarget to the hardcoded { variant: "display", id: "0" }, and the real value loads async from the tauri store. so untrack(activeScreenId) is always non-null at freeze time. in the happy path localStorage sync-hydrates the right target first, but if localStorage is empty/migrated (e.g. first open after the -2 key rename, wiped webview cache), it freezes on "0" and the async update can never correct it — the guarding effect is dead because screenId() is already truthy. result would be a blank cropper + handleConfirm saving screen: "0". worth confirming or simplifying (a plain reactive activeScreenId() gives the same value at mount).

  2. (low) hasStoredSelection doesn't mirror the new initialCrop area fallback — same thing greptile flagged. can briefly flash the "draw a selection" hint on reopen when the area was confirmed via the overlay flow. one-line fix.

  3. target-select-overlay.tsx — no concerns, display-scoping is correct.

ship-able once #1 is confirmed safe or simplified and #2 is polished.

@tembo tembo 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.

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:

  • createOptionsQuery seeds _state.captureTarget to the hardcoded default { variant: "display", id: "0" } (queries.ts:166).
  • makePersisted(localStorage) (queries.ts:225) hydrates synchronously over that if the recording-options-query-2 key exists.
  • The real value from recordingSettingsStore (Tauri Store.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.

@superagent-security superagent-security Bot removed the contributor:verified Contributor passed trust analysis. label Jun 16, 2026
@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@superagent-security superagent-security Bot removed the pr:verified PR passed security analysis. label Jun 16, 2026
const hasStoredSelection = createMemo(() => {
const id = screenId();
if (!id) return false;
if (rawOptions.captureTarget.variant === "area") return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
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
);
});

params.displayId &&
target.screen === params.displayId
) {
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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).

Suggested change
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,
};
}

Comment on lines 83 to 93

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
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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;
});

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo, @greptileai please re-review

@tembo

tembo Bot commented Jun 16, 2026

Copy link
Copy Markdown

@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.

#1872 (comment)

@tembo

tembo Bot commented Jun 16, 2026

Copy link
Copy Markdown

Re-review — looks close. The earlier rounds are resolved; one regression in capture-area.tsx's reset() is still open.

✅ Verified resolved on 4de6f6dcc:

  • screenId no longer read reactively inside an effect (now a plain memo alias).
  • initialCrop falls back to captureTarget.bounds for the area variant.
  • effectiveInitialBounds guards ≤1×1 bounds so the draw-selection hint still shows.
  • hasStoredSelection checks lastSelectedBounds before the captureTarget fallback.

⚠️ Open: reset() clears the persisted store but not captureTarget, so the fallback resurrects the old bounds

apps/desktop/src/routes/capture-area.tsx (lines 161-170):

function reset() {
	cropperRef?.reset();
	setAspect(null);

	const id = screenId();
	if (!id) return;
	setState("lastSelectedBounds", (values) =>
		values?.filter((v) => v.screenId !== id),
	);
}

reset() removes the entry from the persisted lastSelectedBounds, but rawOptions.captureTarget still holds the old area bounds. Because this PR added the captureTarget fallback to both hasStoredSelection and initialCrop, the clear is now only half-effective:

  • In-session: right after Reset, crop() is zeroed but hasStoredSelection() still returns true via the area branch → the "draw a selection" hint stays suppressed even though there's no selection.
  • Across reopens: next time the window opens, lastSelectedBounds has no entry, so initialCrop falls through to captureTarget.bounds and the just-cleared area reappears.

Before this PR lastSelectedBounds was the only source, so Reset fully cleared the selection — this is a behavior regression introduced here, scoped exactly to the surface the PR touches.

Suggested fix — also clear the area bounds on the captureTarget for that screen:

function reset() {
	cropperRef?.reset();
	setAspect(null);

	const id = screenId();
	if (!id) return;
	setState("lastSelectedBounds", (values) =>
		values?.filter((v) => v.screenId !== id),
	);

	const target = rawOptions.captureTarget;
	if (target.variant === "area" && target.screen === id) {
		setOptions(
			"captureTarget",
			reconcile({
				variant: "area",
				screen: id,
				bounds: {
					position: { x: 0, y: 0 },
					size: { width: 0, height: 0 },
				},
			}),
		);
	}
}

The ≤1 size guards already in hasStoredSelection/effectiveInitialBounds then correctly treat the zeroed bounds as "no selection", so the hint reappears and reopening starts blank.

(Note: the overlay's "Reset selection" in target-select-overlay.tsx doesn't persist anything either way — it only mutates captureTarget on confirm/mode-switch — so it's internally consistent and not affected by this.)

Happy to push this fix to the branch if you'd like.

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo please re-review

@tembo

tembo Bot commented Jun 16, 2026

Copy link
Copy Markdown

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

  • Any way to contribute initially? #1 frozen screenId latching onto the "0" default → gone, screenId is now a plain reactive alias of the activeScreenId memo
  • Update README.md #2 hasStoredSelection not mirroring the initialCrop area fallback → fixed, now falls back to the captureTarget area variant (guarded on width/height > 1), so the hint no longer flashes on reopen
  • greptile — reset() leaving captureTarget intact and resurrecting old bounds → fixed, reset() now zeros the area bounds and all read sites agree (> 1 = has selection, <= 1 = none), so the cleared state persists across reopen
  • overlay flow still correctly scoped to the right display

two minor non-blocking notes (both pre-existing, not regressions): reset() calls cropperRef.reset() before clearing state so the box briefly animates back to the old selection; and a reset-without-confirm now persists a 0×0 area target worth a quick sanity-check at the recording entry point.

net: lgtm, shippable.

(m) => m.screenId === id,
)?.bounds;
if (stored) return stored;
const target = rawOptions.captureTarget;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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
);

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo please re-review

@tembo

tembo Bot commented Jun 16, 2026

Copy link
Copy Markdown

@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 initialCrop to prefer captureTarget bounds over lastSelectedBounds. traced it through and it's correct:

  • consistency holds across all read sites — hasStoredSelection is true exactly when initialCrop returns non-zero bounds, in all four state combinations, so no flash-of-hint or blank-cropper mismatch
  • it's a real improvement: the overlay flow writes captureTarget but never the window's persisted lastSelectedBounds, so preferring captureTarget shows the freshest confirmed area instead of a stale one. within capture-area.tsx the two stay in sync anyway (confirm writes both, reset clears both)

everything from earlier rounds still holds (plain screenId memo, reset() zeroes the area bounds, ≤1×1 guards consistent). ci is green (typecheck/lint/format all pass).

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.

@tembo tembo 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.

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 screenId latching onto the "0" default — gone. screenId is now a plain reactive alias of the activeScreenId memo (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 hasStoredSelection not mirroring the initialCrop area fallback — fixed (capture-area.tsx:84-96). It now falls back to the captureTarget area variant (guarded on width/height > 1), so the "draw a selection" hint no longer flashes on reopen after the area was confirmed via the overlay (which never writes lastSelectedBounds). ✅
  • Greptile — reset() left captureTarget intact, resurrecting old bounds on next open — fixed (capture-area.tsx:171-184). reset() now zeros the captureTarget area bounds, and the read sites agree on the convention: hasStoredSelection requires > 1, while initialCrop / effectiveInitialBounds treat <= 1 as no selection. So the cleared state now persists across a reopen. ✅
  • Overlay (target-select-overlay.tsx) still scopes the restore correctly to target.screen === params.displayId, wired into both shouldShowSelectionHint and initialCrop. ✅

Minor / non-blocking

  • reset() calls cropperRef?.reset() before clearing lastSelectedBounds / captureTarget, so the cropper's imperative computeInitialBounds() reads the still-present bounds and animates back to the old selection (the box stays visible, and the hint stays hidden because crop() 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, move cropperRef?.reset() to after the state clears.
  • After a reset-without-confirm, the persisted captureTarget is now an area with 0×0 bounds. 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.

@tembo tembo 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.

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() === trueinitialCrop() 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):

  1. reset() calls cropperRef.reset() before clearing state, so the box briefly animates back to the old selection.
  2. a reset-without-confirm persists a 0×0 area captureTarget — 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.

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo please re-review

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