diff --git a/apps/web/src/components/app/automations-launch-dialog.tsx b/apps/web/src/components/app/automations-launch-dialog.tsx index d13b15a6..fb09a49d 100644 --- a/apps/web/src/components/app/automations-launch-dialog.tsx +++ b/apps/web/src/components/app/automations-launch-dialog.tsx @@ -1,6 +1,4 @@ import { - type ChangeEvent, - type ClipboardEvent, type DragEvent, useCallback, useEffect, @@ -9,30 +7,11 @@ import { useState, } from "react"; import { useNavigate } from "react-router-dom"; -import { - ChevronLeft, - Clipboard, - FileText, - Link2, - Paperclip, - Play, - Plus, - Upload, - X, -} from "lucide-react"; +import { Play } from "lucide-react"; import { toast } from "sonner"; -import { - type ClipboardSuggestion, - STARTUP_FILE_ACCEPT, - createClipboardSuggestionFromText, - getClipboardFilesFromEvent, - getClipboardSuggestion, - normalizeUrl, - startupFileExt, - startupFileKey, - startupLinkLabel, -} from "@/components/app/create-agent-dialog-clipboard"; +import { ContextPicker } from "@/components/app/context-picker"; +import { startupFileKey } from "@/components/app/create-agent-dialog-clipboard"; import { AgentTypeCombobox } from "@/components/app/automations-form-fields"; import { ActivityBars } from "@/components/ui/activity-bars"; import { Button } from "@/components/ui/button"; @@ -44,11 +23,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { useTemplateActions, parseTemplateArgs, @@ -59,7 +33,6 @@ import { type CliAgentType } from "@/lib/agent-types"; import { useRadixPopoverZFix } from "@/hooks/use-radix-popover-z-fix"; import { swallowEscapeFromCombobox } from "@/lib/dialog-escape"; import { agentRoute } from "@/lib/agent-routes"; -import { cn } from "@/lib/utils"; function ArgInput({ arg, @@ -83,100 +56,6 @@ function ArgInput({ ); } -const CONTEXT_LINK_INPUT_ID = "launch-template-context-link-input"; -const CONTEXT_LINK_ERROR_ID = "launch-template-context-link-error"; - -function LaunchAddContextMenu({ - onAddFile, - onAddLink, -}: { - onAddFile: () => void; - onAddLink: () => void; -}) { - return ( -
- - -
- ); -} - -function LaunchAddContextLinkForm({ - value, - onChange, - onSubmit, - onBack, - isValid, -}: { - value: string; - onChange: (next: string) => void; - onSubmit: () => void; - onBack: () => void; - isValid: boolean; -}) { - return ( -
- - onChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - if (value.trim().length > 0 && isValid) { - onSubmit(); - } - } - }} - placeholder="https://..." - aria-invalid={!isValid} - aria-describedby={!isValid ? CONTEXT_LINK_ERROR_ID : undefined} - /> - {!isValid ? ( - - ) : null} -
- - -
-
- ); -} - export function LaunchTemplateDialog({ template, open, @@ -221,25 +100,10 @@ function LaunchTemplateDialogContent({ const [argValues, setArgValues] = useState>({}); const [agentType, setAgentType] = useState(template.agentType); - // Context / files state const showMedia = template.allowMedia; - const fileInputRef = useRef(null); - const clipboardRequestIdRef = useRef(0); const [startupFiles, setStartupFiles] = useState([]); const [startupLinks, setStartupLinks] = useState([]); - const [linkDraft, setLinkDraft] = useState(""); - const [checkingClipboard, setCheckingClipboard] = useState(false); - const [clipboardPasteMode, setClipboardPasteMode] = useState(false); - const [pasteTooltip, setPasteTooltip] = useState(null); - const pasteTooltipTimerRef = useRef>(); - const clipboardPasteRef = useRef(null); - const [clipboardReadFeedback, setClipboardReadFeedback] = useState< - string | null - >(null); const [draggingFiles, setDraggingFiles] = useState(false); - const [addOpen, setAddOpen] = useState(false); - const [addMode, setAddMode] = useState<"menu" | "link">("menu"); - const startupFilePreviewsRef = useRef>(new Map()); useEffect(() => { @@ -285,80 +149,6 @@ function LaunchTemplateDialogContent({ }); }, []); - const applyClipboardSuggestion = useCallback( - (suggestion: ClipboardSuggestion) => { - switch (suggestion.kind) { - case "image": - case "file": - appendStartupFiles([suggestion.file]); - break; - case "url": - setStartupLinks((current) => - current.includes(suggestion.url) - ? current - : [...current, suggestion.url] - ); - break; - case "text": - break; - } - }, - [appendStartupFiles] - ); - - 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) { - 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]); - - const handleClipboardPasteInput = useCallback( - (event: ClipboardEvent) => { - event.preventDefault(); - const pastedFiles = getClipboardFilesFromEvent(event); - if (pastedFiles.length > 0) { - appendStartupFiles(pastedFiles); - setClipboardPasteMode(false); - return; - } - const textSuggestion = createClipboardSuggestionFromText( - event.clipboardData.getData("text/plain") - ); - if (textSuggestion?.kind === "url") { - applyClipboardSuggestion(textSuggestion); - setClipboardPasteMode(false); - return; - } - clearTimeout(pasteTooltipTimerRef.current); - setPasteTooltip("No files or images found"); - pasteTooltipTimerRef.current = setTimeout( - () => setPasteTooltip(null), - 2500 - ); - }, - [appendStartupFiles, applyClipboardSuggestion] - ); - - const handleClipboardPasteBlur = useCallback(() => { - setClipboardPasteMode(false); - setPasteTooltip(null); - }, []); - const handleRemoveStartupFile = useCallback((fileToRemove: File) => { const key = startupFileKey(fileToRemove); const url = startupFilePreviewsRef.current.get(key); @@ -377,14 +167,11 @@ function LaunchTemplateDialogContent({ ); }, []); - const handleFileChange = useCallback( - (event: ChangeEvent) => { - const selected = Array.from(event.target.files ?? []); - appendStartupFiles(selected); - event.target.value = ""; - }, - [appendStartupFiles] - ); + const handleAddLink = useCallback((url: string) => { + setStartupLinks((current) => + current.includes(url) ? current : [...current, url] + ); + }, []); const handleDrop = useCallback( (event: DragEvent) => { @@ -397,72 +184,6 @@ function LaunchTemplateDialogContent({ [appendStartupFiles] ); - const handlePaste = useCallback( - (event: ClipboardEvent) => { - const pastedFiles = getClipboardFilesFromEvent(event); - if (pastedFiles.length > 0) { - event.preventDefault(); - appendStartupFiles(pastedFiles); - setClipboardReadFeedback(null); - return; - } - - const textSuggestion = createClipboardSuggestionFromText( - event.clipboardData.getData("text/plain") - ); - if (textSuggestion?.kind === "url") { - event.preventDefault(); - applyClipboardSuggestion(textSuggestion); - setClipboardReadFeedback(null); - } - }, - [appendStartupFiles, applyClipboardSuggestion] - ); - - const handleAddOpenChange = useCallback((next: boolean) => { - setAddOpen(next); - if (!next) { - setAddMode("menu"); - setLinkDraft(""); - } - }, []); - - const handleAddFileFromMenu = useCallback(() => { - setAddOpen(false); - setAddMode("menu"); - requestAnimationFrame(() => fileInputRef.current?.click()); - }, []); - - const handleAddLinkFromMenu = useCallback(() => { - setAddMode("link"); - }, []); - - const handleAddLinkBack = useCallback(() => { - setLinkDraft(""); - setAddMode("menu"); - }, []); - - const normalizedLinkDraft = normalizeUrl(linkDraft); - const linkDraftIsValid = - linkDraft.trim().length === 0 || normalizedLinkDraft !== null; - - const addStartupLink = useCallback(() => { - if (!normalizedLinkDraft) return false; - setStartupLinks((current) => - current.includes(normalizedLinkDraft) - ? current - : [...current, normalizedLinkDraft] - ); - setLinkDraft(""); - return true; - }, [normalizedLinkDraft]); - - const handleAddLinkSubmit = useCallback(() => { - if (!addStartupLink()) return; - setAddMode("menu"); - setAddOpen(false); - }, [addStartupLink]); - const allArgsFilled = args.length === 0 || args.every((a) => argValues[a.key]?.trim()); @@ -495,8 +216,6 @@ function LaunchTemplateDialogContent({ template.id, ]); - const hasContextItems = startupFiles.length > 0 || startupLinks.length > 0; - return ( -
-
-
- - Context -
-

- Attach files or links. -

-
- {clipboardPasteMode ? ( -
- - {pasteTooltip ? ( -
- {pasteTooltip} -
- ) : null} -
- ) : ( - - )} -
- {clipboardReadFeedback ? ( -

- {clipboardReadFeedback} -

- ) : null} - - {!hasContextItems ? ( - - - - - - {addMode === "menu" ? ( - - ) : ( - - )} - - - ) : ( -
- {startupFiles.map((file) => { - const key = startupFileKey(file); - const preview = startupFilePreviewsRef.current.get(key); - return ( -
-
- {preview ? ( - - ) : ( -
- - - {startupFileExt(file.name)} - -
- )} - -
- - {file.name} - -
- ); - })} - {startupLinks.map((link) => { - const { host, rest } = startupLinkLabel(link); - return ( -
-
- - {host} -
- {rest ? ( - - {rest} - - ) : null} - -
- ); - })} - - - - - - {addMode === "menu" ? ( - - ) : ( - - )} - - -
- )} - + ) : null}
diff --git a/apps/web/src/components/app/context-picker.tsx b/apps/web/src/components/app/context-picker.tsx new file mode 100644 index 00000000..a8d35748 --- /dev/null +++ b/apps/web/src/components/app/context-picker.tsx @@ -0,0 +1,609 @@ +import { + type ChangeEvent, + type ClipboardEvent, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; +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 { ActivityBars } from "@/components/ui/activity-bars"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +function AddContextMenu({ + onAddFile, + onAddLink, +}: { + onAddFile: () => void; + onAddLink: () => void; +}) { + return ( +
+ + +
+ ); +} + +function AddContextLinkForm({ + value, + onChange, + onSubmit, + onBack, + isValid, + inputId, + errorId, + testIdPrefix, +}: { + value: string; + onChange: (next: string) => void; + onSubmit: () => void; + onBack: () => void; + isValid: boolean; + inputId: string; + errorId: string; + testIdPrefix?: string; +}) { + return ( +
+ + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (value.trim().length > 0 && isValid) { + onSubmit(); + } + } + }} + placeholder="https://..." + aria-invalid={!isValid} + aria-describedby={!isValid ? errorId : undefined} + {...(testIdPrefix + ? { "data-testid": `${testIdPrefix}-link-input` } + : {})} + /> + {!isValid ? ( +

+ Enter a valid `http:` or `https:` URL. +

+ ) : null} +
+ + +
+
+ ); +} + +export type ContextPickerProps = { + files: File[]; + links: string[]; + draggingFiles: boolean; + filePreviewsRef: React.MutableRefObject>; + onAppendFiles: (files: File[]) => void; + onRemoveFile: (file: File) => void; + onAddLink: (normalizedUrl: string) => void; + onRemoveLink: (link: string) => void; + onClipboardText?: (text: string) => void; + onDraftInvalid?: (hasInvalidDraft: boolean) => void; + description?: string; + className?: string; + testIdPrefix?: string; +}; + +export function ContextPicker({ + files, + links, + draggingFiles, + filePreviewsRef, + onAppendFiles, + onRemoveFile, + onAddLink, + onRemoveLink, + onClipboardText, + onDraftInvalid, + description = "Attach files or links.", + className, + testIdPrefix, +}: ContextPickerProps) { + const autoId = useId(); + const linkInputId = testIdPrefix + ? `${testIdPrefix}-link-input` + : `${autoId}-link-input`; + const linkErrorId = testIdPrefix + ? `${testIdPrefix}-link-error` + : `${autoId}-link-error`; + + 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 handleClipboardPasteBlur = useCallback(() => { + setClipboardPasteMode(false); + setPasteTooltip(null); + }, []); + + const handleFileChange = useCallback( + (event: ChangeEvent) => { + const selected = Array.from(event.target.files ?? []); + onAppendFiles(selected); + event.target.value = ""; + }, + [onAppendFiles] + ); + + const handlePaste = useCallback( + (event: 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); + } + }, + [onAppendFiles, applyClipboardSuggestion] + ); + + const handleAddOpenChange = useCallback((next: boolean) => { + setAddOpen(next); + if (!next) { + setAddMode("menu"); + setLinkDraft(""); + } + }, []); + + const handleAddFileFromMenu = useCallback(() => { + setAddOpen(false); + setAddMode("menu"); + requestAnimationFrame(() => fileInputRef.current?.click()); + }, []); + + const handleAddLinkFromMenu = useCallback(() => { + setAddMode("link"); + }, []); + + const handleAddLinkBack = useCallback(() => { + setLinkDraft(""); + setAddMode("menu"); + }, []); + + const normalizedLinkDraft = normalizeUrl(linkDraft); + const linkDraftIsValid = + linkDraft.trim().length === 0 || normalizedLinkDraft !== null; + + useEffect(() => { + onDraftInvalid?.(linkDraft.trim().length > 0 && !linkDraftIsValid); + }, [linkDraft, linkDraftIsValid, onDraftInvalid]); + + const handleAddLinkSubmit = useCallback(() => { + if (!normalizedLinkDraft) return; + onAddLink(normalizedLinkDraft); + setLinkDraft(""); + setAddMode("menu"); + setAddOpen(false); + }, [normalizedLinkDraft, onAddLink]); + + return ( +
+
+
+
+ + Context +
+

{description}

+
+ {clipboardPasteMode ? ( +
+ + {pasteTooltip ? ( +
+ {pasteTooltip} +
+ ) : null} +
+ ) : ( + + )} +
+ {clipboardReadFeedback ? ( +

+ {clipboardReadFeedback} +

+ ) : null} + + {!hasContextItems ? ( + + + + + + {addMode === "menu" ? ( + + ) : ( + + )} + + + ) : ( +
+ {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} + +
+ ); + })} + + + + + + {addMode === "menu" ? ( + + ) : ( + + )} + + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/app/create-agent-dialog.tsx b/apps/web/src/components/app/create-agent-dialog.tsx index 872c5041..f59d62ed 100644 --- a/apps/web/src/components/app/create-agent-dialog.tsx +++ b/apps/web/src/components/app/create-agent-dialog.tsx @@ -1,5 +1,4 @@ import { - type ChangeEvent, type ClipboardEvent, type DragEvent, type FormEvent, @@ -10,20 +9,15 @@ import { useState, } from "react"; import { useAtom } from "jotai"; -import { - Check, - ChevronDown, - Clipboard, - ChevronLeft, - FileText, - GitBranch, - Link2, - Plus, - Upload, - X, -} from "lucide-react"; +import { Check, ChevronDown, ChevronLeft, GitBranch } from "lucide-react"; import { BranchSelect } from "@/components/app/branch-select"; +import { ContextPicker } from "@/components/app/context-picker"; +import { + createClipboardSuggestionFromText, + getClipboardFilesFromEvent, + startupFileKey, +} from "@/components/app/create-agent-dialog-clipboard"; import { PathInput } from "@/components/app/path-input"; import { ActivityBars } from "@/components/ui/activity-bars"; import { Button } from "@/components/ui/button"; @@ -42,22 +36,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { - type ClipboardSuggestion, - STARTUP_FILE_ACCEPT, - createClipboardSuggestionFromText, - getClipboardFilesFromEvent, - getClipboardSuggestion, - normalizeUrl, - startupFileExt, - startupFileKey, - startupLinkLabel, -} from "@/components/app/create-agent-dialog-clipboard"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { type Agent } from "@/components/app/types"; import { useClickOutside } from "@/hooks/use-click-outside"; import { useRadixPopoverZFix } from "@/hooks/use-radix-popover-z-fix"; @@ -79,105 +57,6 @@ const FULL_ACCESS_PREFIX = "dispatch:fullAccess:"; const AUTO_REVIEW_PREFIX = "dispatch:autoReview:"; const BASE_BRANCH_PREFIX = "dispatch:baseBranch:"; const CONTEXT_PROMPT_ID = "create-agent-context-prompt"; -const CONTEXT_LINK_INPUT_ID = "create-agent-context-link-input"; -const CONTEXT_LINK_ERROR_ID = "create-agent-context-link-error"; - -function AddContextMenu({ - onAddFile, - onAddLink, -}: { - onAddFile: () => void; - onAddLink: () => void; -}) { - return ( -
- - -
- ); -} - -function AddContextLinkForm({ - value, - onChange, - onSubmit, - onBack, - isValid, -}: { - value: string; - onChange: (next: string) => void; - onSubmit: () => void; - onBack: () => void; - isValid: boolean; -}) { - return ( -
- - onChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - if (value.trim().length > 0 && isValid) { - onSubmit(); - } - } - }} - placeholder="https://..." - data-testid="create-agent-context-link-input" - aria-invalid={!isValid} - aria-describedby={!isValid ? CONTEXT_LINK_ERROR_ID : undefined} - /> - {!isValid ? ( - - ) : null} -
- - -
-
- ); -} function readStoredString(key: string): string { if (typeof window === "undefined") return ""; @@ -344,8 +223,6 @@ function CreateAgentDialogContent({ }: Omit): JSX.Element { const [step, setStep] = useState<"config" | "context">("config"); const promptTextareaRef = useRef(null); - const startupFileInputRef = useRef(null); - const clipboardRequestIdRef = useRef(0); const [createName, setCreateName] = useState(""); const [createType, setCreateType] = useState(() => { const preferred = initialAgentType ?? readLastUsedAgentType(); @@ -367,12 +244,8 @@ function CreateAgentDialogContent({ const [initialPrompt, setInitialPrompt] = useState(""); const [startupFiles, setStartupFiles] = useState([]); const [startupLinks, setStartupLinks] = useState([]); - const [linkDraft, setLinkDraft] = useState(""); - const [checkingClipboard, setCheckingClipboard] = useState(false); - const [clipboardReadFeedback, setClipboardReadFeedback] = useState< - string | null - >(null); const [draggingFiles, setDraggingFiles] = useState(false); + const [contextDraftInvalid, setContextDraftInvalid] = useState(false); const [creating, setCreating] = useState(false); const [cwdHistory, setCwdHistory] = useState(() => readCwdHistory() @@ -428,9 +301,6 @@ function CreateAgentDialogContent({ useEffect(() => { if (step !== "context") { - clipboardRequestIdRef.current += 1; - setCheckingClipboard(false); - setClipboardReadFeedback(null); setDraggingFiles(false); } }, [step]); @@ -480,37 +350,11 @@ function CreateAgentDialogContent({ }); }, []); - const applyClipboardSuggestion = useCallback( - (suggestion: ClipboardSuggestion) => { - switch (suggestion.kind) { - case "image": - case "file": - appendStartupFiles([suggestion.file]); - break; - case "url": - setStartupLinks((current) => - current.includes(suggestion.url) - ? current - : [...current, suggestion.url] - ); - break; - case "text": { - const suggestionText = suggestion.text; - // Append when the user has typed real content; treat whitespace-only - // as empty so we don't leave leading blank lines for someone who - // just clicked into the textarea before reading the clipboard. - setInitialPrompt((current) => - current.trim().length === 0 - ? suggestionText - : `${current.trimEnd()}\n\n${suggestionText}` - ); - requestAnimationFrame(() => promptTextareaRef.current?.focus()); - break; - } - } - }, - [appendStartupFiles] - ); + const handleAddLink = useCallback((url: string) => { + setStartupLinks((current) => + current.includes(url) ? current : [...current, url] + ); + }, []); const handleStartupPaste = useCallback( (event: ClipboardEvent) => { @@ -523,25 +367,18 @@ function CreateAgentDialogContent({ if (pastedFiles.length > 0) { event.preventDefault(); appendStartupFiles(pastedFiles); - setClipboardReadFeedback(null); return; } const textSuggestion = createClipboardSuggestionFromText( event.clipboardData.getData("text/plain") ); - - // Plain text falls through to native paste so the prompt textarea - // gets the typed content. Only intercept URLs — the user almost - // always means "add this as a context link," not "paste this URL - // into the prompt as text." if (textSuggestion?.kind === "url") { event.preventDefault(); - applyClipboardSuggestion(textSuggestion); - setClipboardReadFeedback(null); + handleAddLink(textSuggestion.url); } }, - [appendStartupFiles, applyClipboardSuggestion] + [appendStartupFiles, handleAddLink] ); const handleStartupDrop = useCallback( @@ -555,15 +392,6 @@ function CreateAgentDialogContent({ [appendStartupFiles] ); - const handleStartupFileChange = useCallback( - (event: ChangeEvent) => { - const selected = Array.from(event.target.files ?? []); - appendStartupFiles(selected); - event.target.value = ""; - }, - [appendStartupFiles] - ); - const handleRemoveStartupFile = useCallback((fileToRemove: File) => { const key = startupFileKey(fileToRemove); const url = startupFilePreviewsRef.current.get(key); @@ -592,82 +420,17 @@ function CreateAgentDialogContent({ ); }, []); - const enterContextStep = useCallback(() => { - setStep("context"); - setCheckingClipboard(false); - setClipboardReadFeedback(null); - }, []); - - 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) { - applyClipboardSuggestion(result.suggestion); - return; - } - setClipboardReadFeedback( - result.status === "blocked" - ? "Clipboard access was blocked. Paste into Instructions instead." - : result.status === "unsupported" - ? "Clipboard access isn't available here. Paste into Instructions instead." - : "Nothing readable found. Try pasting into Instructions instead." - ); - }); - }, [applyClipboardSuggestion]); - - const normalizedLinkDraft = normalizeUrl(linkDraft); - const linkDraftIsValid = - linkDraft.trim().length === 0 || normalizedLinkDraft !== null; - - const addStartupLink = useCallback(() => { - if (!normalizedLinkDraft) return false; - setStartupLinks((current) => - current.includes(normalizedLinkDraft) - ? current - : [...current, normalizedLinkDraft] + const handleClipboardText = useCallback((text: string) => { + setInitialPrompt((current) => + current.trim().length === 0 ? text : `${current.trimEnd()}\n\n${text}` ); - setLinkDraft(""); - return true; - }, [normalizedLinkDraft]); - - const [addOpen, setAddOpen] = useState(false); - const [addMode, setAddMode] = useState<"menu" | "link">("menu"); - - const handleAddOpenChange = useCallback((next: boolean) => { - setAddOpen(next); - if (!next) { - setAddMode("menu"); - setLinkDraft(""); - } + requestAnimationFrame(() => promptTextareaRef.current?.focus()); }, []); - const handleAddFileFromMenu = useCallback(() => { - setAddOpen(false); - setAddMode("menu"); - // Defer the file picker click so the popover unmounts cleanly first. - requestAnimationFrame(() => startupFileInputRef.current?.click()); - }, []); - - const handleAddLinkFromMenu = useCallback(() => { - setAddMode("link"); - }, []); - - const handleAddLinkBack = useCallback(() => { - setLinkDraft(""); - setAddMode("menu"); + const enterContextStep = useCallback(() => { + setStep("context"); }, []); - const handleAddLinkSubmit = useCallback(() => { - if (!addStartupLink()) return; - setAddMode("menu"); - setAddOpen(false); - }, [addStartupLink]); - useRadixPopoverZFix(); const handleSubmit = useCallback( @@ -675,7 +438,6 @@ function CreateAgentDialogContent({ event.preventDefault(); const cwd = createCwd.trim(); if (!cwd) return; - if (step === "context" && !linkDraftIsValid) return; setCreating(true); try { @@ -754,7 +516,6 @@ function CreateAgentDialogContent({ createUseWorktree, createWorktreeBranch, initialPrompt, - linkDraftIsValid, onCreated, startupFiles, startupLinks, @@ -1145,37 +906,6 @@ function CreateAgentDialogContent({ )} >
-
- - {clipboardReadFeedback ? ( -

- {clipboardReadFeedback} -

- ) : null} -
-
-
-
-
- Context -
-

- Attach files or links to give the agent context. -

-
- - {startupFiles.length === 0 && startupLinks.length === 0 ? ( - - - - - - {addMode === "menu" ? ( - - ) : ( - - )} - - - ) : ( -
- {startupFiles.map((file) => { - const key = startupFileKey(file); - const preview = startupFilePreviewsRef.current.get(key); - return ( -
-
- {preview ? ( - - ) : ( -
- - - {startupFileExt(file.name)} - -
- )} - -
- - {file.name} - -
- ); - })} - {startupLinks.map((link) => { - const { host, rest } = startupLinkLabel(link); - return ( -
-
- - - {host} - -
- {rest ? ( - - {rest} - - ) : null} - -
- ); - })} - - - - - - {addMode === "menu" ? ( - - ) : ( - - )} - - -
- )} -
+
@@ -1428,7 +972,7 @@ function CreateAgentDialogContent({ variant="primary" tabIndex={0} className="min-h-11 px-3" - disabled={creating || !linkDraftIsValid} + disabled={creating || contextDraftInvalid} data-testid="create-agent-context-submit" > {creating ? ( diff --git a/e2e/terminal-agent-type.spec.ts b/e2e/terminal-agent-type.spec.ts index be476201..0d1a0420 100644 --- a/e2e/terminal-agent-type.spec.ts +++ b/e2e/terminal-agent-type.spec.ts @@ -264,9 +264,7 @@ test.describe("Terminal agent type", () => { await expect( page.locator('[title="https://example.com/docs/launch-context"]') ).not.toBeVisible(); - await page - .getByTestId("create-agent-context-clipboard-check-action") - .click(); + await page.getByTestId("create-agent-context-clipboard-action").click(); await expect( page.locator('[title="https://example.com/docs/launch-context"]') @@ -285,9 +283,7 @@ test.describe("Terminal agent type", () => { await page.getByTestId("create-agent-button").click(); await page.getByTestId("create-agent-with-context").click(); - await page - .getByTestId("create-agent-context-clipboard-check-action") - .click(); + await page.getByTestId("create-agent-context-clipboard-action").click(); await expect( page.locator('[title="https://example.com/rich-link"]') @@ -330,16 +326,14 @@ test.describe("Terminal agent type", () => { await page.getByTestId("create-agent-button").click(); await page.getByTestId("create-agent-with-context").click(); - await expect( - page.getByTestId("create-agent-context-clipboard-check") - ).toBeVisible(); + await expect(page.getByTestId("create-agent-context")).toBeVisible(); const readClipboard = page.getByTestId( - "create-agent-context-clipboard-check-action" + "create-agent-context-clipboard-action" ); await readClipboard.click(); await expect( page.getByTestId("create-agent-context-clipboard-feedback") - ).toContainText("Nothing readable found."); + ).toContainText("Nothing readable found on the clipboard."); await readClipboard.click(); await expect( @@ -347,7 +341,7 @@ test.describe("Terminal agent type", () => { ).toBeVisible(); }); - test("create with context shows feedback when clipboard access is blocked", async ({ + test("create with context enters paste mode when clipboard access is blocked", async ({ page, }) => { await stubClipboard(page, { kind: "blocked" }); @@ -357,18 +351,15 @@ test.describe("Terminal agent type", () => { await page.getByTestId("create-agent-with-context").click(); const readButton = page.getByTestId( - "create-agent-context-clipboard-check-action" + "create-agent-context-clipboard-action" ); await expect(readButton).toBeVisible(); await readButton.click(); - await expect(readButton).toBeVisible(); - await expect( - page.getByTestId("create-agent-context-clipboard-feedback") - ).toContainText("Clipboard access was blocked."); + await expect(page.getByPlaceholder("Paste here")).toBeVisible(); }); - test("create with context shows the read button even when clipboard APIs are unavailable", async ({ + test("create with context enters paste mode when clipboard APIs are unavailable", async ({ page, }) => { await stubClipboard(page, { kind: "unsupported" }); @@ -378,14 +369,12 @@ test.describe("Terminal agent type", () => { await page.getByTestId("create-agent-with-context").click(); const readButton = page.getByTestId( - "create-agent-context-clipboard-check-action" + "create-agent-context-clipboard-action" ); await expect(readButton).toBeVisible(); await readButton.click(); - await expect( - page.getByTestId("create-agent-context-clipboard-feedback") - ).toContainText("Clipboard access isn't available here."); + await expect(page.getByPlaceholder("Paste here")).toBeVisible(); }); test("create with context auto-adds pasted URLs as link tiles in the prompt textarea", async ({ @@ -483,9 +472,7 @@ test.describe("Terminal agent type", () => { const prompt = page.getByTestId("create-agent-initial-prompt"); await prompt.fill(" \n\n "); - await page - .getByTestId("create-agent-context-clipboard-check-action") - .click(); + await page.getByTestId("create-agent-context-clipboard-action").click(); await expect(prompt).toHaveValue("Real instructions from clipboard."); }); @@ -502,9 +489,7 @@ test.describe("Terminal agent type", () => { await page.getByTestId("create-agent-button").click(); await page.getByTestId("create-agent-with-context").click(); - await page - .getByTestId("create-agent-context-clipboard-check-action") - .click(); + await page.getByTestId("create-agent-context-clipboard-action").click(); await expect(page.getByText("clipboard-image.png")).toBeVisible(); });