From df0b897f8f42262c14966520b97db6b3c6ee3be7 Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Tue, 30 Sep 2025 19:24:47 +0100 Subject: [PATCH 001/160] update UI components --- .../ChatBox/BottomBox/BoxAction.tsx | 57 ++ .../ChatBox/BottomBox/BoxHeader.tsx | 338 +++++++++++ src/components/ChatBox/BottomBox/InputBox.tsx | 423 ++++++++++++++ src/components/ChatBox/BottomBox/index.tsx | 122 ++++ src/components/ChatBox/BottomInput.tsx | 547 ------------------ src/components/ChatBox/FloatingAction.tsx | 98 ++++ src/components/ChatBox/index.tsx | 319 ++++++++-- src/components/TopBar/index.tsx | 162 ++++-- src/components/ui/button.tsx | 11 +- src/components/ui/textarea.tsx | 32 +- src/store/chatStore.ts | 51 +- src/style/index.css | 40 +- src/style/token.css | 5 +- tailwind.config.js | 5 + 14 files changed, 1562 insertions(+), 648 deletions(-) create mode 100644 src/components/ChatBox/BottomBox/BoxAction.tsx create mode 100644 src/components/ChatBox/BottomBox/BoxHeader.tsx create mode 100644 src/components/ChatBox/BottomBox/InputBox.tsx create mode 100644 src/components/ChatBox/BottomBox/index.tsx delete mode 100644 src/components/ChatBox/BottomInput.tsx create mode 100644 src/components/ChatBox/FloatingAction.tsx diff --git a/src/components/ChatBox/BottomBox/BoxAction.tsx b/src/components/ChatBox/BottomBox/BoxAction.tsx new file mode 100644 index 000000000..23dbe4831 --- /dev/null +++ b/src/components/ChatBox/BottomBox/BoxAction.tsx @@ -0,0 +1,57 @@ +import { Button } from "@/components/ui/button"; +import { Tag } from "@/components/ui/tag"; +import { CirclePlay, CirclePause, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface BoxActionProps { + /** Token count to display */ + tokens: number; + /** Whether replay is allowed (e.g., only when task finished) */ + disabled?: boolean; + /** Loading state for replay action */ + loading?: boolean; + /** Callback when replay button is clicked */ + onReplay?: () => void; + /** Optional right-side content to replace replay */ + rightContent?: React.ReactNode; + /** Task status for determining what button to show */ + status?: 'running' | 'finished' | 'pending' | 'pause'; + /** Task time display */ + taskTime?: string; + /** Callback for pause/resume */ + onPauseResume?: () => void; + /** Loading state for pause/resume */ + pauseResumeLoading?: boolean; + className?: string; +} + +export function BoxAction({ + tokens, + disabled = false, + loading = false, + onReplay, + rightContent, + status, + taskTime, + onPauseResume, + pauseResumeLoading = false, + className, +}: BoxActionProps) { + const { t } = useTranslation(); + + return ( +
+
+ # {t("chat.token")} {tokens || 0}
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/ChatBox/BottomBox/BoxHeader.tsx b/src/components/ChatBox/BottomBox/BoxHeader.tsx new file mode 100644 index 000000000..54531f975 --- /dev/null +++ b/src/components/ChatBox/BottomBox/BoxHeader.tsx @@ -0,0 +1,338 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ChevronUp, ChevronDown, ChevronLeft, Circle, X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Queued message item + */ +export interface QueuedMessage { + id: string; + content: string; + timestamp?: number; +} + +/** + * Subtask item for confirmation + */ +export interface SubTask { + id: string; + content: string; + status?: "pending" | "confirmed" | "rejected"; +} + +/** + * BoxHeader State Types + */ +export type BoxHeaderState = "empty" | "queuing" | "splitting" | "confirm"; + +/** + * BoxHeader Props + */ +export interface BoxHeaderProps { + /** Current state of the header */ + state: BoxHeaderState; + /** Queued messages (for queuing state) */ + queuedMessages?: QueuedMessage[]; + /** Subtasks (for confirm state) */ + subTasks?: SubTask[]; + /** Title text (not used, auto-generated based on state) */ + title?: string; + /** Subtitle/description text (shown in confirm state only) */ + subtitle?: string; + /** Callback when start task button is clicked (confirm state only) */ + onStartTask?: () => void; + /** Callback when back/edit button is clicked (confirm state, triggered by lead button) */ + onEdit?: () => void; + /** Callback when a queued message is removed */ + onRemoveQueuedMessage?: (id: string) => void; + /** Callback when a subtask is removed */ + onRemoveSubTask?: (id: string) => void; + /** Additional CSS classes */ + className?: string; +} + +/** + * BoxHeader Component + * + * A multi-state header component for the chat box bottom area with four states: + * + * - **Empty**: Hidden state (returns null) + * - **Queuing**: Shows queued messages when a task is running and user inputs are queued + * - Header Top: Lead button (expand/collapse) + Title + * - **Splitting**: Shows task splitting progress + * - Header Top: Lead button (expand/collapse) + Title + * - **Confirm**: Shows subtasks for confirmation before starting + * - Header Top: Lead button (back/edit with chevron left) + Subtitle + Start Task button + * + * Structure: + * - Header Top: Lead button, title/subtitle, action button (confirm state only) + * - Header Content: Accordion item list (queued messages or subtasks) + * + * @example + * ```tsx + * // Queuing State + * + * + * // Confirm State + * console.log("Confirmed")} + * onEdit={() => console.log("Back to edit")} + * /> + * ``` + */ +export const BoxHeader = ({ + state, + queuedMessages = [], + subTasks = [], + title, + subtitle, + onStartTask, + onEdit, + onRemoveQueuedMessage, + onRemoveSubTask, + className, +}: BoxHeaderProps) => { + const [isExpanded, setIsExpanded] = useState(true); + + // Empty state - hide header + if (state === "empty") { + return null; + } + + // Determine content based on state + const items = state === "queuing" ? queuedMessages : subTasks; + const showActionButton = state === "confirm"; + + return ( +
+ {/* Header Top */} +
+ {/* Lead Button - Back/Edit for confirm state, Expand/Collapse for others (hidden for splitting state) */} + {state !== "splitting" && ( + + )} + + {/* Middle - Title & Subtitle */} +
+ {/* Queuing State: Show Title only */} + {state === "queuing" && ( + <> +
+ + {queuedMessages.length} + +
+
+ + Queued Tasks + +
+ + )} + + {/* Splitting State: Show Title only */} + {state === "splitting" && ( +
+ + Splitting Tasks + +
+ )} + + {/* Confirm State: Show Subtitle only */} + {state === "confirm" && subtitle && ( +
+ + {subtitle} + +
+ )} +
+ + {/* Right - Action Button (only for confirm state) */} + {showActionButton && ( + + )} +
+ + {/* Header Content - Accordion Items */} +
0 ? "max-h-[156px] opacity-100" : "max-h-0 opacity-0" + )} + > + {state === "queuing" && + queuedMessages.map((msg) => ( + onRemoveQueuedMessage?.(msg.id)} + /> + ))} + + {state === "confirm" && + subTasks.map((task) => ( + onRemoveSubTask?.(task.id)} + /> + ))} +
+
+ ); +}; + +/** + * Queuing Item Component + * Individual queued message item + */ +interface QueueingItemProps { + content: string; + onRemove?: () => void; +} + +const QueueingItem = ({ content, onRemove }: QueueingItemProps) => { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Circle Icon */} +
+ +
+ + {/* Content Text */} +
+

