Selection Actions
AI & AgentsHighlight a passage and hand it to the agent to rewrite.
Interactive Live Preview
components/ui/selection-actions.tsxTypeScript · React 19
1"use client";23import { 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";78/* ─────────────────────────────────────────────────────────9 * SELECTION ACTIONS10 * A contextual AI bar attached beneath selected text.11 * The global theme owns its surface; this component only12 * composes existing surface, ink, accent, radius and motion13 * tokens.14 * ───────────────────────────────────────────────────────── */1516const 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.";2122type Mode = "idle" | "thinking" | "streaming" | "result";2324const iconProps = {25 width: 14,26 height: 14,27 strokeWidth: 1.8,28 "aria-hidden": true,29} as const;3031const icons = {32 explain: <ChatBubbleQuestion {...iconProps} />,33 improve: <Spark {...iconProps} />,34 shorten: <Scissor {...iconProps} />,35 tone: <EmojiSatisfied {...iconProps} />,36 grammar: <TextBox {...iconProps} />,37 send: (38 <ArrowUp39 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};5051const 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]";5354const 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]";5657export 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);6667 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);7576 useEffect(() => {77 const timer = window.setTimeout(() => setShown(true), 280);78 return () => window.clearTimeout(timer);79 }, []);8081 useEffect(() => {82 if (mode !== "thinking") return;83 const timer = window.setTimeout(() => setMode("streaming"), 700);84 return () => window.clearTimeout(timer);85 }, [mode]);8687 /* Attach beneath the final selected line, while centering the bar88 * against the complete selection bounds. requestAnimationFrame batches89 * 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;9697 const bounds = selection.getBoundingClientRect();98 const lines = Array.from(selection.getClientRects());99 const lastLine = lines.at(-1);100 if (!lastLine) return;101102 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 };107108 setAnchor((current) =>109 current.x === next.x && current.y === next.y ? current : next,110 );111 setPositioned(true);112 });113 }, []);114115 useLayoutEffect(() => {116 place();117 }, [mode, place]);118119 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]);131132 /* Intrinsic width handles the preset expansion. When the entire content133 * changes between idle, loading and confirmation, animate from the last134 * 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;139140 const nextWidth = Math.ceil(content.getBoundingClientRect().width) + 8;141 const previousWidth =142 lastWidthRef.current || Math.ceil(bar.getBoundingClientRect().width);143144 if (145 previousModeRef.current !== mode &&146 Math.abs(nextWidth - previousWidth) > 1147 ) {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 }167168 previousModeRef.current = mode;169 }, [mode]);170171 useEffect(() => {172 const content = contentRef.current;173 if (!content) return;174175 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 }, []);186187 const run = (nextAction: string) => {188 setAction(nextAction);189 setExpanded(false);190 setMode("thinking");191 };192193 const reset = () => {194 setExpanded(false);195 setPrompt("");196 setTypingWidth(null);197 setAction("Improve");198 setMode("idle");199 };200201 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";212213 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 <span219 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 PICKED224 ) : mode === "streaming" ? (225 <StreamText226 text={REWRITE}227 onProgress={place}228 onDone={() => setMode("result")}229 />230 ) : (231 REWRITE232 )}233 </span>234 </p>235236 <div237 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 controls248 resolve to a 14px radius, preserving the concentric curve. */}249 <div250 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 && typingWidth255 ? typingWidth256 : undefined,257 ...(visible258 ? {259 animation:260 "pop-in 220ms cubic-bezier(0.23,1,0.32,1) both",261 }262 : {}),263 }}264 >265 <div266 ref={contentRef}267 className="flex w-fit shrink-0 items-center justify-center gap-0.5"268 style={{269 width:270 mode === "idle" && hasPrompt && typingWidth271 ? typingWidth - 8272 : 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 <span278 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 )}290291 {mode === "result" && (292 <>293 <button294 type="button"295 onClick={reset}296 className={primary}297 >298 {icons.check}299 Keep300 </button>301 <button type="button" onClick={reset} className={control}>302 {icons.close}303 Discard304 </button>305 <span className="mx-0.5 h-4 w-px shrink-0 bg-line" />306 <button307 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 )}316317 {mode === "idle" && (318 <>319 <div320 className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400"321 style={{322 maxWidth: expanded323 ? 0324 : hasPrompt && typingWidth325 ? typingWidth - 40326 : 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 <form333 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 <input345 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>366367 <div368 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 Explain382 </button>383 <button384 type="button"385 onClick={() => run("Improve")}386 className={control}387 >388 {icons.improve}389 Improve390 </button>391392 <div393 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 <button402 type="button"403 onClick={() => run("Shorten")}404 className={control}405 >406 {icons.shorten}407 Shorten408 </button>409 <button410 type="button"411 onClick={() => run("Change tone")}412 className={control}413 >414 {icons.tone}415 Tone416 </button>417 <button418 type="button"419 onClick={() => run("Fix grammar")}420 className={control}421 >422 {icons.grammar}423 Grammar424 </button>425 </div>426427 <span className="mx-0.5 h-4 w-px shrink-0 bg-line" />428 <button429 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 <span436 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>446447 <div448 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 <button457 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>471472 </div>473 );474}475
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |