From cb607b6f668ecc2a62f7504da4cb280363bc41a7 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 13 Jul 2026 02:09:34 -0600 Subject: [PATCH 1/2] refactor(web): extract clipboard hook and item subcomponents from context-picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split context-picker.tsx (609 → 441 lines) by extracting: - use-context-picker-clipboard.ts (142 lines): all clipboard state, refs, and handlers (checkClipboard, pasteInput, pasteFeedback) into a custom hook - context-picker-items.tsx (85 lines): ContextFileItem and ContextLinkItem subcomponents for file thumbnail and link badge rendering Also deduplicated the popover content JSX that was repeated in both the empty-state and has-items branches. Co-Authored-By: Claude Opus 4.6 --- .../components/app/context-picker-items.tsx | 85 +++++ .../web/src/components/app/context-picker.tsx | 296 ++++-------------- .../app/use-context-picker-clipboard.ts | 142 +++++++++ 3 files changed, 291 insertions(+), 232 deletions(-) create mode 100644 apps/web/src/components/app/context-picker-items.tsx create mode 100644 apps/web/src/components/app/use-context-picker-clipboard.ts diff --git a/apps/web/src/components/app/context-picker-items.tsx b/apps/web/src/components/app/context-picker-items.tsx new file mode 100644 index 00000000..31254462 --- /dev/null +++ b/apps/web/src/components/app/context-picker-items.tsx @@ -0,0 +1,85 @@ +import { FileText, Link2, X } from "lucide-react"; + +import { + startupFileExt, + startupLinkLabel, +} from "@/components/app/create-agent-dialog-clipboard"; + +export function ContextFileItem({ + file, + preview, + onRemove, +}: { + file: File; + preview: string | undefined; + onRemove: () => void; +}) { + return ( +
+
+ {preview ? ( + + ) : ( +
+ + + {startupFileExt(file.name)} + +
+ )} + +
+ + {file.name} + +
+ ); +} + +export function ContextLinkItem({ + link, + onRemove, +}: { + link: string; + onRemove: () => void; +}) { + const { host, rest } = startupLinkLabel(link); + return ( +
+
+ + {host} +
+ {rest ? ( + + {rest} + + ) : null} + +
+ ); +} diff --git a/apps/web/src/components/app/context-picker.tsx b/apps/web/src/components/app/context-picker.tsx index a8d35748..a7664d19 100644 --- a/apps/web/src/components/app/context-picker.tsx +++ b/apps/web/src/components/app/context-picker.tsx @@ -1,6 +1,5 @@ import { type ChangeEvent, - type ClipboardEvent, useCallback, useEffect, useId, @@ -10,25 +9,22 @@ import { import { ChevronLeft, Clipboard, - FileText, Link2, Paperclip, Plus, Upload, - X, } from "lucide-react"; import { - type ClipboardSuggestion, STARTUP_FILE_ACCEPT, - createClipboardSuggestionFromText, - getClipboardFilesFromEvent, - getClipboardSuggestion, normalizeUrl, - startupFileExt, startupFileKey, - startupLinkLabel, } from "@/components/app/create-agent-dialog-clipboard"; +import { + ContextFileItem, + ContextLinkItem, +} from "@/components/app/context-picker-items"; +import { useContextPickerClipboard } from "@/components/app/use-context-picker-clipboard"; import { ActivityBars } from "@/components/ui/activity-bars"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -186,101 +182,19 @@ export function ContextPicker({ const rootRef = useRef(null); const fileInputRef = useRef(null); - const clipboardRequestIdRef = useRef(0); - const clipboardPasteRef = useRef(null); - const pasteTooltipTimerRef = useRef>(); const [linkDraft, setLinkDraft] = useState(""); - const [checkingClipboard, setCheckingClipboard] = useState(false); - const [clipboardPasteMode, setClipboardPasteMode] = useState(false); - const [pasteTooltip, setPasteTooltip] = useState(null); - const [clipboardReadFeedback, setClipboardReadFeedback] = useState< - string | null - >(null); const [addOpen, setAddOpen] = useState(false); const [addMode, setAddMode] = useState<"menu" | "link">("menu"); - const hasContextItems = files.length > 0 || links.length > 0; - - const applyClipboardSuggestion = useCallback( - (suggestion: ClipboardSuggestion) => { - switch (suggestion.kind) { - case "image": - case "file": - onAppendFiles([suggestion.file]); - break; - case "url": - onAddLink(suggestion.url); - break; - case "text": - onClipboardText?.(suggestion.text); - break; - } - }, - [onAppendFiles, onAddLink, onClipboardText] - ); - - const handleCheckClipboard = useCallback(() => { - setCheckingClipboard(true); - setClipboardReadFeedback(null); - const requestId = clipboardRequestIdRef.current + 1; - clipboardRequestIdRef.current = requestId; - void getClipboardSuggestion().then((result) => { - if (clipboardRequestIdRef.current !== requestId) return; - setCheckingClipboard(false); - if (result.suggestion) { - if (result.suggestion.kind === "text" && !onClipboardText) { - setClipboardReadFeedback("Nothing readable found on the clipboard."); - } else { - applyClipboardSuggestion(result.suggestion); - } - return; - } - if (result.status === "blocked" || result.status === "unsupported") { - setClipboardPasteMode(true); - requestAnimationFrame(() => clipboardPasteRef.current?.focus()); - } else { - setClipboardReadFeedback("Nothing readable found on the clipboard."); - } - }); - }, [applyClipboardSuggestion, onClipboardText]); - - const handleClipboardPasteInput = useCallback( - (event: ClipboardEvent) => { - event.preventDefault(); - const pastedFiles = getClipboardFilesFromEvent(event); - if (pastedFiles.length > 0) { - onAppendFiles(pastedFiles); - setClipboardPasteMode(false); - return; - } - const textSuggestion = createClipboardSuggestionFromText( - event.clipboardData.getData("text/plain") - ); - if (textSuggestion?.kind === "url") { - applyClipboardSuggestion(textSuggestion); - setClipboardPasteMode(false); - return; - } - if (onClipboardText && textSuggestion?.kind === "text") { - onClipboardText(textSuggestion.text); - setClipboardPasteMode(false); - return; - } - clearTimeout(pasteTooltipTimerRef.current); - setPasteTooltip("No files or images found"); - pasteTooltipTimerRef.current = setTimeout( - () => setPasteTooltip(null), - 2500 - ); - }, - [onAppendFiles, applyClipboardSuggestion, onClipboardText] - ); + const clipboard = useContextPickerClipboard({ + onAppendFiles, + onAddLink, + onClipboardText, + }); + const { handlePaste: clipboardHandlePaste } = clipboard; - const handleClipboardPasteBlur = useCallback(() => { - setClipboardPasteMode(false); - setPasteTooltip(null); - }, []); + const hasContextItems = files.length > 0 || links.length > 0; const handleFileChange = useCallback( (event: ChangeEvent) => { @@ -291,27 +205,13 @@ export function ContextPicker({ [onAppendFiles] ); - const handlePaste = useCallback( - (event: ClipboardEvent) => { + const handleRootPaste = useCallback( + (event: React.ClipboardEvent) => { if (rootRef.current && !rootRef.current.contains(event.target as Node)) return; - const pastedFiles = getClipboardFilesFromEvent(event); - if (pastedFiles.length > 0) { - event.preventDefault(); - onAppendFiles(pastedFiles); - setClipboardReadFeedback(null); - return; - } - const textSuggestion = createClipboardSuggestionFromText( - event.clipboardData.getData("text/plain") - ); - if (textSuggestion?.kind === "url") { - event.preventDefault(); - applyClipboardSuggestion(textSuggestion); - setClipboardReadFeedback(null); - } + clipboardHandlePaste(event); }, - [onAppendFiles, applyClipboardSuggestion] + [clipboardHandlePaste] ); const handleAddOpenChange = useCallback((next: boolean) => { @@ -353,6 +253,25 @@ export function ContextPicker({ setAddOpen(false); }, [normalizedLinkDraft, onAddLink]); + const popoverContent = + addMode === "menu" ? ( + + ) : ( + + ); + return (
@@ -374,22 +293,22 @@ export function ContextPicker({

{description}

- {clipboardPasteMode ? ( + {clipboard.clipboardPasteMode ? (
- {pasteTooltip ? ( + {clipboard.pasteTooltip ? (
- {pasteTooltip} + {clipboard.pasteTooltip}
) : null}
@@ -399,13 +318,13 @@ export function ContextPicker({ variant="default" size="sm" className="h-7 shrink-0 gap-1 px-2 text-xs" - onClick={handleCheckClipboard} - disabled={checkingClipboard} + onClick={clipboard.handleCheckClipboard} + disabled={clipboard.checkingClipboard} {...(testIdPrefix ? { "data-testid": `${testIdPrefix}-clipboard-action` } : {})} > - {checkingClipboard ? ( + {clipboard.checkingClipboard ? ( ) : ( @@ -414,7 +333,7 @@ export function ContextPicker({ )} - {clipboardReadFeedback ? ( + {clipboard.clipboardReadFeedback ? (

- {clipboardReadFeedback} + {clipboard.clipboardReadFeedback}

) : null} - {addMode === "menu" ? ( - - ) : ( - - )} + {popoverContent} ) : (
- {files.map((file) => { - const key = startupFileKey(file); - const preview = filePreviewsRef.current.get(key); - return ( -
-
- {preview ? ( - - ) : ( -
- - - {startupFileExt(file.name)} - -
- )} - -
- - {file.name} - -
- ); - })} - {links.map((link) => { - const { host, rest } = startupLinkLabel(link); - return ( -
-
- - {host} -
- {rest ? ( - - {rest} - - ) : null} - -
- ); - })} + {files.map((file) => ( + onRemoveFile(file)} + /> + ))} + {links.map((link) => ( + onRemoveLink(link)} + /> + ))}
diff --git a/apps/web/src/components/app/use-context-picker-clipboard.ts b/apps/web/src/components/app/use-context-picker-clipboard.ts new file mode 100644 index 00000000..1ad8697a --- /dev/null +++ b/apps/web/src/components/app/use-context-picker-clipboard.ts @@ -0,0 +1,142 @@ +import { type ClipboardEvent, useCallback, useRef, useState } from "react"; + +import { + type ClipboardSuggestion, + createClipboardSuggestionFromText, + getClipboardFilesFromEvent, + getClipboardSuggestion, +} from "@/components/app/create-agent-dialog-clipboard"; + +export function useContextPickerClipboard({ + onAppendFiles, + onAddLink, + onClipboardText, +}: { + onAppendFiles: (files: File[]) => void; + onAddLink: (normalizedUrl: string) => void; + onClipboardText?: (text: string) => void; +}) { + const clipboardRequestIdRef = useRef(0); + const clipboardPasteRef = useRef(null); + const pasteTooltipTimerRef = useRef>(); + + const [checkingClipboard, setCheckingClipboard] = useState(false); + const [clipboardPasteMode, setClipboardPasteMode] = useState(false); + const [pasteTooltip, setPasteTooltip] = useState(null); + const [clipboardReadFeedback, setClipboardReadFeedback] = useState< + string | null + >(null); + + const applyClipboardSuggestion = useCallback( + (suggestion: ClipboardSuggestion) => { + switch (suggestion.kind) { + case "image": + case "file": + onAppendFiles([suggestion.file]); + break; + case "url": + onAddLink(suggestion.url); + break; + case "text": + onClipboardText?.(suggestion.text); + break; + } + }, + [onAppendFiles, onAddLink, onClipboardText] + ); + + const handleCheckClipboard = useCallback(() => { + setCheckingClipboard(true); + setClipboardReadFeedback(null); + const requestId = clipboardRequestIdRef.current + 1; + clipboardRequestIdRef.current = requestId; + void getClipboardSuggestion().then((result) => { + if (clipboardRequestIdRef.current !== requestId) return; + setCheckingClipboard(false); + if (result.suggestion) { + if (result.suggestion.kind === "text" && !onClipboardText) { + setClipboardReadFeedback("Nothing readable found on the clipboard."); + } else { + applyClipboardSuggestion(result.suggestion); + } + return; + } + if (result.status === "blocked" || result.status === "unsupported") { + setClipboardPasteMode(true); + requestAnimationFrame(() => clipboardPasteRef.current?.focus()); + } else { + setClipboardReadFeedback("Nothing readable found on the clipboard."); + } + }); + }, [applyClipboardSuggestion, onClipboardText]); + + const handleClipboardPasteInput = useCallback( + (event: ClipboardEvent) => { + event.preventDefault(); + const pastedFiles = getClipboardFilesFromEvent(event); + if (pastedFiles.length > 0) { + onAppendFiles(pastedFiles); + setClipboardPasteMode(false); + return; + } + const textSuggestion = createClipboardSuggestionFromText( + event.clipboardData.getData("text/plain") + ); + if (textSuggestion?.kind === "url") { + applyClipboardSuggestion(textSuggestion); + setClipboardPasteMode(false); + return; + } + if (onClipboardText && textSuggestion?.kind === "text") { + onClipboardText(textSuggestion.text); + setClipboardPasteMode(false); + return; + } + clearTimeout(pasteTooltipTimerRef.current); + setPasteTooltip("No files or images found"); + pasteTooltipTimerRef.current = setTimeout( + () => setPasteTooltip(null), + 2500 + ); + }, + [onAppendFiles, applyClipboardSuggestion, onClipboardText] + ); + + const handleClipboardPasteBlur = useCallback(() => { + setClipboardPasteMode(false); + setPasteTooltip(null); + }, []); + + const handlePaste = useCallback( + (event: ClipboardEvent) => { + const pastedFiles = getClipboardFilesFromEvent(event); + if (pastedFiles.length > 0) { + event.preventDefault(); + onAppendFiles(pastedFiles); + setClipboardReadFeedback(null); + return; + } + const textSuggestion = createClipboardSuggestionFromText( + event.clipboardData.getData("text/plain") + ); + if (textSuggestion?.kind === "url") { + event.preventDefault(); + applyClipboardSuggestion(textSuggestion); + setClipboardReadFeedback(null); + } + }, + [onAppendFiles, applyClipboardSuggestion] + ); + + return { + checkingClipboard, + clipboardPasteMode, + pasteTooltip, + clipboardReadFeedback, + clipboardPasteRef, + handleCheckClipboard, + handleClipboardPasteInput, + handleClipboardPasteBlur, + handlePaste, + }; +} From e3ebf0229c7225c4cd5992ce9b1929fabd4d073f Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 13 Jul 2026 02:14:10 -0600 Subject: [PATCH 2/2] refactor(web): drop clipboard prefix from hook return keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address architecture review feedback: rename stuttery hook return keys (e.g. clipboard.clipboardPasteMode → clipboard.pasteMode) since the clipboard namespace already provides context. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/context-picker.tsx | 18 +++++++++--------- .../app/use-context-picker-clipboard.ts | 14 +++++++------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/app/context-picker.tsx b/apps/web/src/components/app/context-picker.tsx index a7664d19..b9fbfb12 100644 --- a/apps/web/src/components/app/context-picker.tsx +++ b/apps/web/src/components/app/context-picker.tsx @@ -293,15 +293,15 @@ export function ContextPicker({

{description}

- {clipboard.clipboardPasteMode ? ( + {clipboard.pasteMode ? (
{clipboard.pasteTooltip ? (
- {clipboard.checkingClipboard ? ( + {clipboard.checking ? ( ) : ( @@ -333,7 +333,7 @@ export function ContextPicker({ )}
- {clipboard.clipboardReadFeedback ? ( + {clipboard.readFeedback ? (

- {clipboard.clipboardReadFeedback} + {clipboard.readFeedback}

) : null}