Files
kandev/apps/web/components/task/session-dialog-shared.tsx
T
José Almeida 85820e4a43 fix(web): warn when pasted attachments are too large (#1983)
* fix(web): warn when pasted attachment is too large

* test(web): provide toast context to chat input tests

* fix(web): warn when browser image paste lacks file data

* fix(web): warn about oversized new task attachments

* fix(web): keep toasts above modal overlays

* fix(web): handle file-list-only dialog pastes

* fix(web): avoid parsing clipboard HTML

* fix(web): handle attachment paste edge cases

* fix(web): improve attachment limit feedback

* fix(web): show aggregate attachment limit feedback
2026-07-27 17:19:02 +01:00

325 lines
10 KiB
TypeScript

"use client";
import { useCallback, 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 {
processFile,
formatBytes,
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";
export function EnvironmentBadges({
executorLabel,
worktreeBranch,
description,
}: {
executorLabel: string | null;
worktreeBranch: string | null;
description?: string;
}) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{executorLabel && (
<Badge variant="secondary" className="text-xs font-normal">
{executorLabel}
</Badge>
)}
{worktreeBranch && (
<Badge variant="outline" className="min-w-0 max-w-full gap-1 text-xs font-normal">
<IconGitBranch className="h-3 w-3" />
<span className="min-w-0 truncate">{worktreeBranch}</span>
</Badge>
)}
<span className="min-w-0 break-words">
{description ?? "Same environment as current session"}
</span>
</div>
);
}
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<void>) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []);
const handleFileInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Context</label>
<div className="flex min-w-0 items-center gap-2">
<Select value={value} onValueChange={onValueChange} disabled={isSummarizing}>
<SelectTrigger className="w-full min-w-0 text-xs">
<SelectValue>{isSummarizing ? "Summarizing..." : displayLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="blank" className="text-xs cursor-pointer">
Blank
</SelectItem>
<SelectItem
value="copy_prompt"
disabled={!hasInitialPrompt}
className="text-xs cursor-pointer"
>
Copy initial prompt
</SelectItem>
{sessionOptions.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[11px] text-muted-foreground/70">
Summarize session
</SelectLabel>
{sessionOptions.map((opt) => (
<SelectItem
key={opt.id}
value={`summarize:${opt.id}`}
className="text-xs cursor-pointer"
>
<span className="inline-flex items-center gap-1.5">
{opt.index != null && (
<span className="text-[10px] font-medium leading-none text-muted-foreground bg-foreground/10 rounded px-1 py-0.5">
{opt.index}
</span>
)}
{opt.agentName && (
<AgentLogo agentName={opt.agentName} size={14} className="shrink-0" />
)}
{opt.label}
</span>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
</div>
);
}
export function useDialogAttachments(disabled: boolean) {
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [isDragging, setIsDragging] = useState(false);
const warnAttachmentCountLimit = useAttachmentCountFeedback();
const rejectOversizedFile = useAttachmentFileFeedback();
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
const attachmentsRef = useRef<FileAttachment[]>([]);
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,
);
attachmentsRef.current = next;
setAttachments(next);
if (rejection === "count") warnAttachmentCountLimit();
if (rejection === "total-size") warnAttachmentTotalSizeLimit();
},
[rejectOversizedFile, warnAttachmentCountLimit, warnAttachmentTotalSizeLimit],
);
const { fileInputRef, handleAttachClick, handleFileInputChange } = useDialogFileInput(addFiles);
const handleRemoveAttachment = useCallback((id: string) => {
const next = attachmentsRef.current.filter((attachment) => attachment.id !== id);
attachmentsRef.current = next;
setAttachments(next);
}, []);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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,
handlePaste,
handleDragOver,
handleDragLeave,
handleDrop,
handleAttachClick,
handleFileInputChange,
};
}
export function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) {
return (
<div className="flex items-center px-1 pb-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Attach files"
className={`h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted/40 hover:text-foreground ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
onClick={onClick}
disabled={disabled}
>
<IconPaperclip className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</div>
);
}
export function toContextItems(
attachments: FileAttachment[],
onRemove: (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),
} as ImageContextItem)
: ({
kind: "file-attachment" as const,
id: `file:${att.id}`,
label: att.fileName,
attachment: att,
onRemove: () => onRemove(att.id),
} as FileAttachmentContextItem),
);
}