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..b9fbfb12 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.pasteMode ? (
- {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.handleCheck} + disabled={clipboard.checking} {...(testIdPrefix ? { "data-testid": `${testIdPrefix}-clipboard-action` } : {})} > - {checkingClipboard ? ( + {clipboard.checking ? ( ) : ( @@ -414,7 +333,7 @@ export function ContextPicker({ )} - {clipboardReadFeedback ? ( + {clipboard.readFeedback ? (

- {clipboardReadFeedback} + {clipboard.readFeedback}

) : 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..2ae53d07 --- /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 { + checking: checkingClipboard, + pasteMode: clipboardPasteMode, + pasteTooltip, + readFeedback: clipboardReadFeedback, + pasteRef: clipboardPasteRef, + handleCheck: handleCheckClipboard, + handlePasteInput: handleClipboardPasteInput, + handlePasteBlur: handleClipboardPasteBlur, + handlePaste, + }; +}