+ {content} +

+
+ + {/* X Icon - Shows on hover from right side */} + +
+ ); +}; + +/** + * SubTask Item Component + * Individual subtask item for confirmation + */ +interface SubTaskItemProps { + content: string; + status?: "pending" | "confirmed" | "rejected"; + onRemove?: () => void; +} + +const SubTaskItem = ({ content, status = "pending", onRemove }: SubTaskItemProps) => { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Status Icon Button */} + + + {/* Content Text */} +
+

+ {content} +

+
+
+ ); +}; diff --git a/src/components/ChatBox/BottomBox/InputBox.tsx b/src/components/ChatBox/BottomBox/InputBox.tsx new file mode 100644 index 000000000..526be97ae --- /dev/null +++ b/src/components/ChatBox/BottomBox/InputBox.tsx @@ -0,0 +1,423 @@ +import { useState, useRef, useEffect } from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Paperclip, ArrowRight, X, Image, FileText, UploadCloud, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +/** + * File attachment object + */ +export interface FileAttachment { + fileName: string; + filePath: string; +} + +/** + * Inputbox Props + */ +export interface InputboxProps { + /** Current text value */ + value?: string; + /** Callback when text changes */ + onChange?: (value: string) => void; + /** Callback when send button is clicked (only fires when value is not empty) */ + onSend?: () => void; + /** Array of file attachments */ + files?: FileAttachment[]; + /** Callback when files are modified */ + onFilesChange?: (files: FileAttachment[]) => void; + /** Callback when add file button is clicked */ + onAddFile?: () => void; + /** Placeholder text for empty state */ + placeholder?: string; + /** Disable all interactions */ + disabled?: boolean; + /** Additional CSS classes */ + className?: string; + /** Ref for textarea */ + textareaRef?: React.RefObject; + /** Allow drag and drop */ + allowDragDrop?: boolean; + /** Privacy mode enabled */ + privacy?: boolean; + /** Use cloud model in dev */ + useCloudModelInDev?: boolean; +} + +/** + * Inputbox Component + * + * A multi-state input component with two visual states: + * - **Default**: Empty state with placeholder text and disabled send button + * - **Focus/Input**: Active state with content, file attachments, and active send button + * + * Features: + * - Auto-expanding textarea (up to 100px height) + * - File attachment display (shows up to 5 files + count indicator) + * - Action buttons (add file on left, send on right) + * - Send button changes color based on content (gray when empty, green when has content) + * - Arrow icon rotates when there's content + * - Supports Enter to send, Shift+Enter for new line + * - Drag and drop file support + * + * @example + * ```tsx + * const [message, setMessage] = useState(""); + * const [files, setFiles] = useState([]); + * + * { + * console.log("Sending:", message); + * setMessage(""); + * }} + * files={files} + * onFilesChange={setFiles} + * onAddFile={() => { + * // Open file picker + * }} + * placeholder="What do you need to achieve today?" + * allowDragDrop={true} + * /> + * ``` + */ + +export const Inputbox = ({ + value = "", + onChange, + onSend, + files = [], + onFilesChange, + onAddFile, + placeholder = "What do you need to achieve today?", + disabled = false, + className, + textareaRef: externalTextareaRef, + allowDragDrop = false, + privacy = true, + useCloudModelInDev = false, +}: InputboxProps) => { + const internalTextareaRef = useRef(null); + const textareaRef = externalTextareaRef || internalTextareaRef; + const [isFocused, setIsFocused] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const dragCounter = useRef(0); + const [hoveredFilePath, setHoveredFilePath] = useState(null); + const [isRemainingOpen, setIsRemainingOpen] = useState(false); + const remainingRef = useRef(null); + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (!remainingRef.current) return; + if (!remainingRef.current.contains(e.target as Node)) { + setIsRemainingOpen(false); + } + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, []); + + // Auto-resize textarea on value changes (hug content up to max height) + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, [value, textareaRef]); + + // Determine if we're in the "Input" state (has content or files) + const hasContent = value.trim().length > 0 || files.length > 0; + const isActive = isFocused || hasContent; + + const handleTextChange = (newValue: string) => { + onChange?.(newValue); + }; + + const handleSend = () => { + if (value.trim().length > 0 && !disabled) { + onSend?.(); + } else if (value.trim().length === 0) { + toast.error("Message cannot be empty", { + closeButton: true, + }); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !disabled) { + e.preventDefault(); + handleSend(); + } + }; + + const handleRemoveFile = (filePath: string) => { + const newFiles = files.filter((f) => f.filePath !== filePath); + onFilesChange?.(newFiles); + }; + + const getFileIcon = (fileName: string) => { + const ext = fileName.split(".").pop()?.toLowerCase() || ""; + if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) { + return ; + } + return ; + }; + + // Drag & drop handlers + const isFileDrag = (e: React.DragEvent) => { + try { + return Array.from(e.dataTransfer?.types || []).includes("Files"); + } catch { + return false; + } + }; + + const handleDragOver = (e: React.DragEvent) => { + if (!allowDragDrop || !privacy || useCloudModelInDev) return; + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + setIsDragging(true); + }; + + const handleDragEnter = (e: React.DragEvent) => { + if (!allowDragDrop || !privacy || useCloudModelInDev) return; + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + dragCounter.current += 1; + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current = Math.max(0, dragCounter.current - 1); + if (dragCounter.current === 0) setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + dragCounter.current = 0; + if (!allowDragDrop || !privacy || useCloudModelInDev) return; + try { + const dropped = Array.from(e.dataTransfer?.files || []); + if (dropped.length === 0) return; + const mapped = dropped.map((f: File) => ({ + fileName: f.name, + filePath: (f as any).path || f.name, + })); + const newFiles = [ + ...files.filter((f: FileAttachment) => !mapped.find((m) => m.filePath === f.filePath)), + ...mapped.filter((m) => !files.find((f) => f.filePath === m.filePath)), + ]; + onFilesChange?.(newFiles); + } catch (error) { + console.error("Drop File Error:", error); + } + }; + + // Determine remaining files count (show max 5 files + count tag) + const maxVisibleFiles = 5; + const visibleFiles = files.slice(0, maxVisibleFiles); + const remainingCount = files.length > maxVisibleFiles ? files.length - maxVisibleFiles : 0; + + return ( +
+ {isDragging && ( +
+ +
Drop files to attach
+
+ )} + {/* Text Input Area */} +
+
+