Selection Actions

AI & Agents

Highlight a passage and hand it to the agent to rewrite.

Interactive Live Preview
components/ui/selection-actions.tsxTypeScript · React 19
1"use client";
2
3import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4import { ArrowUp, ChatBubbleQuestion, Check, EmojiSatisfied, NavArrowRight, Refresh, Scissor, Spark, TextBox, Xmark } from "@/components/icons";
5import { Shimmer } from "@/components/atoms/Shimmer";
6import { StreamText } from "@/components/atoms/StreamText";
7
8/* ─────────────────────────────────────────────────────────
9 * SELECTION ACTIONS
10 * A contextual AI bar attached beneath selected text.
11 * The global theme owns its surface; this component only
12 * composes existing surface, ink, accent, radius and motion
13 * tokens.
14 * ───────────────────────────────────────────────────────── */
15
16const LEAD = "Pistachio holds the top slot all weekend. ";
17const PICKED =
18 "Churn it first thing Saturday so the batch has time to firm up before the afternoon rush.";
19const REWRITE =
20 "Churn pistachio first thing Saturday so the batch has time to fully firm before the afternoon rush.";
21
22type Mode = "idle" | "thinking" | "streaming" | "result";
23
24const iconProps = {
25 width: 14,
26 height: 14,
27 strokeWidth: 1.8,
28 "aria-hidden": true,
29} as const;
30
31const icons = {
32 explain: <ChatBubbleQuestion {...iconProps} />,
33 improve: <Spark {...iconProps} />,
34 shorten: <Scissor {...iconProps} />,
35 tone: <EmojiSatisfied {...iconProps} />,
36 grammar: <TextBox {...iconProps} />,
37 send: (
38 <ArrowUp
39 width="16"
40 height="16"
41 strokeWidth="2.4"
42 aria-hidden="true"
43 />
44 ),
45 chevron: <NavArrowRight {...iconProps} />,
46 check: <Check {...iconProps} />,
47 close: <Xmark {...iconProps} />,
48 retry: <Refresh {...iconProps} />,
49};
50
51const control =
52 "inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2.5 text-[12px] font-normal text-ink transition-[background-color,color,transform] duration-150 hover:bg-hover active:scale-[0.96]";
53
54const primary =
55 "inline-flex h-7 shrink-0 items-center gap-1 rounded-full bg-ink px-2.5 text-[12.5px] font-normal text-canvas shadow-hairline transition-[opacity,transform] duration-150 hover:opacity-90 active:scale-[0.96]";
56
57export default function SelectionActions() {
58 const [shown, setShown] = useState(false);
59 const [mode, setMode] = useState<Mode>("idle");
60 const [action, setAction] = useState("Improve");
61 const [prompt, setPrompt] = useState("");
62 const [typingWidth, setTypingWidth] = useState<number | null>(null);
63 const [expanded, setExpanded] = useState(false);
64 const [anchor, setAnchor] = useState({ x: 0, y: 0 });
65 const [positioned, setPositioned] = useState(false);
66
67 const hostRef = useRef<HTMLDivElement>(null);
68 const selectionRef = useRef<HTMLSpanElement>(null);
69 const barRef = useRef<HTMLDivElement>(null);
70 const contentRef = useRef<HTMLDivElement>(null);
71 const frameRef = useRef<number | null>(null);
72 const previousModeRef = useRef<Mode>("idle");
73 const lastWidthRef = useRef(0);
74 const widthAnimationRef = useRef<Animation | null>(null);
75
76 useEffect(() => {
77 const timer = window.setTimeout(() => setShown(true), 280);
78 return () => window.clearTimeout(timer);
79 }, []);
80
81 useEffect(() => {
82 if (mode !== "thinking") return;
83 const timer = window.setTimeout(() => setMode("streaming"), 700);
84 return () => window.clearTimeout(timer);
85 }, [mode]);
86
87 /* Attach beneath the final selected line, while centering the bar
88 * against the complete selection bounds. requestAnimationFrame batches
89 * streaming reflow measurements and avoids visible intermediate positions. */
90 const place = useCallback(() => {
91 if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
92 frameRef.current = requestAnimationFrame(() => {
93 const host = hostRef.current;
94 const selection = selectionRef.current;
95 if (!host || !selection) return;
96
97 const bounds = selection.getBoundingClientRect();
98 const lines = Array.from(selection.getClientRects());
99 const lastLine = lines.at(-1);
100 if (!lastLine) return;
101
102 const hostBounds = host.getBoundingClientRect();
103 const next = {
104 x: Math.round(bounds.left - hostBounds.left + bounds.width / 2),
105 y: Math.round(lastLine.bottom - hostBounds.top + 8),
106 };
107
108 setAnchor((current) =>
109 current.x === next.x && current.y === next.y ? current : next,
110 );
111 setPositioned(true);
112 });
113 }, []);
114
115 useLayoutEffect(() => {
116 place();
117 }, [mode, place]);
118
119 useEffect(() => {
120 const host = hostRef.current;
121 if (!host) return;
122 const observer = new ResizeObserver(place);
123 observer.observe(host);
124 window.addEventListener("resize", place);
125 return () => {
126 observer.disconnect();
127 window.removeEventListener("resize", place);
128 if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
129 };
130 }, [place]);
131
132 /* Intrinsic width handles the preset expansion. When the entire content
133 * changes between idle, loading and confirmation, animate from the last
134 * rendered width to the new intrinsic width before the browser paints. */
135 useLayoutEffect(() => {
136 const bar = barRef.current;
137 const content = contentRef.current;
138 if (!bar || !content) return;
139
140 const nextWidth = Math.ceil(content.getBoundingClientRect().width) + 8;
141 const previousWidth =
142 lastWidthRef.current || Math.ceil(bar.getBoundingClientRect().width);
143
144 if (
145 previousModeRef.current !== mode &&
146 Math.abs(nextWidth - previousWidth) > 1
147 ) {
148 widthAnimationRef.current?.cancel();
149 const animation = bar.animate(
150 [
151 { width: `${previousWidth}px` },
152 { width: `${nextWidth}px` },
153 ],
154 {
155 duration: 320,
156 easing: "cubic-bezier(0.23,1,0.32,1)",
157 },
158 );
159 widthAnimationRef.current = animation;
160 animation.onfinish = () => {
161 lastWidthRef.current = nextWidth;
162 widthAnimationRef.current = null;
163 };
164 } else {
165 lastWidthRef.current = nextWidth;
166 }
167
168 previousModeRef.current = mode;
169 }, [mode]);
170
171 useEffect(() => {
172 const content = contentRef.current;
173 if (!content) return;
174
175 const observer = new ResizeObserver(() => {
176 if (widthAnimationRef.current?.playState === "running") return;
177 lastWidthRef.current =
178 Math.ceil(content.getBoundingClientRect().width) + 8;
179 });
180 observer.observe(content);
181 return () => {
182 observer.disconnect();
183 widthAnimationRef.current?.cancel();
184 };
185 }, []);
186
187 const run = (nextAction: string) => {
188 setAction(nextAction);
189 setExpanded(false);
190 setMode("thinking");
191 };
192
193 const reset = () => {
194 setExpanded(false);
195 setPrompt("");
196 setTypingWidth(null);
197 setAction("Improve");
198 setMode("idle");
199 };
200
201 const busy = mode === "thinking" || mode === "streaming";
202 const visible = shown && positioned;
203 const hasPrompt = prompt.trim().length > 0;
204 const busyLabel =
205 action === "Improve"
206 ? "Improving"
207 : action === "Shorten"
208 ? "Shortening"
209 : action === "Change tone"
210 ? "Changing tone"
211 : "Editing";
212
213 return (
214 <div className="w-full max-w-[460px]">
215 <div ref={hostRef} className="relative select-none pb-12">
216 <p className="text-[13px] leading-relaxed text-ink">
217 {LEAD}
218 <span
219 ref={selectionRef}
220 className="box-decoration-clone rounded-[3px] bg-[color-mix(in_srgb,var(--accent)_14%,var(--surface))] text-ink dark:bg-accent-tint"
221 >
222 {mode === "idle" || mode === "thinking" ? (
223 PICKED
224 ) : mode === "streaming" ? (
225 <StreamText
226 text={REWRITE}
227 onProgress={place}
228 onDone={() => setMode("result")}
229 />
230 ) : (
231 REWRITE
232 )}
233 </span>
234 </p>
235
236 <div
237 className="absolute top-0 left-0 z-10"
238 style={{
239 transform: `translate3d(${anchor.x}px, ${anchor.y}px, 0) translateX(-50%)`,
240 transition:
241 "transform 320ms cubic-bezier(0.77,0,0.175,1), opacity 180ms ease-out",
242 opacity: visible ? 1 : 0,
243 pointerEvents: visible ? "auto" : "none",
244 willChange: "transform",
245 }}
246 >
247 {/* A 36px pill wraps 28px controls at a 4px inset. The controls
248 resolve to a 14px radius, preserving the concentric curve. */}
249 <div
250 ref={barRef}
251 className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay"
252 style={{
253 width:
254 mode === "idle" && hasPrompt && typingWidth
255 ? typingWidth
256 : undefined,
257 ...(visible
258 ? {
259 animation:
260 "pop-in 220ms cubic-bezier(0.23,1,0.32,1) both",
261 }
262 : {}),
263 }}
264 >
265 <div
266 ref={contentRef}
267 className="flex w-fit shrink-0 items-center justify-center gap-0.5"
268 style={{
269 width:
270 mode === "idle" && hasPrompt && typingWidth
271 ? typingWidth - 8
272 : undefined,
273 }}
274 >
275 {busy && (
276 <span className="inline-flex h-7 items-center gap-1.5 whitespace-nowrap px-2.5 text-[12.5px] font-normal text-ink-2">
277 <span
278 className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2"
279 style={{ animation: "spin 700ms linear infinite" }}
280 />
281 {mode === "thinking" ? (
282 <Shimmer className="text-[12.5px] font-normal">
283 {busyLabel}…
284 </Shimmer>
285 ) : (
286 <span>{busyLabel}…</span>
287 )}
288 </span>
289 )}
290
291 {mode === "result" && (
292 <>
293 <button
294 type="button"
295 onClick={reset}
296 className={primary}
297 >
298 {icons.check}
299 Keep
300 </button>
301 <button type="button" onClick={reset} className={control}>
302 {icons.close}
303 Discard
304 </button>
305 <span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
306 <button
307 type="button"
308 aria-label="Try again"
309 onClick={() => run(action)}
310 className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink-2 active:scale-[0.96]"
311 >
312 {icons.retry}
313 </button>
314 </>
315 )}
316
317 {mode === "idle" && (
318 <>
319 <div
320 className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400"
321 style={{
322 maxWidth: expanded
323 ? 0
324 : hasPrompt && typingWidth
325 ? typingWidth - 40
326 : 145,
327 opacity: expanded ? 0 : 1,
328 transform: expanded ? "translateX(-8px)" : "translateX(0)",
329 transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
330 }}
331 >
332 <form
333 className="flex h-7 shrink-0 items-center transition-[width] duration-400"
334 style={{
335 width:
336 hasPrompt && typingWidth ? typingWidth - 40 : 145,
337 transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
338 }}
339 onSubmit={(event) => {
340 event.preventDefault();
341 run(prompt.trim() || "Improve");
342 }}
343 >
344 <input
345 value={prompt}
346 onChange={(event) => {
347 const next = event.target.value;
348 if (!prompt.trim() && next.trim()) {
349 setTypingWidth(
350 Math.ceil(
351 barRef.current?.getBoundingClientRect().width ??
352 0,
353 ),
354 );
355 } else if (!next.trim()) {
356 setTypingWidth(null);
357 }
358 setPrompt(next);
359 }}
360 aria-label="Describe edits"
361 placeholder="Describe edits"
362 className="h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink placeholder:text-ink-3"
363 />
364 </form>
365 </div>
366
367 <div
368 className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,transform] duration-400"
369 style={{
370 maxWidth: hasPrompt ? 0 : expanded ? 462 : 224,
371 opacity: hasPrompt ? 0 : 1,
372 transform: hasPrompt ? "translateX(-8px)" : "translateX(0)",
373 transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
374 }}
375 >
376 {!expanded && (
377 <span className="mx-1 h-4 w-px shrink-0 bg-line-strong" />
378 )}
379 <button type="button" className={control}>
380 {icons.explain}
381 Explain
382 </button>
383 <button
384 type="button"
385 onClick={() => run("Improve")}
386 className={control}
387 >
388 {icons.improve}
389 Improve
390 </button>
391
392 <div
393 className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,margin] duration-400"
394 style={{
395 maxWidth: expanded ? 262 : 0,
396 opacity: expanded ? 1 : 0,
397 marginLeft: expanded ? 2 : 0,
398 transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
399 }}
400 >
401 <button
402 type="button"
403 onClick={() => run("Shorten")}
404 className={control}
405 >
406 {icons.shorten}
407 Shorten
408 </button>
409 <button
410 type="button"
411 onClick={() => run("Change tone")}
412 className={control}
413 >
414 {icons.tone}
415 Tone
416 </button>
417 <button
418 type="button"
419 onClick={() => run("Fix grammar")}
420 className={control}
421 >
422 {icons.grammar}
423 Grammar
424 </button>
425 </div>
426
427 <span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
428 <button
429 type="button"
430 aria-label={expanded ? "Show fewer actions" : "Show more actions"}
431 aria-expanded={expanded}
432 onClick={() => setExpanded((value) => !value)}
433 className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]"
434 >
435 <span
436 className="flex transition-transform duration-400"
437 style={{
438 transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
439 transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
440 }}
441 >
442 {icons.chevron}
443 </span>
444 </button>
445 </div>
446
447 <div
448 className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400"
449 style={{
450 maxWidth: hasPrompt ? 30 : 0,
451 opacity: hasPrompt ? 1 : 0,
452 transform: hasPrompt ? "scale(1)" : "scale(0.88)",
453 transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
454 }}
455 >
456 <button
457 type="button"
458 aria-label="Send edit instruction"
459 onClick={() => run(prompt.trim())}
460 className="flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]"
461 >
462 {icons.send}
463 </button>
464 </div>
465 </>
466 )}
467 </div>
468 </div>
469 </div>
470 </div>
471
472 </div>
473 );
474}
475

Props & Specification

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in AI & Agents