"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Badge } from "@kandev/ui/badge"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@kandev/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconGitBranch, IconPaperclip } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; import { formatBytes } from "@/lib/utils/format-bytes"; import { processFile, MAX_FILES, MAX_TOTAL_SIZE, type FileAttachment, } from "./chat/file-attachment"; import { readClipboardAttachments } from "./chat/clipboard-attachments"; import { useAttachmentCountFeedback, useAttachmentFileFeedback, useAttachmentTotalSizeFeedback, useUnreadablePastedImageFeedback, } from "./chat/use-attachment-file-feedback"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api"; import { ApiError } from "@/lib/api/client"; export function EnvironmentBadges({ executorLabel, worktreeBranch, description, }: { executorLabel: string | null; worktreeBranch: string | null; description?: string; }) { return (
{executorLabel && ( {executorLabel} )} {worktreeBranch && ( {worktreeBranch} )} {description ?? "Same environment as current session"}
); } export type SessionOption = { id: string; label: string; index?: number; agentName?: string }; type AttachmentLimitRejection = "count" | "total-size" | null; function appendAttachmentsWithinLimits( current: FileAttachment[], processed: FileAttachment[], ): { attachments: FileAttachment[]; rejection: AttachmentLimitRejection } { let count = current.length; let totalSize = current.reduce((sum, attachment) => sum + attachment.size, 0); const accepted: FileAttachment[] = []; for (const attachment of processed) { if (count >= MAX_FILES) return { attachments: [...current, ...accepted], rejection: "count" }; if (totalSize + attachment.size > MAX_TOTAL_SIZE) { return { attachments: [...current, ...accepted], rejection: "total-size" }; } accepted.push(attachment); count += 1; totalSize += attachment.size; } return { attachments: accepted.length > 0 ? [...current, ...accepted] : current, rejection: null, }; } function useDialogFileInput(addFiles: (files: File[]) => Promise) { const fileInputRef = useRef(null); const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []); const handleFileInputChange = useCallback( (event: React.ChangeEvent) => { const files = event.target.files; if (files && files.length > 0) void addFiles(Array.from(files)); event.target.value = ""; }, [addFiles], ); return { fileInputRef, handleAttachClick, handleFileInputChange }; } /** Unified context selector: Blank, Copy prompt, and per-session summarize options. */ export function ContextSelect({ value, onValueChange, hasInitialPrompt, sessionOptions, isSummarizing, }: { value: string; onValueChange: (v: string) => void; hasInitialPrompt: boolean; sessionOptions: SessionOption[]; isSummarizing: boolean; }) { const displayLabel = useMemo(() => { if (value === "blank") return "Blank"; if (value === "copy_prompt") return "Copy initial prompt"; if (value.startsWith("summarize:")) { const sid = value.slice("summarize:".length); const opt = sessionOptions.find((o) => o.id === sid); return opt ? `Summarize ${opt.label}` : "Summarize"; } return "Blank"; }, [value, sessionOptions]); return (
); } // eslint-disable-next-line max-lines-per-function export function useDialogAttachments(disabled: boolean, workspaceId?: string | null) { const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); const warnAttachmentCountLimit = useAttachmentCountFeedback(); const rejectOversizedFile = useAttachmentFileFeedback(); const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback(); const warnUnreadablePastedImage = useUnreadablePastedImageFeedback(); const attachmentsRef = useRef([]); const updateAttachment = useCallback((id: string, update: Partial) => { setAttachments((prev) => { const next = prev.map((attachment) => attachment.id === id ? { ...attachment, ...update } : attachment, ); attachmentsRef.current = next; return next; }); }, []); const uploadPendingAttachment = useCallback( async (attachment: FileAttachment) => { if (!workspaceId || !attachment.file || attachment.attachmentId) return; updateAttachment(attachment.id, { uploadStatus: "uploading" }); try { const uploaded = await uploadAttachment(attachment.file, { workspaceId, kind: attachment.isImage ? "image" : "resource", deliveryMode: attachment.deliveryMode, }); if (!attachmentsRef.current.some((current) => current.id === attachment.id)) { void deleteAttachment(uploaded.attachment_id).catch(() => undefined); return; } updateAttachment(attachment.id, { attachmentId: uploaded.attachment_id, uploadStatus: "ready", size: uploaded.size_bytes, }); } catch (error) { updateAttachment(attachment.id, { uploadStatus: "failed", uploadError: error instanceof ApiError ? error.message : "Upload failed", }); } }, [updateAttachment, workspaceId], ); useEffect(() => { if (!workspaceId) return; for (const attachment of attachmentsRef.current) { if (attachment.file && !attachment.attachmentId && attachment.uploadStatus !== "uploading") { void uploadPendingAttachment(attachment); } } }, [uploadPendingAttachment, workspaceId]); const addFiles = useCallback( async (files: File[]) => { const processed: FileAttachment[] = []; for (const file of files) { if (rejectOversizedFile(file)) continue; const attachment = await processFile(file); if (attachment) processed.push(attachment); } if (processed.length === 0) return; const { attachments: next, rejection } = appendAttachmentsWithinLimits( attachmentsRef.current, processed, ); const added = next.slice(attachmentsRef.current.length); attachmentsRef.current = next; setAttachments(next); for (const attachment of added) { void uploadPendingAttachment(attachment); } if (rejection === "count") warnAttachmentCountLimit(); if (rejection === "total-size") warnAttachmentTotalSizeLimit(); }, [ rejectOversizedFile, warnAttachmentCountLimit, warnAttachmentTotalSizeLimit, uploadPendingAttachment, ], ); const { fileInputRef, handleAttachClick, handleFileInputChange } = useDialogFileInput(addFiles); const handleRemoveAttachment = useCallback((id: string) => { const removed = attachmentsRef.current.find((attachment) => attachment.id === id); const next = attachmentsRef.current.filter((attachment) => attachment.id !== id); attachmentsRef.current = next; setAttachments(next); if (removed?.attachmentId) void deleteAttachment(removed.attachmentId).catch(() => undefined); }, []); const handleRetryAttachment = useCallback( (id: string) => { const attachment = attachmentsRef.current.find((item) => item.id === id); if (attachment) void uploadPendingAttachment(attachment); }, [uploadPendingAttachment], ); const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (disabled) return; const { files, issue } = readClipboardAttachments(e.clipboardData); if (files.length > 0) { e.preventDefault(); void addFiles(files); } else if (issue === "unreadable-image") { e.preventDefault(); warnUnreadablePastedImage(); } }, [disabled, addFiles, warnUnreadablePastedImage], ); const handleDragOver = useCallback( (e: React.DragEvent) => { if (disabled) return; e.preventDefault(); e.stopPropagation(); setIsDragging(true); }, [disabled], ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const { clientX, clientY } = e; if ( clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom ) { setIsDragging(false); } }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (disabled) return; const files = Array.from(e.dataTransfer.files).filter((f) => f.size > 0 || f.type !== ""); if (files.length > 0) void addFiles(files); }, [disabled, addFiles], ); return { attachments, isDragging, fileInputRef, handleRemoveAttachment, handleRetryAttachment, handlePaste, handleDragOver, handleDragLeave, handleDrop, handleAttachClick, handleFileInputChange, }; } export function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) { return (
Attach files
); } export function toContextItems( attachments: FileAttachment[], onRemove: (id: string) => void, onRetry?: (id: string) => void, ): ContextItem[] { return attachments.map((att) => att.isImage ? ({ kind: "image" as const, id: `image:${att.id}`, label: `Image (${formatBytes(att.size)})`, attachment: att, onRemove: () => onRemove(att.id), onRetry: onRetry ? () => onRetry(att.id) : undefined, } as ImageContextItem) : ({ kind: "file-attachment" as const, id: `file:${att.id}`, label: att.fileName, attachment: att, onRemove: () => onRemove(att.id), onRetry: onRetry ? () => onRetry(att.id) : undefined, } as FileAttachmentContextItem), ); }