From 543b7e57cabf88c83c930ad55e0dc73b1b526eef Mon Sep 17 00:00:00 2001
From: Vamil Gandhi <13998000+vamgan@users.noreply.github.com>
Date: Sun, 7 Jun 2026 19:01:07 -0400
Subject: [PATCH] feat(react): expose reactive selection capture state
---
packages/react/README.md | 28 +++++++++--
.../useAskableRegionCapture.test.tsx | 49 +++++++++++++++++++
.../useAskableTextSelectionCapture.test.tsx | 45 +++++++++++++++++
packages/react/src/useAskableRegionCapture.ts | 6 +++
.../src/useAskableTextSelectionCapture.ts | 6 +++
site/docs/api/react.md | 25 ++++++++--
6 files changed, 151 insertions(+), 8 deletions(-)
diff --git a/packages/react/README.md b/packages/react/README.md
index 53254c9..2b64849 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -316,6 +316,13 @@ function RegionTools() {
Lasso area
{capture.active && }
+ {capture.selectionState && (
+
+ )}
>
);
}
@@ -326,8 +333,10 @@ The lasso overlay ships with the core `ASKABLE_REGION_CAPTURE_THEME`. Pass
region/circle fill, or lasso gradient for your app.
Use `selectionAffordance` to keep the selected area visible and optionally show
an anchored prompt that focuses by default. Pass `dismissible: true` to include
-a built-in clear button. Call `capture.getSelection()` when the chat composer
-needs the current pinned packet, selection geometry, and affordance element.
+a built-in clear button. Read `capture.selectionState` when React should render
+a confirmation chip, inline question input, or custom composer for the pinned
+selection. Call `capture.getSelection()` when non-render code needs the current
+pinned packet, selection geometry, and affordance element.
Use `onSelectionChange(state)` to mirror that pinned context into external
state; it receives `null` when the selection is cleared.
@@ -368,14 +377,23 @@ function SelectionTools() {
{selection.active && }
+ {selection.selectionState && (
+
+ )}
>
);
}
```
-`selection.getSelection()` returns the current pinned text packet, selected
-range metadata, and affordance element. It returns `null` after the selection is
-cleared, dismissed, cancelled, or destroyed.
+`selection.selectionState` updates when text is pinned, cleared, dismissed,
+cancelled, or destroyed. Use it for app-rendered selected-text confirmation and
+inline chat inputs. `selection.getSelection()` returns the same current pinned
+text packet, selected range metadata, and affordance element for imperative
+code.
Use `onSelectionChange(state)` to keep chat input state aligned with the pinned
text selection.
diff --git a/packages/react/src/__tests__/useAskableRegionCapture.test.tsx b/packages/react/src/__tests__/useAskableRegionCapture.test.tsx
index 0ab3e37..ae2ae93 100644
--- a/packages/react/src/__tests__/useAskableRegionCapture.test.tsx
+++ b/packages/react/src/__tests__/useAskableRegionCapture.test.tsx
@@ -33,6 +33,7 @@ describe('useAskableRegionCapture', () => {
{String(capture.active)}
{capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'}
{capture.getSelection() ? JSON.stringify(capture.getSelection()?.selection) : 'null'}
+ {capture.selectionState ? JSON.stringify(capture.selectionState.selection) : 'null'}
);
}
@@ -56,6 +57,7 @@ describe('useAskableRegionCapture', () => {
expect(screen.getByTestId('active').textContent).toBe('false');
expect(screen.getByTestId('packet').textContent).not.toBe('null');
expect(screen.getByTestId('selected').textContent).not.toBe('null');
+ expect(screen.getByTestId('selection-state').textContent).not.toBe('null');
});
const packet = JSON.parse(screen.getByTestId('packet').textContent!);
@@ -77,6 +79,53 @@ describe('useAskableRegionCapture', () => {
shape: 'region',
bounds: { x: 20, y: 30, width: 60, height: 60 },
});
+ expect(JSON.parse(screen.getByTestId('selection-state').textContent!)).toMatchObject({
+ shape: 'region',
+ bounds: { x: 20, y: 30, width: 60, height: 60 },
+ });
+ });
+
+ it('exposes pinned selection state and clears it from React state', async () => {
+ function Consumer() {
+ const capture = useAskableRegionCapture({ selectionAffordance: true });
+
+ return (
+
+
+
+ {capture.selectionState ? capture.selectionState.selection.shape : 'null'}
+
+ );
+ }
+
+ render();
+
+ act(() => {
+ fireEvent.click(screen.getByText('Start'));
+ });
+
+ const overlay = document.getElementById('askable-region-capture')!;
+ act(() => {
+ overlay.dispatchEvent(pointerEvent('pointerdown', 20, 30));
+ overlay.dispatchEvent(pointerEvent('pointermove', 80, 90));
+ overlay.dispatchEvent(pointerEvent('pointerup', 80, 90));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('selection-state').textContent).toBe('region');
+ });
+
+ act(() => {
+ fireEvent.click(screen.getByText('Clear'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('selection-state').textContent).toBe('null');
+ });
});
it('supports circle capture overrides at start time', async () => {
diff --git a/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx b/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx
index 0802902..a511ff4 100644
--- a/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx
+++ b/packages/react/src/__tests__/useAskableTextSelectionCapture.test.tsx
@@ -88,6 +88,7 @@ describe('useAskableTextSelectionCapture', () => {
{capture.lastPacket ? JSON.stringify(capture.lastPacket) : 'null'}
{capture.getSelection() ? JSON.stringify(capture.getSelection()?.selection) : 'null'}
+ {capture.selectionState ? JSON.stringify(capture.selectionState.selection) : 'null'}
);
}
@@ -102,6 +103,7 @@ describe('useAskableTextSelectionCapture', () => {
await waitFor(() => {
expect(screen.getByTestId('packet').textContent).not.toBe('null');
expect(screen.getByTestId('selected').textContent).not.toBe('null');
+ expect(screen.getByTestId('selection-state').textContent).not.toBe('null');
});
const packet = JSON.parse(screen.getByTestId('packet').textContent!);
@@ -122,5 +124,48 @@ describe('useAskableTextSelectionCapture', () => {
text: 'Selected React copy',
selector: '#react-selection',
});
+ expect(JSON.parse(screen.getByTestId('selection-state').textContent!)).toMatchObject({
+ text: 'Selected React copy',
+ selector: '#react-selection',
+ });
+ });
+
+ it('exposes pinned selected text state and clears it from React state', async () => {
+ function Consumer() {
+ const capture = useAskableTextSelectionCapture({
+ selectionAffordance: true,
+ });
+
+ return (
+
+
+
+ {capture.selectionState?.selection.text ?? 'null'}
+
+ );
+ }
+
+ render();
+ selectText('Pinned React text');
+
+ act(() => {
+ fireEvent.click(screen.getByText('Capture'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('selection-state').textContent).toBe('Pinned React text');
+ });
+
+ act(() => {
+ fireEvent.click(screen.getByText('Clear'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('selection-state').textContent).toBe('null');
+ });
});
});
diff --git a/packages/react/src/useAskableRegionCapture.ts b/packages/react/src/useAskableRegionCapture.ts
index 1a7cda4..49f8955 100644
--- a/packages/react/src/useAskableRegionCapture.ts
+++ b/packages/react/src/useAskableRegionCapture.ts
@@ -19,6 +19,7 @@ export interface UseAskableRegionCaptureResult {
active: boolean;
lastPacket: WebContextPacket | null;
lastSelection: AskableRegionCaptureSelection | null;
+ selectionState: AskableRegionCaptureState | null;
start: (overrides?: Partial) => void;
cancel: () => void;
clearSelection: () => void;
@@ -36,6 +37,7 @@ export function useAskableRegionCapture(
const [active, setActive] = useState(false);
const [lastPacket, setLastPacket] = useState(null);
const [lastSelection, setLastSelection] = useState(null);
+ const [selectionState, setSelectionState] = useState(null);
useEffect(() => {
optionsRef.current = options;
@@ -45,12 +47,14 @@ export function useAskableRegionCapture(
handleRef.current?.destroy();
handleRef.current = null;
setActive(false);
+ setSelectionState(null);
}, []);
const cancel = useCallback(() => {
handleRef.current?.cancel();
handleRef.current = null;
setActive(false);
+ setSelectionState(null);
}, []);
const clearSelection = useCallback(() => {
@@ -82,6 +86,7 @@ export function useAskableRegionCapture(
optionsRef.current.onCapture?.(packet, selection);
},
onSelectionChange(state) {
+ setSelectionState(state);
optionsRef.current.onSelectionChange?.(state);
},
onCancel() {
@@ -104,6 +109,7 @@ export function useAskableRegionCapture(
active,
lastPacket,
lastSelection,
+ selectionState,
start,
cancel,
clearSelection,
diff --git a/packages/react/src/useAskableTextSelectionCapture.ts b/packages/react/src/useAskableTextSelectionCapture.ts
index bdb0191..b0cc529 100644
--- a/packages/react/src/useAskableTextSelectionCapture.ts
+++ b/packages/react/src/useAskableTextSelectionCapture.ts
@@ -19,6 +19,7 @@ export interface UseAskableTextSelectionCaptureResult {
active: boolean;
lastPacket: WebContextPacket | null;
lastSelection: AskableTextSelectionCaptureSelection | null;
+ selectionState: AskableTextSelectionCaptureState | null;
start: (overrides?: Partial) => void;
captureNow: (overrides?: Partial) => WebContextPacket | null;
cancel: () => void;
@@ -37,6 +38,7 @@ export function useAskableTextSelectionCapture(
const [active, setActive] = useState(false);
const [lastPacket, setLastPacket] = useState(null);
const [lastSelection, setLastSelection] = useState(null);
+ const [selectionState, setSelectionState] = useState(null);
useEffect(() => {
optionsRef.current = options;
@@ -46,12 +48,14 @@ export function useAskableTextSelectionCapture(
handleRef.current?.destroy();
handleRef.current = null;
setActive(false);
+ setSelectionState(null);
}, []);
const cancel = useCallback(() => {
handleRef.current?.cancel();
handleRef.current = null;
setActive(false);
+ setSelectionState(null);
}, []);
const clearSelection = useCallback(() => {
@@ -78,6 +82,7 @@ export function useAskableTextSelectionCapture(
optionsRef.current.onCapture?.(packet, selection);
},
onSelectionChange(state) {
+ setSelectionState(state);
optionsRef.current.onSelectionChange?.(state);
},
onCancel() {
@@ -115,6 +120,7 @@ export function useAskableTextSelectionCapture(
active,
lastPacket,
lastSelection,
+ selectionState,
start,
captureNow,
cancel,
diff --git a/site/docs/api/react.md b/site/docs/api/react.md
index 999cf61..46a92d0 100644
--- a/site/docs/api/react.md
+++ b/site/docs/api/react.md
@@ -405,6 +405,13 @@ function DashboardCapture() {
Lasso area
{capture.active && }
+ {capture.selectionState && (
+
+ )}
>
);
}
@@ -433,6 +440,7 @@ function DashboardCapture() {
| `active` | `boolean` | Whether the overlay is currently active |
| `lastPacket` | `WebContextPacket \| null` | Last captured packet |
| `lastSelection` | `AskableRegionCaptureSelection \| null` | Last captured geometry; lasso includes point path metadata |
+| `selectionState` | `AskableRegionCaptureState \| null` | Reactive pinned packet, selection, and selected-state affordance element for rendering confirmation UI |
| `start(overrides?)` | `function` | Start capture, optionally overriding shape/intent/etc. |
| `cancel()` | `function` | Cancel the active overlay |
| `clearSelection()` | `function` | Remove the current persisted selected-state UI |
@@ -468,6 +476,13 @@ function TextSelectionCapture() {
{selection.active && }
+ {selection.selectionState && (
+
+ )}
>
);
}
@@ -477,6 +492,10 @@ function TextSelectionCapture() {
`onSelectionChange`, `onCancel`, `ctx`, and packet options such as `includeViewport`, `source`,
`intent`, `privacy`, and `provenance`.
-**Returns:** `ctx`, `active`, `lastPacket`, `lastSelection`, `start(overrides?)`,
-`captureNow(overrides?)`, `cancel()`, `clearSelection()`, `getSelection()`,
-`destroy()`, and `isActive()`.
+**Returns:** `ctx`, `active`, `lastPacket`, `lastSelection`, `selectionState`,
+`start(overrides?)`, `captureNow(overrides?)`, `cancel()`, `clearSelection()`,
+`getSelection()`, `destroy()`, and `isActive()`.
+
+Use `selectionState` when a React component should visibly confirm selected
+context or show an inline question input. It mirrors `getSelection()` but
+updates reactively when capture pins, clears, or dismisses selected context.