Files
kandev/apps/web/components/task/session-dialog-shared.tsx
T
João Salavisa b082d5e1b9 refactor(web): use the shared formatBytes in chat attachments (#2071)
Delete the two byte-identical local formatBytes copies from the chat-attachment
modules and repoint all call sites at the canonical lib/utils/format-bytes.ts,
which its own doc comment asks new code to use.

The copies had no upper clamp (>= 1 TB rendered "1 undefined") and no NaN or
negative guard (NaN rendered "NaN undefined"). Size labels now carry one
decimal ("10.0 MB" instead of "10 MB") because the canonical helper does not
strip a trailing zero; unit and e2e expectations updated to match.
2026-07-30 22:40:50 +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 { 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";
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),
);
}