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)}
+ />
+ ))}
- {addMode === "menu" ? (
-
- ) : (
-
- )}
+ {popoverContent}
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,
+ };
+}