From 75cafd5fbc3b1e272bc7c571923693cdadf5c68a Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:37:07 +0000 Subject: [PATCH 1/7] Split thread.tsx into components, rename Composer to PromptInput, and add chat runtime ACL --- src/components/assistant-ui/attachment.tsx | 6 +- src/components/assistant-ui/thread.tsx | 1511 +---------------- .../thread/AssistantActionBar.tsx | 45 + .../assistant-ui/thread/AssistantMessage.tsx | 50 + .../assistant-ui/thread/BranchPicker.tsx | 40 + .../assistant-ui/thread/EditPromptInput.tsx | 81 + .../assistant-ui/thread/MessageError.tsx | 14 + .../assistant-ui/thread/PromptInput.tsx | 159 ++ .../assistant-ui/thread/PromptInputShell.tsx | 167 ++ .../thread/PromptInputToolbar.tsx | 187 ++ src/components/assistant-ui/thread/Thread.tsx | 46 + .../thread/ThreadLoadingSkeleton.tsx | 34 + .../assistant-ui/thread/ThreadSuggestions.tsx | 92 + .../assistant-ui/thread/ThreadWelcome.tsx | 27 + .../assistant-ui/thread/UserActionBar.tsx | 46 + .../assistant-ui/thread/UserMessage.tsx | 92 + .../thread/UserMessageTruncateContext.tsx | 30 + .../thread/VirtualizedMessages.tsx | 55 + .../thread/hooks/use-mention-menu.ts | 153 ++ .../thread/hooks/use-prompt-input-paste.ts | 53 + src/components/assistant-ui/thread/index.ts | 1 + .../assistant-ui/thread/message-components.ts | 9 + .../thread/prompt-input-floating-actions.ts | 53 + .../assistant-ui/thread/suggestion-actions.ts | 47 + src/lib/chat/runtime/hooks.ts | 70 + src/lib/chat/runtime/index.ts | 11 + src/lib/chat/runtime/primitives.ts | 20 + src/lib/chat/runtime/types.ts | 45 + .../process-pdf-attachments-in-background.ts | 67 + 29 files changed, 1698 insertions(+), 1513 deletions(-) create mode 100644 src/components/assistant-ui/thread/AssistantActionBar.tsx create mode 100644 src/components/assistant-ui/thread/AssistantMessage.tsx create mode 100644 src/components/assistant-ui/thread/BranchPicker.tsx create mode 100644 src/components/assistant-ui/thread/EditPromptInput.tsx create mode 100644 src/components/assistant-ui/thread/MessageError.tsx create mode 100644 src/components/assistant-ui/thread/PromptInput.tsx create mode 100644 src/components/assistant-ui/thread/PromptInputShell.tsx create mode 100644 src/components/assistant-ui/thread/PromptInputToolbar.tsx create mode 100644 src/components/assistant-ui/thread/Thread.tsx create mode 100644 src/components/assistant-ui/thread/ThreadLoadingSkeleton.tsx create mode 100644 src/components/assistant-ui/thread/ThreadSuggestions.tsx create mode 100644 src/components/assistant-ui/thread/ThreadWelcome.tsx create mode 100644 src/components/assistant-ui/thread/UserActionBar.tsx create mode 100644 src/components/assistant-ui/thread/UserMessage.tsx create mode 100644 src/components/assistant-ui/thread/UserMessageTruncateContext.tsx create mode 100644 src/components/assistant-ui/thread/VirtualizedMessages.tsx create mode 100644 src/components/assistant-ui/thread/hooks/use-mention-menu.ts create mode 100644 src/components/assistant-ui/thread/hooks/use-prompt-input-paste.ts create mode 100644 src/components/assistant-ui/thread/index.ts create mode 100644 src/components/assistant-ui/thread/message-components.ts create mode 100644 src/components/assistant-ui/thread/prompt-input-floating-actions.ts create mode 100644 src/components/assistant-ui/thread/suggestion-actions.ts create mode 100644 src/lib/chat/runtime/hooks.ts create mode 100644 src/lib/chat/runtime/index.ts create mode 100644 src/lib/chat/runtime/primitives.ts create mode 100644 src/lib/chat/runtime/types.ts create mode 100644 src/lib/uploads/process-pdf-attachments-in-background.ts diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 241b5eea..d1031709 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -385,7 +385,7 @@ export const UserMessageAttachments: FC = () => { ); }; -export const ComposerAttachments: FC = () => { +export const PromptInputAttachments: FC = () => { return (
{ ); }; -export const ComposerAddAttachment: FC = () => { +export const PromptInputAddAttachment: FC = () => { const fileInputRef = useRef(null); const containerRef = useRef(null); const aui = useAui(); @@ -480,7 +480,7 @@ export const ComposerAddAttachment: FC = () => { } }; - const uploadInputId = "composer-file-upload"; + const uploadInputId = "prompt-input-file-upload"; return ( <> diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index e2efadc8..afb9c1f3 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1,1510 +1 @@ -import { - ArrowUpIcon, - CheckIcon, - ChevronDown, - ChevronUp, - Loader2, - CheckCircle2, - ChevronLeftIcon, - ChevronRightIcon, - CopyIcon, - FileText, - Upload, - PencilIcon, - PlusSquareIcon, - RefreshCwIcon, - Square, - GalleryHorizontalEnd, - AlertTriangle, - Sparkles, - Bug, - Brain, - Play, - Search, -} from "lucide-react"; -import { FaWandMagicSparkles } from "react-icons/fa6"; -import { LuBook } from "react-icons/lu"; -import { PiCardsThreeBold } from "react-icons/pi"; -import { cn } from "@/lib/utils"; -import { - ActionBarPrimitive, - AuiIf, - BranchPickerPrimitive, - ComposerPrimitive, - ErrorPrimitive, - MessagePrimitive, - ThreadPrimitive, - useAui, - useMessage, - useMessagePartText, - useAuiState, -} from "@assistant-ui/react"; -import { useVirtualizer } from "@tanstack/react-virtual"; - -import type { FC, RefObject } from "react"; -import { createContext, useContext } from "react"; -import { useEffect, useRef, useState, useMemo, useCallback } from "react"; - -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import Link from "next/link"; -import { MarkdownText } from "@/components/assistant-ui/markdown-text"; -import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; -import { AIFeedbackDialog } from "@/components/assistant-ui/AIFeedbackDialog"; - -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; -import { - ComposerAttachments, - ComposerAddAttachment, - UserMessageAttachments, -} from "@/components/assistant-ui/attachment"; -import { AssistantLoader } from "@/components/assistant-ui/assistant-loader"; -import { File as FileComponent } from "@/components/assistant-ui/file"; -import { isOfficeDocument } from "@/lib/uploads/office-document-validation"; -import { Sources } from "@/components/assistant-ui/sources"; -import { Image } from "@/components/assistant-ui/image"; -import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; -import { ToolGroup } from "@/components/assistant-ui/tool-group"; - -import type { Item } from "@/lib/workspace-state/types"; -import { CardContextDisplay } from "@/components/chat/CardContextDisplay"; -import { ReplyContextDisplay } from "@/components/chat/ReplyContextDisplay"; -import { MessageContextBadges } from "@/components/chat/MessageContextBadges"; -import { MentionMenu } from "@/components/chat/MentionMenu"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useUIStore, selectReplySelections } from "@/lib/stores/ui-store"; -import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; -import { useShallow } from "zustand/react/shallow"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { toast } from "sonner"; -import { filterItems } from "@/lib/workspace-state/search"; -import { useSession } from "@/lib/auth-client"; -import { focusComposerInput } from "@/lib/utils/composer-utils"; -import { buildWorkspaceItemDefinitionsFromAssets } from "@/lib/uploads/uploaded-asset"; -import { uploadSelectedFiles } from "@/lib/uploads/upload-selection"; -import { startAssetProcessing } from "@/lib/uploads/start-asset-processing"; -import { - getDocumentUploadFailureMessage, - getDocumentUploadPartialMessage, - getDocumentUploadSuccessMessage, -} from "@/lib/uploads/upload-feedback"; -import { SpeechToTextButton } from "@/components/assistant-ui/SpeechToTextButton"; -import { - PromptBuilderDialog, - type PromptBuilderAction, -} from "@/components/assistant-ui/PromptBuilderDialog"; -import { ModelPicker } from "@/components/assistant-ui/ModelPicker"; -import { ModelSettingsMenu } from "@/components/assistant-ui/ModelSettingsMenu"; -import { ThinkExLogo } from "@/components/ui/thinkex-logo"; - -interface ThreadProps { - items?: Item[]; -} - -export const Thread: FC = ({ items = [] }) => { - const viewportRef = useRef(null); - - return ( - - - thread.isLoading}> - - - thread.isEmpty && !thread.isLoading}> - - - - - - -
- -
-
- ); -}; - -interface VirtualizedMessagesProps { - scrollRef: RefObject; -} - -const VirtualizedMessages: FC = ({ scrollRef }) => { - const messageCount = useAuiState((s) => s.thread.messages.length); - - const virtualizer = useVirtualizer({ - count: messageCount, - getScrollElement: () => scrollRef.current, - estimateSize: () => 350, - overscan: 5, - }); - - if (messageCount === 0) return null; - - return ( -
- {virtualizer.getVirtualItems().map((virtualRow) => ( -
- -
- ))} -
- ); -}; - -const ThreadLoadingSkeleton: FC = () => { - return ( -
- {/* User message skeleton (right-aligned) */} -
-
- -
-
- {/* Assistant message skeleton (left-aligned) */} -
- - - -
- {/* User message skeleton */} -
- -
- {/* Assistant message skeleton - taller */} -
- - - - -
-
- ); -}; - -interface ThreadWelcomeProps { - items: Item[]; -} - -const ThreadWelcome: FC = ({ items }) => { - return ( -
-
-
-
-
- -
-
-
-
- -
- ); -}; - -interface ThreadSuggestionsProps { - items: Item[]; -} - -const SUGGESTION_ACTIONS = [ - { - title: "Search", - icon: Search, - iconClassName: "size-4 shrink-0 text-sky-500", - action: "search" as PromptBuilderAction, - useDialog: true, - }, - { - title: "Flashcards", - icon: PiCardsThreeBold, - iconClassName: "size-4 shrink-0 text-purple-400 rotate-180", - action: "flashcards" as PromptBuilderAction, - useDialog: true, - }, - { - title: "YouTube", - icon: Play, - iconClassName: "size-4 shrink-0 text-red-500", - action: "youtube" as PromptBuilderAction, - useDialog: true, - }, - { - title: "Upload", - icon: Upload, - iconClassName: "size-4 shrink-0 text-red-400", - triggerFileInput: true, - }, - { - title: "Quiz", - icon: Brain, - iconClassName: "size-4 shrink-0 text-green-400", - action: "quiz" as PromptBuilderAction, - useDialog: true, - }, - { - title: "Document", - icon: FileText, - iconClassName: "size-4 shrink-0 text-sky-400", - action: "document" as PromptBuilderAction, - useDialog: true, - }, -]; - -const ThreadSuggestions: FC = ({ items }) => { - const aui = useAui(); - const [dialogAction, setDialogAction] = useState( - null, - ); - - const handleDirectFill = useCallback( - (action: string) => { - aui?.composer()?.setText(action); - focusComposerInput(true); - }, - [aui], - ); - - const handleTriggerFileInput = useCallback(() => { - document.getElementById("composer-file-upload")?.click(); - focusComposerInput(true); - }, []); - - return ( - <> -
- {SUGGESTION_ACTIONS.map((suggestedAction, index) => { - const Icon = suggestedAction.icon; - return ( -
- -
- ); - })} -
- - {dialogAction && ( - !open && setDialogAction(null)} - action={dialogAction} - items={items} - /> - )} - - ); -}; - -// Floating action buttons shown above composer on hover -const COMPOSER_FLOATING_ACTIONS = [ - { - id: "document", - label: "Document", - icon: FileText, - iconClassName: "size-3.5 shrink-0 text-sky-400", - action: "document" as PromptBuilderAction, - useDialog: true, - }, - { - id: "learn", - label: "Learn", - icon: LuBook, - iconClassName: "size-3.5 shrink-0 text-amber-500", - subActions: [ - { - id: "flashcards", - label: "Flashcards", - icon: PiCardsThreeBold, - iconClassName: "size-4 text-purple-400 rotate-180", - action: "flashcards" as PromptBuilderAction, - }, - { - id: "quiz", - label: "Quiz", - icon: Brain, - iconClassName: "size-4 text-green-400", - action: "quiz" as PromptBuilderAction, - }, - ], - }, - { - id: "youtube", - label: "YouTube", - icon: Play, - iconClassName: "size-3.5 text-red-500", - action: "youtube" as PromptBuilderAction, - useDialog: true, - }, - { - id: "search", - label: "Search", - icon: Search, - iconClassName: "size-3.5 text-teal-500", - action: "search" as PromptBuilderAction, - useDialog: true, - }, -]; - -interface ComposerHoverWrapperProps { - items: Item[]; -} - -const FLOATING_MENU_HIDE_DELAY_MS = 400; - -const ComposerHoverWrapper: FC = ({ items }) => { - const [isHovered, setIsHovered] = useState(false); - const hideTimeoutRef = useRef(null); - const aui = useAui(); - const [dialogAction, setDialogAction] = useState( - null, - ); - const isThreadEmpty = useAuiState(({ thread }) => thread?.isEmpty ?? true); - const hasComposerText = useAuiState( - (s) => - Boolean( - ((s as { composer?: { text?: string } })?.composer?.text ?? "").trim(), - ), - ); - - const handleDirectFill = useCallback( - (fill: string) => { - aui?.composer()?.setText(fill); - focusComposerInput(true); - }, - [aui], - ); - - const handleMouseEnter = useCallback(() => { - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - setIsHovered(true); - }, []); - - const handleMouseLeave = useCallback(() => { - hideTimeoutRef.current = setTimeout(() => { - setIsHovered(false); - }, FLOATING_MENU_HIDE_DELAY_MS); - }, []); - - useEffect(() => { - return () => { - if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); - }; - }, []); - - return ( -
- {/* Composer + floating menu - main hover zone */} -
- {/* Floating buttons - appear above composer on hover */} -
-
- {COMPOSER_FLOATING_ACTIONS.map((action) => { - if ("subActions" in action) { - // Learn button with dropdown - const Icon = action.icon; - return ( - - - - - - {(action.subActions ?? []).map((sub) => { - const SubIcon = sub.icon; - return ( - setDialogAction(sub.action)} - className="flex cursor-pointer items-center gap-2" - > - - {sub.label} - - ); - })} - - - ); - } - const Icon = action.icon; - return ( - - ); - })} -
-
- - - - {dialogAction && ( - !open && setDialogAction(null)} - action={dialogAction} - items={items} - /> - )} -
-
- ); -}; - -interface ComposerProps { - items: Item[]; -} - -const Composer: FC = ({ items }) => { - const currentWorkspaceId = useWorkspaceStore( - (state) => state.currentWorkspaceId, - ); - const aui = useAui(); - const replySelections = useUIStore(useShallow(selectReplySelections)); - const clearReplySelections = useUIStore( - (state) => state.clearReplySelections, - ); - const { selectedCardIds } = useSelectedCardIds(); - - // Get workspace state and operations for PDF card creation - const { state: workspaceState } = useWorkspaceState(currentWorkspaceId); - const operations = useWorkspaceOperations(currentWorkspaceId, workspaceState); - - // Watch for thread changes to auto-focus composer (built-in assistant-ui behavior) - const mainThreadId = useAuiState( - ({ threads }) => (threads as any)?.mainThreadId, - ); - const inputRef = useRef(null); - - // Auto-focus composer when thread changes - useEffect(() => { - if (mainThreadId && inputRef.current) { - // Small delay to ensure DOM is ready after thread switch - const timeoutId = setTimeout(() => { - inputRef.current?.focus(); - }, 100); - return () => clearTimeout(timeoutId); - } - }, [mainThreadId]); - - // Mention menu state - const [mentionMenuOpen, setMentionMenuOpen] = useState(false); - const [mentionQuery, setMentionQuery] = useState(""); - const [mentionStartIndex, setMentionStartIndex] = useState( - null, - ); - const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); - - // Handle input changes for @ mention detection - const handleInput = useCallback( - (e: React.FormEvent) => { - const textarea = e.currentTarget; - const value = textarea.value; - const cursorPos = textarea.selectionStart ?? 0; - - // Check if we're currently tracking a mention - if (mentionStartIndex !== null) { - // Extract the query from @ to cursor - const query = value.slice(mentionStartIndex + 1, cursorPos); - - // Check if we've moved before the @ or if there's a space/newline after @ - if ( - cursorPos <= mentionStartIndex || - query.includes(" ") || - query.includes("\n") - ) { - setMentionMenuOpen(false); - setMentionStartIndex(null); - setMentionQuery(""); - } else { - setMentionQuery(query); - } - } - }, - [mentionStartIndex], - ); - - // Handle keydown for @ detection and menu control - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const textarea = e.currentTarget; - - // Detect @ key - if (e.key === "@" && !mentionMenuOpen) { - const cursorPos = textarea.selectionStart ?? 0; - // Check if @ is at start or preceded by whitespace - const charBefore = cursorPos > 0 ? textarea.value[cursorPos - 1] : " "; - if (charBefore === " " || charBefore === "\n" || cursorPos === 0) { - setMentionMenuOpen(true); - setMentionStartIndex(cursorPos); - setMentionQuery(""); - } - } - - // Close menu on Escape - if (e.key === "Escape" && mentionMenuOpen) { - e.preventDefault(); - setMentionMenuOpen(false); - setMentionStartIndex(null); - setMentionQuery(""); - } - - // Prevent default behavior when menu is open for arrow keys and Enter - if ( - mentionMenuOpen && - ["ArrowUp", "ArrowDown", "Enter"].includes(e.key) - ) { - e.preventDefault(); - } - }, - [mentionMenuOpen], - ); - - // Clear the @query from input (extracted for reuse) - const clearMentionQuery = useCallback(() => { - if (mentionStartIndex !== null && inputRef.current) { - const textarea = inputRef.current; - const currentValue = textarea.value; - - // Calculate what to remove: from the @ symbol to current cursor/end of query - const atSymbolIndex = mentionStartIndex; - - // Find where the query ends (current text after @ until space/newline or end) - let queryEndIndex = mentionStartIndex; - while ( - queryEndIndex < currentValue.length && - currentValue[queryEndIndex] !== " " && - currentValue[queryEndIndex] !== "\n" - ) { - queryEndIndex++; - } - - const textBefore = currentValue.substring(0, atSymbolIndex); - const textAfter = currentValue.substring(queryEndIndex); - - // Set the new value without the @query - const newValue = textBefore + textAfter; - - // Update the textarea value - aui?.composer()?.setText(newValue); - - // Reset mention state - setMentionQuery(""); - setMentionStartIndex(null); - - // Focus and position cursor at where the @ was - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - const newCursorPos = textBefore.length; - inputRef.current.setSelectionRange(newCursorPos, newCursorPos); - } - }, 0); - } - }, [mentionStartIndex, aui]); - - // Handle mention selection - toggle item, keep menu open for multi-select - const handleMentionSelect = useCallback( - (item: Item) => { - toggleCardSelection(item.id); - // Don't close menu or clear query - allow selecting multiple items - }, - [toggleCardSelection], - ); - - // Handle mention menu close - remove the @query from input - const handleMentionMenuClose = useCallback( - (open: boolean) => { - if (!open) { - clearMentionQuery(); - } - setMentionMenuOpen(open); - }, - [clearMentionQuery], - ); - - const handlePaste = async (e: React.ClipboardEvent) => { - const clipboardData = e.clipboardData; - if (!clipboardData || !currentWorkspaceId) return; - - // Check if clipboard contains files (images or other file types) - const files = Array.from(clipboardData.files) as File[]; - - if (files.length > 0) { - e.preventDefault(); - // Add the first file (or prioritize images if multiple files) as an attachment - // It will be uploaded when the message is sent - const imageFile = files.find((file: File) => - file.type.startsWith("image/"), - ); - const fileToUpload = imageFile || files[0]; - - if (fileToUpload) { - try { - await aui?.composer()?.addAttachment(fileToUpload); - } catch (error) { - console.error("Failed to add file attachment:", error); - } - } - return; - } - - // Also check clipboard items for image data (e.g., screenshots) - const clipboardItems = Array.from( - clipboardData.items, - ) as DataTransferItem[]; - const imageItem = clipboardItems.find((item: DataTransferItem) => - item.type.startsWith("image/"), - ); - - if (imageItem) { - e.preventDefault(); - const file = imageItem.getAsFile(); - if (file) { - try { - // Add as attachment - will be uploaded when message is sent - await aui?.composer()?.addAttachment(file); - } catch (error) { - console.error("Failed to add image attachment:", error); - } - } - return; - } - - // Check if clipboard contains a URL (logic removed) - }; - - /** - * Process PDF attachments in the background without blocking message sending - */ - const processPdfAttachmentsInBackground = async ( - pdfAttachments: any[], - workspaceId: string, - operations: any, - ) => { - let files: File[] = []; - try { - files = pdfAttachments - .map((attachment) => attachment.file) - .filter((file): file is File => !!file); - const { uploads, failedFiles } = await uploadSelectedFiles(files); - - if (uploads.length > 0) { - const pdfCardDefinitions = - buildWorkspaceItemDefinitionsFromAssets(uploads); - const createdIds = operations.createItems(pdfCardDefinitions, { - showSuccessToast: false, - }); - - void startAssetProcessing({ - workspaceId, - assets: uploads, - itemIds: createdIds, - onOcrError: (error) => { - console.error("Error starting assistant file processing:", error); - }, - }); - - // Show success toast - if (failedFiles.length === 0) { - toast.success(getDocumentUploadSuccessMessage(uploads.length)); - } else { - toast.warning( - getDocumentUploadPartialMessage(uploads.length, failedFiles.length), - ); - } - } else { - toast.error( - getDocumentUploadFailureMessage(failedFiles.length || files.length), - ); - } - } catch (error) { - console.error("Error creating PDF cards in background:", error); - toast.error( - getDocumentUploadFailureMessage(files.length || pdfAttachments.length), - ); - } - }; - - return ( - { - // Focus the input when clicking anywhere in the composer area - // This allows users to easily return focus after interacting with quizzes or other cards - if (inputRef.current && !e.defaultPrevented) { - inputRef.current.focus(); - } - }} - onSubmit={async (e) => { - e.preventDefault(); - - // Wait for attachment uploads before sending (same UX as home input) - if (useAttachmentUploadStore.getState().uploadingIds.size > 0) { - toast.info("Please wait for uploads to finish before sending"); - return; - } - - // Get the current composer state - const composerState = aui?.composer()?.getState(); - if (!composerState) return; - - const currentText = composerState.text; - const attachments = composerState.attachments || []; - - // Prevent empty messages when reply context already provides content. - const hasReplyContext = replySelections.length > 0; - if ( - !currentText.trim() && - attachments.length === 0 && - !hasReplyContext - ) { - return; - } - - // Detect PDF attachments for background processing - const pdfAttachments = attachments.filter((att) => { - const file = att.file; - return ( - file && - (file.type === "application/pdf" || - file.name.toLowerCase().endsWith(".pdf") || - isOfficeDocument(file)) - ); - }); - - // Process PDFs in background - don't block message sending - if (pdfAttachments.length > 0 && currentWorkspaceId) { - processPdfAttachmentsInBackground( - pdfAttachments, - currentWorkspaceId, - operations, - ); - } - - // Get selected cards for context - const selectedItems = items.filter((item) => - selectedCardIds.has(item.id), - ); - - // Combine all context: selected cards, reply texts, and user message - // Use placeholder when empty so AI SDK accepts (backend injects reply context into message) - let modifiedText = - currentText.trim() || (hasReplyContext ? "Empty message" : ""); - - // Attach per-request context as metadata via runConfig - // This flows through as body.metadata.custom on the server - // IMPORTANT: Always set runConfig (even empty) to clear stale data from previous sends, - // because the composer does NOT reset runConfig after send(). - const customMetadata: Record = {}; - if (replySelections.length > 0) { - customMetadata.replySelections = replySelections; - } - aui - ?.composer() - ?.setRunConfig( - Object.keys(customMetadata).length > 0 - ? { custom: customMetadata } - : {}, - ); - - // Set the modified text and send - aui?.composer()?.setText(modifiedText); - aui?.composer()?.send(); - - // Clear all per-request state immediately — captured in runConfig before send() - clearReplySelections(); - }} - > - {/* Attachment Display - shows uploaded files */} - - {/* Card Context Display - shows selected cards inside input area */} - - {/* Ask AI context chips (text selections from chat / PDF / document) */} - -
- - {/* Mention Menu */} - - isSelected ? ( - - ) : undefined - } - /> -
- -
- ); -}; - -interface ComposerActionProps { - items: Item[]; -} - -const ComposerAction: FC = ({ items }) => { - const { data: session } = useSession(); - useAui(); - const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); - const isAnonymous = session?.user?.isAnonymous ?? false; - const { selectedCardIds } = useSelectedCardIds(); - const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); - - const [isWarningPopoverOpen, setIsWarningPopoverOpen] = useState(false); - const hoverTimeoutRef = useRef(null); - const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); - - // AI Debug button: always show in dev, only when feature flag enabled in prod - const isDev = process.env.NODE_ENV === "development"; - const aiDebugFlagEnabled = useFeatureFlagEnabled("ai-debug-feedback"); - const showAiDebugButton = isDev || aiDebugFlagEnabled === true; - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - }; - }, []); - - // Filter items for the dropdown based on selected tags - const filteredItems = useMemo(() => { - return filterItems(items, ""); - }, [items]); - - return ( -
- {/* Attachment buttons on the left */} -
-
- -
- {!isAnonymous && } -
- -
- {/* AI Debug Button - feature flag in prod, localhost debugger in dev */} - {showAiDebugButton && ( - - )} - - {/* Warning icon for anonymous users */} - {isAnonymous && ( - { - setIsWarningPopoverOpen(open); - if (!open) { - focusComposerInput(); - } - }} - > - - - - { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - setIsWarningPopoverOpen(true); - }} - onMouseLeave={() => { - hoverTimeoutRef.current = setTimeout(() => { - setIsWarningPopoverOpen(false); - }, 100); - }} - className="w-64 p-3" - > -
-

- Your AI chats won't save unless you are logged in. -

-
- - - - - - -
-
-
-
- )} -
- {/* Right side: speech/send/cancel button */} -
- {!isAnonymous && } - !thread.isRunning}> - - {hasUploading ? ( - - ) : ( - - )} - - - - thread.isRunning}> - - - - -
-
- ); -}; - -const MessageError: FC = () => { - return ( - - - - - - ); -}; - -const AssistantMessage: FC = () => { - return ( - -
-
- - - -
- -
- - -
-
-
- ); -}; - -const AssistantActionBar: FC = () => { - const { content } = useMessage(); - - const textContent = useMemo(() => { - const textParts = content.filter( - (part): part is { type: "text"; text: string } => part.type === "text", - ); - return textParts.map((part) => part.text ?? "").join("\n\n"); - }, [content]); - - const [copied, setCopied] = useState(false); - const copyTimeoutRef = useRef(null); - - const handleCopy = useCallback(() => { - if (!textContent) return; - navigator.clipboard.writeText(textContent); - setCopied(true); - if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); - copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); - }, [textContent]); - - return ( - - - {copied ? : } - - - - - - - - ); -}; - -const USER_MESSAGE_MAX_CHARS = 250; - -const UserMessageTruncateContext = createContext<{ - maxChars: number; - expanded: boolean; - showExpand: boolean; -} | null>(null); - -// Custom Text component for UserMessage (plain text + truncation) -const UserMessageText: FC = () => { - const { text: rawText } = useMessagePartText(); - const truncateCtx = useContext(UserMessageTruncateContext); - - let text = rawText; - - if ( - truncateCtx && - !truncateCtx.expanded && - truncateCtx.maxChars < Infinity && - text.length > truncateCtx.maxChars - ) { - text = text.slice(0, truncateCtx.maxChars).trim() + "..."; - } - - return
{text}
; -}; - -const UserMessage: FC = () => { - const [expanded, setExpanded] = useState(false); - const message = useMessage(); - - const textLength = useMemo( - () => - message.content - .filter( - (part): part is { type: "text"; text: string } => - part.type === "text", - ) - .reduce((sum, part) => sum + (part.text?.length ?? 0), 0), - [message.content], - ); - - const showExpand = textLength > USER_MESSAGE_MAX_CHARS; - - const truncateCtxValue = useMemo( - () => ({ - maxChars: USER_MESSAGE_MAX_CHARS, - expanded, - showExpand, - }), - [expanded, showExpand], - ); - - return ( - -
- {/* Attachments display */} - - -
- - -
- - {showExpand && ( -
- -
- )} -
-
-
- -
-
- -
-
- - -
-
- ); -}; - -const UserActionBar: FC = () => { - const message = useMessage(); - const [copied, setCopied] = useState(false); - const copyTimeoutRef = useRef(null); - - const textContent = useMemo(() => { - return message.content - .filter( - (part): part is { type: "text"; text: string } => part.type === "text", - ) - .map((part) => part.text ?? "") - .join("\n\n"); - }, [message.content]); - - const handleCopy = useCallback(() => { - if (!textContent) return; - navigator.clipboard.writeText(textContent); - setCopied(true); - if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); - copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); - }, [textContent]); - - return ( - - - {copied ? : } - - - - - - - - ); -}; - -const EditComposer: FC = () => { - const aui = useAui(); - const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); - const [originalText, setOriginalText] = useState( - () => aui?.composer()?.getState()?.text ?? "", - ); - const [currentText, setCurrentText] = useState( - () => aui?.composer()?.getState()?.text ?? "", - ); - - return ( -
- { - e.preventDefault(); - - if (useAttachmentUploadStore.getState().uploadingIds.size > 0) { - toast.info("Please wait for uploads to finish before sending"); - return; - } - - aui?.composer()?.send(); - }} - > - - - setCurrentText(e.target.value)} - /> - -
-
- -
-
- - - - -
-
-
-
- ); -}; - -const MESSAGE_COMPONENTS = { - UserMessage, - EditComposer, - AssistantMessage, -}; - -const BranchPicker: FC = ({ - className, - ...rest -}) => { - return ( - - - - - - - - / - - - - - - - - ); -}; +export { Thread } from "./thread/index"; diff --git a/src/components/assistant-ui/thread/AssistantActionBar.tsx b/src/components/assistant-ui/thread/AssistantActionBar.tsx new file mode 100644 index 00000000..84a23a62 --- /dev/null +++ b/src/components/assistant-ui/thread/AssistantActionBar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { CheckIcon, CopyIcon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState, type FC } from "react"; +import { ChatActionBar, useChatMessage } from "@/lib/chat/runtime"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; + +export const AssistantActionBar: FC = () => { + const { content } = useChatMessage(); + + const textContent = useMemo(() => { + const textParts = content.filter( + (part): part is { type: "text"; text: string } => part.type === "text", + ); + return textParts.map((part) => part.text ?? "").join("\n\n"); + }, [content]); + + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef(null); + + const handleCopy = useCallback(() => { + if (!textContent) return; + navigator.clipboard.writeText(textContent); + setCopied(true); + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + }, [textContent]); + + return ( + + + {copied ? : } + + + + + + + + ); +}; diff --git a/src/components/assistant-ui/thread/AssistantMessage.tsx b/src/components/assistant-ui/thread/AssistantMessage.tsx new file mode 100644 index 00000000..5d726c65 --- /dev/null +++ b/src/components/assistant-ui/thread/AssistantMessage.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { FC } from "react"; +import { AssistantLoader } from "@/components/assistant-ui/assistant-loader"; +import { File as FileComponent } from "@/components/assistant-ui/file"; +import { Image } from "@/components/assistant-ui/image"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; +import { Sources } from "@/components/assistant-ui/sources"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { ToolGroup } from "@/components/assistant-ui/tool-group"; +import { ChatMessage } from "@/lib/chat/runtime"; +import { AssistantActionBar } from "./AssistantActionBar"; +import { BranchPicker } from "./BranchPicker"; +import { MessageError } from "./MessageError"; + +export const AssistantMessage: FC = () => { + return ( + +
+
+ + + +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/BranchPicker.tsx b/src/components/assistant-ui/thread/BranchPicker.tsx new file mode 100644 index 00000000..0dea7fd8 --- /dev/null +++ b/src/components/assistant-ui/thread/BranchPicker.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import type { FC } from "react"; +import { + ChatBranchPicker, + type ChatBranchPickerRootProps, +} from "@/lib/chat/runtime"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +export const BranchPicker: FC = ({ + className, + ...rest +}) => { + return ( + + + + + + + + / + + + + + + + + ); +}; diff --git a/src/components/assistant-ui/thread/EditPromptInput.tsx b/src/components/assistant-ui/thread/EditPromptInput.tsx new file mode 100644 index 00000000..ce66d50b --- /dev/null +++ b/src/components/assistant-ui/thread/EditPromptInput.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useState, type FC } from "react"; +import { + PromptInputAddAttachment, + PromptInputAttachments, +} from "@/components/assistant-ui/attachment"; +import { Button } from "@/components/ui/button"; +import { ChatPromptInput, usePromptInput } from "@/lib/chat/runtime"; +import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +export const EditPromptInput: FC = () => { + const promptInput = usePromptInput(); + const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); + const [originalText] = useState(() => promptInput?.getState()?.text ?? ""); + const [currentText, setCurrentText] = useState( + () => promptInput?.getState()?.text ?? "", + ); + + return ( +
+ { + e.preventDefault(); + + if (useAttachmentUploadStore.getState().uploadingIds.size > 0) { + toast.info("Please wait for uploads to finish before sending"); + return; + } + + promptInput?.send(); + }} + > + + + setCurrentText(e.target.value)} + /> + +
+
+ +
+
+ + + + +
+
+
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/MessageError.tsx b/src/components/assistant-ui/thread/MessageError.tsx new file mode 100644 index 00000000..44f5551c --- /dev/null +++ b/src/components/assistant-ui/thread/MessageError.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { FC } from "react"; +import { ChatError, ChatMessage } from "@/lib/chat/runtime"; + +export const MessageError: FC = () => { + return ( + + + + + + ); +}; diff --git a/src/components/assistant-ui/thread/PromptInput.tsx b/src/components/assistant-ui/thread/PromptInput.tsx new file mode 100644 index 00000000..07431367 --- /dev/null +++ b/src/components/assistant-ui/thread/PromptInput.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { CheckCircle2 } from "lucide-react"; +import { useEffect, useRef, type FC } from "react"; +import { + PromptInputAttachments, +} from "@/components/assistant-ui/attachment"; +import { CardContextDisplay } from "@/components/chat/CardContextDisplay"; +import { MentionMenu } from "@/components/chat/MentionMenu"; +import { ReplyContextDisplay } from "@/components/chat/ReplyContextDisplay"; +import { ChatPromptInput, useMainThreadId, usePromptInput } from "@/lib/chat/runtime"; +import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; +import { selectReplySelections, useUIStore } from "@/lib/stores/ui-store"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { isOfficeDocument } from "@/lib/uploads/office-document-validation"; +import { processPdfAttachmentsInBackground } from "@/lib/uploads/process-pdf-attachments-in-background"; +import type { Item } from "@/lib/workspace-state/types"; +import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; +import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { toast } from "sonner"; +import { useShallow } from "zustand/react/shallow"; +import { PromptInputToolbar } from "./PromptInputToolbar"; +import { useMentionMenu } from "./hooks/use-mention-menu"; +import { usePromptInputPaste } from "./hooks/use-prompt-input-paste"; + +interface PromptInputProps { + items: Item[]; +} + +export const PromptInput: FC = ({ items }) => { + const currentWorkspaceId = useWorkspaceStore( + (state) => state.currentWorkspaceId, + ); + const promptInput = usePromptInput(); + const replySelections = useUIStore(useShallow(selectReplySelections)); + const clearReplySelections = useUIStore( + (state) => state.clearReplySelections, + ); + const { selectedCardIds } = useSelectedCardIds(); + const { state: workspaceState } = useWorkspaceState(currentWorkspaceId); + const operations = useWorkspaceOperations(currentWorkspaceId, workspaceState); + const mainThreadId = useMainThreadId(); + const inputRef = useRef(null); + const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); + const mention = useMentionMenu({ + inputRef, + promptInput, + onSelectItem: (item) => toggleCardSelection(item.id), + }); + const handlePaste = usePromptInputPaste({ + promptInput, + workspaceId: currentWorkspaceId, + }); + + useEffect(() => { + if (mainThreadId && inputRef.current) { + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 100); + return () => clearTimeout(timeoutId); + } + }, [mainThreadId]); + + return ( + { + if (inputRef.current && !e.defaultPrevented) { + inputRef.current.focus(); + } + }} + onSubmit={async (e) => { + e.preventDefault(); + + if (useAttachmentUploadStore.getState().uploadingIds.size > 0) { + toast.info("Please wait for uploads to finish before sending"); + return; + } + + const composerState = promptInput?.getState(); + if (!composerState) return; + + const currentText = composerState.text; + const attachments = composerState.attachments || []; + const hasReplyContext = replySelections.length > 0; + if (!currentText.trim() && attachments.length === 0 && !hasReplyContext) { + return; + } + + const pdfAttachments = attachments.filter((att) => { + const file = att.file; + return ( + file && + (file.type === "application/pdf" || + file.name.toLowerCase().endsWith(".pdf") || + isOfficeDocument(file)) + ); + }); + + if (pdfAttachments.length > 0 && currentWorkspaceId) { + void processPdfAttachmentsInBackground( + pdfAttachments, + currentWorkspaceId, + operations, + ); + } + + const modifiedText = + currentText.trim() || (hasReplyContext ? "Empty message" : ""); + + const customMetadata: Record = {}; + if (replySelections.length > 0) { + customMetadata.replySelections = replySelections; + } + promptInput?.setRunConfig( + Object.keys(customMetadata).length > 0 ? { custom: customMetadata } : {}, + ); + + promptInput?.setText(modifiedText); + promptInput?.send(); + clearReplySelections(); + }} + > + + + +
+ + + isSelected ? ( + + ) : undefined + } + /> +
+ +
+ ); +}; diff --git a/src/components/assistant-ui/thread/PromptInputShell.tsx b/src/components/assistant-ui/thread/PromptInputShell.tsx new file mode 100644 index 00000000..7fb9b651 --- /dev/null +++ b/src/components/assistant-ui/thread/PromptInputShell.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type FC } from "react"; +import { + PromptBuilderDialog, + type PromptBuilderAction, +} from "@/components/assistant-ui/PromptBuilderDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useHasPromptInputText, useIsThreadEmpty, usePromptInput } from "@/lib/chat/runtime"; +import { cn } from "@/lib/utils"; +import { focusComposerInput } from "@/lib/utils/composer-utils"; +import type { Item } from "@/lib/workspace-state/types"; +import { PROMPT_INPUT_FLOATING_ACTIONS } from "./prompt-input-floating-actions"; +import { PromptInput } from "./PromptInput"; + +interface PromptInputShellProps { + items: Item[]; +} + +const FLOATING_MENU_HIDE_DELAY_MS = 400; + +export const PromptInputShell: FC = ({ items }) => { + const [isHovered, setIsHovered] = useState(false); + const hideTimeoutRef = useRef(null); + const promptInput = usePromptInput(); + const [dialogAction, setDialogAction] = useState( + null, + ); + const isThreadEmpty = useIsThreadEmpty(); + const hasPromptInputText = useHasPromptInputText(); + + const handleDirectFill = useCallback( + (fill: string) => { + promptInput?.setText(fill); + focusComposerInput(true); + }, + [promptInput], + ); + + const handleMouseEnter = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + hideTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + }, FLOATING_MENU_HIDE_DELAY_MS); + }, []); + + useEffect(() => { + return () => { + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + }; + }, []); + + return ( +
+
+
+
+ {PROMPT_INPUT_FLOATING_ACTIONS.map((action) => { + if ("subActions" in action) { + const Icon = action.icon; + return ( + + + + + + {(action.subActions ?? []).map((sub) => { + const SubIcon = sub.icon; + return ( + setDialogAction(sub.action)} + className="flex cursor-pointer items-center gap-2" + > + + {sub.label} + + ); + })} + + + ); + } + const Icon = action.icon; + return ( + + ); + })} +
+
+ + + + {dialogAction && ( + !open && setDialogAction(null)} + action={dialogAction} + items={items} + /> + )} +
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/PromptInputToolbar.tsx b/src/components/assistant-ui/thread/PromptInputToolbar.tsx new file mode 100644 index 00000000..a76b43b2 --- /dev/null +++ b/src/components/assistant-ui/thread/PromptInputToolbar.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { PromptInputAddAttachment } from "@/components/assistant-ui/attachment"; +import { AIFeedbackDialog } from "@/components/assistant-ui/AIFeedbackDialog"; +import { ModelPicker } from "@/components/assistant-ui/ModelPicker"; +import { ModelSettingsMenu } from "@/components/assistant-ui/ModelSettingsMenu"; +import { SpeechToTextButton } from "@/components/assistant-ui/SpeechToTextButton"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useSession } from "@/lib/auth-client"; +import { ChatIf, ChatPromptInput } from "@/lib/chat/runtime"; +import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; +import { focusComposerInput } from "@/lib/utils/composer-utils"; +import { AlertTriangle, ArrowUpIcon, Bug, Loader2, Square } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState, type FC } from "react"; +import { useFeatureFlagEnabled } from "posthog-js/react"; + +export const PromptInputToolbar: FC = () => { + const { data: session } = useSession(); + const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); + const isAnonymous = session?.user?.isAnonymous ?? false; + const [isWarningPopoverOpen, setIsWarningPopoverOpen] = useState(false); + const hoverTimeoutRef = useRef(null); + const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); + const isDev = process.env.NODE_ENV === "development"; + const aiDebugFlagEnabled = useFeatureFlagEnabled("ai-debug-feedback"); + const showAiDebugButton = isDev || aiDebugFlagEnabled === true; + + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + return ( +
+
+
+ +
+ {!isAnonymous && } +
+ +
+ {showAiDebugButton && ( + + )} + + {isAnonymous && ( + { + setIsWarningPopoverOpen(open); + if (!open) { + focusComposerInput(); + } + }} + > + + + + { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + setIsWarningPopoverOpen(true); + }} + onMouseLeave={() => { + hoverTimeoutRef.current = setTimeout(() => { + setIsWarningPopoverOpen(false); + }, 100); + }} + className="w-64 p-3" + > +
+

+ Your AI chats won't save unless you are logged in. +

+
+ + + + + + +
+
+
+
+ )} +
+
+ {!isAnonymous && } + !thread.isRunning}> + + {hasUploading ? ( + + ) : ( + + )} + + + + thread.isRunning}> + + + + +
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/Thread.tsx b/src/components/assistant-ui/thread/Thread.tsx new file mode 100644 index 00000000..a41089fa --- /dev/null +++ b/src/components/assistant-ui/thread/Thread.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRef, type FC } from "react"; +import { ChatIf, ChatThread } from "@/lib/chat/runtime"; +import type { Item } from "@/lib/workspace-state/types"; +import { PromptInputShell } from "./PromptInputShell"; +import { ThreadLoadingSkeleton } from "./ThreadLoadingSkeleton"; +import { ThreadWelcome } from "./ThreadWelcome"; +import { VirtualizedMessages } from "./VirtualizedMessages"; + +interface ThreadProps { + items?: Item[]; +} + +export const Thread: FC = ({ items = [] }) => { + const viewportRef = useRef(null); + + return ( + + + thread.isLoading}> + + + thread.isEmpty && !thread.isLoading}> + + + + + + +
+ +
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/ThreadLoadingSkeleton.tsx b/src/components/assistant-ui/thread/ThreadLoadingSkeleton.tsx new file mode 100644 index 00000000..9d943617 --- /dev/null +++ b/src/components/assistant-ui/thread/ThreadLoadingSkeleton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { FC } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const ThreadLoadingSkeleton: FC = () => { + return ( +
+
+
+ +
+
+
+ + + +
+
+ +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/ThreadSuggestions.tsx b/src/components/assistant-ui/thread/ThreadSuggestions.tsx new file mode 100644 index 00000000..d399728e --- /dev/null +++ b/src/components/assistant-ui/thread/ThreadSuggestions.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useCallback, useState, type FC } from "react"; +import { + PromptBuilderDialog, + type PromptBuilderAction, +} from "@/components/assistant-ui/PromptBuilderDialog"; +import { Button } from "@/components/ui/button"; +import { usePromptInput } from "@/lib/chat/runtime"; +import { focusComposerInput } from "@/lib/utils/composer-utils"; +import type { Item } from "@/lib/workspace-state/types"; +import { SUGGESTION_ACTIONS } from "./suggestion-actions"; + +interface ThreadSuggestionsProps { + items: Item[]; +} + +export const ThreadSuggestions: FC = ({ items }) => { + const promptInput = usePromptInput(); + const [dialogAction, setDialogAction] = useState( + null, + ); + + const handleDirectFill = useCallback( + (action: string) => { + promptInput?.setText(action); + focusComposerInput(true); + }, + [promptInput], + ); + + const handleTriggerFileInput = useCallback(() => { + document.getElementById("prompt-input-file-upload")?.click(); + focusComposerInput(true); + }, []); + + return ( + <> +
+ {SUGGESTION_ACTIONS.map((suggestedAction, index) => { + const Icon = suggestedAction.icon; + return ( +
+ +
+ ); + })} +
+ + {dialogAction && ( + !open && setDialogAction(null)} + action={dialogAction} + items={items} + /> + )} + + ); +}; diff --git a/src/components/assistant-ui/thread/ThreadWelcome.tsx b/src/components/assistant-ui/thread/ThreadWelcome.tsx new file mode 100644 index 00000000..b70fca34 --- /dev/null +++ b/src/components/assistant-ui/thread/ThreadWelcome.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { FC } from "react"; +import { ThinkExLogo } from "@/components/ui/thinkex-logo"; +import type { Item } from "@/lib/workspace-state/types"; +import { ThreadSuggestions } from "./ThreadSuggestions"; + +interface ThreadWelcomeProps { + items: Item[]; +} + +export const ThreadWelcome: FC = ({ items }) => { + return ( +
+
+
+
+
+ +
+
+
+
+ +
+ ); +}; diff --git a/src/components/assistant-ui/thread/UserActionBar.tsx b/src/components/assistant-ui/thread/UserActionBar.tsx new file mode 100644 index 00000000..5fe6b16a --- /dev/null +++ b/src/components/assistant-ui/thread/UserActionBar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { CheckIcon, CopyIcon, PencilIcon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState, type FC } from "react"; +import { ChatActionBar, useChatMessage } from "@/lib/chat/runtime"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; + +export const UserActionBar: FC = () => { + const message = useChatMessage(); + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef(null); + + const textContent = useMemo(() => { + return message.content + .filter( + (part): part is { type: "text"; text: string } => part.type === "text", + ) + .map((part) => part.text ?? "") + .join("\n\n"); + }, [message.content]); + + const handleCopy = useCallback(() => { + if (!textContent) return; + navigator.clipboard.writeText(textContent); + setCopied(true); + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + }, [textContent]); + + return ( + + + {copied ? : } + + + + + + + + ); +}; diff --git a/src/components/assistant-ui/thread/UserMessage.tsx b/src/components/assistant-ui/thread/UserMessage.tsx new file mode 100644 index 00000000..928db68d --- /dev/null +++ b/src/components/assistant-ui/thread/UserMessage.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useMemo, useState, type FC } from "react"; +import { UserMessageAttachments } from "@/components/assistant-ui/attachment"; +import { File as FileComponent } from "@/components/assistant-ui/file"; +import { MessageContextBadges } from "@/components/chat/MessageContextBadges"; +import { ChatMessage, useChatMessage } from "@/lib/chat/runtime"; +import { BranchPicker } from "./BranchPicker"; +import { + USER_MESSAGE_MAX_CHARS, + UserMessageText, + UserMessageTruncateContext, +} from "./UserMessageTruncateContext"; +import { UserActionBar } from "./UserActionBar"; + +export const UserMessage: FC = () => { + const [expanded, setExpanded] = useState(false); + const message = useChatMessage(); + + const textLength = useMemo( + () => + message.content + .filter( + (part): part is { type: "text"; text: string } => + part.type === "text", + ) + .reduce((sum, part) => sum + (part.text?.length ?? 0), 0), + [message.content], + ); + + const showExpand = textLength > USER_MESSAGE_MAX_CHARS; + + const truncateCtxValue = useMemo( + () => ({ + maxChars: USER_MESSAGE_MAX_CHARS, + expanded, + showExpand, + }), + [expanded, showExpand], + ); + + return ( + +
+ + +
+ + +
+ + {showExpand && ( +
+ +
+ )} +
+
+
+ +
+
+ +
+
+ + +
+
+ ); +}; diff --git a/src/components/assistant-ui/thread/UserMessageTruncateContext.tsx b/src/components/assistant-ui/thread/UserMessageTruncateContext.tsx new file mode 100644 index 00000000..d7304e4e --- /dev/null +++ b/src/components/assistant-ui/thread/UserMessageTruncateContext.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { createContext, useContext, type FC } from "react"; +import { useChatMessagePartText } from "@/lib/chat/runtime"; + +export const USER_MESSAGE_MAX_CHARS = 250; + +export const UserMessageTruncateContext = createContext<{ + maxChars: number; + expanded: boolean; + showExpand: boolean; +} | null>(null); + +export const UserMessageText: FC = () => { + const { text: rawText } = useChatMessagePartText(); + const truncateCtx = useContext(UserMessageTruncateContext); + + let text = rawText; + + if ( + truncateCtx && + !truncateCtx.expanded && + truncateCtx.maxChars < Infinity && + text.length > truncateCtx.maxChars + ) { + text = text.slice(0, truncateCtx.maxChars).trim() + "..."; + } + + return
{text}
; +}; diff --git a/src/components/assistant-ui/thread/VirtualizedMessages.tsx b/src/components/assistant-ui/thread/VirtualizedMessages.tsx new file mode 100644 index 00000000..df32bcca --- /dev/null +++ b/src/components/assistant-ui/thread/VirtualizedMessages.tsx @@ -0,0 +1,55 @@ +"use client"; + +import type { FC, RefObject } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ChatThread, useThreadMessageCount } from "@/lib/chat/runtime"; +import { MESSAGE_COMPONENTS } from "./message-components"; + +interface VirtualizedMessagesProps { + scrollRef: RefObject; +} + +export const VirtualizedMessages: FC = ({ + scrollRef, +}) => { + const messageCount = useThreadMessageCount(); + + const virtualizer = useVirtualizer({ + count: messageCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => 350, + overscan: 5, + }); + + if (messageCount === 0) return null; + + return ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ +
+ ))} +
+ ); +}; diff --git a/src/components/assistant-ui/thread/hooks/use-mention-menu.ts b/src/components/assistant-ui/thread/hooks/use-mention-menu.ts new file mode 100644 index 00000000..d3167dfd --- /dev/null +++ b/src/components/assistant-ui/thread/hooks/use-mention-menu.ts @@ -0,0 +1,153 @@ +"use client"; + +import { + useCallback, + useState, + type FormEvent, + type KeyboardEvent, + type RefObject, +} from "react"; +import type { ComposerActions } from "@/lib/chat/runtime"; +import type { Item } from "@/lib/workspace-state/types"; + +interface UseMentionMenuArgs { + inputRef: RefObject; + promptInput: ComposerActions | null; + onSelectItem: (item: Item) => void; +} + +interface UseMentionMenuResult { + mentionMenuOpen: boolean; + mentionQuery: string; + handleInput: (e: FormEvent) => void; + handleKeyDown: (e: KeyboardEvent) => void; + handleMentionMenuClose: (open: boolean) => void; + handleMentionSelect: (item: Item) => void; +} + +export function useMentionMenu({ + inputRef, + promptInput, + onSelectItem, +}: UseMentionMenuArgs): UseMentionMenuResult { + const [mentionMenuOpen, setMentionMenuOpen] = useState(false); + const [mentionQuery, setMentionQuery] = useState(""); + const [mentionStartIndex, setMentionStartIndex] = useState( + null, + ); + + const handleInput = useCallback( + (e: FormEvent) => { + const textarea = e.currentTarget; + const value = textarea.value; + const cursorPos = textarea.selectionStart ?? 0; + + if (mentionStartIndex !== null) { + const query = value.slice(mentionStartIndex + 1, cursorPos); + + if ( + cursorPos <= mentionStartIndex || + query.includes(" ") || + query.includes("\n") + ) { + setMentionMenuOpen(false); + setMentionStartIndex(null); + setMentionQuery(""); + } else { + setMentionQuery(query); + } + } + }, + [mentionStartIndex], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const textarea = e.currentTarget; + + if (e.key === "@" && !mentionMenuOpen) { + const cursorPos = textarea.selectionStart ?? 0; + const charBefore = cursorPos > 0 ? textarea.value[cursorPos - 1] : " "; + if (charBefore === " " || charBefore === "\n" || cursorPos === 0) { + setMentionMenuOpen(true); + setMentionStartIndex(cursorPos); + setMentionQuery(""); + } + } + + if (e.key === "Escape" && mentionMenuOpen) { + e.preventDefault(); + setMentionMenuOpen(false); + setMentionStartIndex(null); + setMentionQuery(""); + } + + if ( + mentionMenuOpen && + ["ArrowUp", "ArrowDown", "Enter"].includes(e.key) + ) { + e.preventDefault(); + } + }, + [mentionMenuOpen], + ); + + const clearMentionQuery = useCallback(() => { + if (mentionStartIndex !== null && inputRef.current) { + const textarea = inputRef.current; + const currentValue = textarea.value; + const atSymbolIndex = mentionStartIndex; + + let queryEndIndex = mentionStartIndex; + while ( + queryEndIndex < currentValue.length && + currentValue[queryEndIndex] !== " " && + currentValue[queryEndIndex] !== "\n" + ) { + queryEndIndex++; + } + + const textBefore = currentValue.substring(0, atSymbolIndex); + const textAfter = currentValue.substring(queryEndIndex); + const newValue = textBefore + textAfter; + + promptInput?.setText(newValue); + setMentionQuery(""); + setMentionStartIndex(null); + + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + const newCursorPos = textBefore.length; + inputRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + } + }, [inputRef, mentionStartIndex, promptInput]); + + const handleMentionSelect = useCallback( + (item: Item) => { + onSelectItem(item); + }, + [onSelectItem], + ); + + const handleMentionMenuClose = useCallback( + (open: boolean) => { + if (!open) { + clearMentionQuery(); + } + setMentionMenuOpen(open); + }, + [clearMentionQuery], + ); + + return { + mentionMenuOpen, + mentionQuery, + handleInput, + handleKeyDown, + handleMentionMenuClose, + handleMentionSelect, + }; +} diff --git a/src/components/assistant-ui/thread/hooks/use-prompt-input-paste.ts b/src/components/assistant-ui/thread/hooks/use-prompt-input-paste.ts new file mode 100644 index 00000000..190fa681 --- /dev/null +++ b/src/components/assistant-ui/thread/hooks/use-prompt-input-paste.ts @@ -0,0 +1,53 @@ +"use client"; + +import type { ClipboardEvent } from "react"; +import type { ComposerActions } from "@/lib/chat/runtime"; + +interface UsePromptInputPasteArgs { + promptInput: ComposerActions | null; + workspaceId: string | null; +} + +export function usePromptInputPaste({ + promptInput, + workspaceId, +}: UsePromptInputPasteArgs) { + return async function handlePaste(e: ClipboardEvent) { + const clipboardData = e.clipboardData; + if (!clipboardData || !workspaceId) return; + + const files = Array.from(clipboardData.files) as File[]; + + if (files.length > 0) { + e.preventDefault(); + const imageFile = files.find((file: File) => file.type.startsWith("image/")); + const fileToUpload = imageFile || files[0]; + + if (fileToUpload) { + try { + await promptInput?.addAttachment(fileToUpload); + } catch (error) { + console.error("Failed to add file attachment:", error); + } + } + return; + } + + const clipboardItems = Array.from(clipboardData.items) as DataTransferItem[]; + const imageItem = clipboardItems.find((item: DataTransferItem) => + item.type.startsWith("image/"), + ); + + if (imageItem) { + e.preventDefault(); + const file = imageItem.getAsFile(); + if (file) { + try { + await promptInput?.addAttachment(file); + } catch (error) { + console.error("Failed to add image attachment:", error); + } + } + } + }; +} diff --git a/src/components/assistant-ui/thread/index.ts b/src/components/assistant-ui/thread/index.ts new file mode 100644 index 00000000..a6166749 --- /dev/null +++ b/src/components/assistant-ui/thread/index.ts @@ -0,0 +1 @@ +export { Thread } from "./Thread"; diff --git a/src/components/assistant-ui/thread/message-components.ts b/src/components/assistant-ui/thread/message-components.ts new file mode 100644 index 00000000..bfc2cb17 --- /dev/null +++ b/src/components/assistant-ui/thread/message-components.ts @@ -0,0 +1,9 @@ +import { AssistantMessage } from "./AssistantMessage"; +import { EditPromptInput } from "./EditPromptInput"; +import { UserMessage } from "./UserMessage"; + +export const MESSAGE_COMPONENTS = { + UserMessage, + EditComposer: EditPromptInput, + AssistantMessage, +}; diff --git a/src/components/assistant-ui/thread/prompt-input-floating-actions.ts b/src/components/assistant-ui/thread/prompt-input-floating-actions.ts new file mode 100644 index 00000000..4795a167 --- /dev/null +++ b/src/components/assistant-ui/thread/prompt-input-floating-actions.ts @@ -0,0 +1,53 @@ +import { Brain, FileText, Play, Search } from "lucide-react"; +import { LuBook } from "react-icons/lu"; +import { PiCardsThreeBold } from "react-icons/pi"; +import type { PromptBuilderAction } from "@/components/assistant-ui/PromptBuilderDialog"; + +export const PROMPT_INPUT_FLOATING_ACTIONS = [ + { + id: "document", + label: "Document", + icon: FileText, + iconClassName: "size-3.5 shrink-0 text-sky-400", + action: "document" as PromptBuilderAction, + useDialog: true, + }, + { + id: "learn", + label: "Learn", + icon: LuBook, + iconClassName: "size-3.5 shrink-0 text-amber-500", + subActions: [ + { + id: "flashcards", + label: "Flashcards", + icon: PiCardsThreeBold, + iconClassName: "size-4 text-purple-400 rotate-180", + action: "flashcards" as PromptBuilderAction, + }, + { + id: "quiz", + label: "Quiz", + icon: Brain, + iconClassName: "size-4 text-green-400", + action: "quiz" as PromptBuilderAction, + }, + ], + }, + { + id: "youtube", + label: "YouTube", + icon: Play, + iconClassName: "size-3.5 text-red-500", + action: "youtube" as PromptBuilderAction, + useDialog: true, + }, + { + id: "search", + label: "Search", + icon: Search, + iconClassName: "size-3.5 text-teal-500", + action: "search" as PromptBuilderAction, + useDialog: true, + }, +]; diff --git a/src/components/assistant-ui/thread/suggestion-actions.ts b/src/components/assistant-ui/thread/suggestion-actions.ts new file mode 100644 index 00000000..0b135af8 --- /dev/null +++ b/src/components/assistant-ui/thread/suggestion-actions.ts @@ -0,0 +1,47 @@ +import { Brain, FileText, Play, Search, Upload } from "lucide-react"; +import { PiCardsThreeBold } from "react-icons/pi"; +import type { PromptBuilderAction } from "@/components/assistant-ui/PromptBuilderDialog"; + +export const SUGGESTION_ACTIONS = [ + { + title: "Search", + icon: Search, + iconClassName: "size-4 shrink-0 text-sky-500", + action: "search" as PromptBuilderAction, + useDialog: true, + }, + { + title: "Flashcards", + icon: PiCardsThreeBold, + iconClassName: "size-4 shrink-0 text-purple-400 rotate-180", + action: "flashcards" as PromptBuilderAction, + useDialog: true, + }, + { + title: "YouTube", + icon: Play, + iconClassName: "size-4 shrink-0 text-red-500", + action: "youtube" as PromptBuilderAction, + useDialog: true, + }, + { + title: "Upload", + icon: Upload, + iconClassName: "size-4 shrink-0 text-red-400", + triggerFileInput: true, + }, + { + title: "Quiz", + icon: Brain, + iconClassName: "size-4 shrink-0 text-green-400", + action: "quiz" as PromptBuilderAction, + useDialog: true, + }, + { + title: "Document", + icon: FileText, + iconClassName: "size-4 shrink-0 text-sky-400", + action: "document" as PromptBuilderAction, + useDialog: true, + }, +]; diff --git a/src/lib/chat/runtime/hooks.ts b/src/lib/chat/runtime/hooks.ts new file mode 100644 index 00000000..cf5e9652 --- /dev/null +++ b/src/lib/chat/runtime/hooks.ts @@ -0,0 +1,70 @@ +"use client"; + +import { + useAui, + useAuiState, + useMessage, + useMessagePartText, +} from "@assistant-ui/react"; +import type { + ChatMessage, + ComposerActions, + ComposerStateSnapshot, + ThreadState, +} from "./types"; + +export function useThreadState(): ThreadState { + return useAuiState((s: any) => ({ + messageCount: s.thread?.messages?.length ?? 0, + isLoading: !!s.thread?.isLoading, + isEmpty: s.thread?.isEmpty ?? true, + isRunning: !!s.thread?.isRunning, + })); +} + +export function useIsThreadLoading(): boolean { + return useAuiState((s: any) => !!s.thread?.isLoading); +} + +export function useIsThreadEmpty(): boolean { + return useAuiState((s: any) => s.thread?.isEmpty ?? true); +} + +export function useIsThreadRunning(): boolean { + return useAuiState((s: any) => !!s.thread?.isRunning); +} + +export function useThreadMessageCount(): number { + return useAuiState((s: any) => s.thread?.messages?.length ?? 0); +} + +export function useMainThreadId(): string | null { + return useAuiState((s: any) => s.threads?.mainThreadId ?? null); +} + +export function useHasPromptInputText(): boolean { + return useAuiState((s: any) => Boolean((s.composer?.text ?? "").trim())); +} + +export function useChatMessage(): ChatMessage { + const msg = useMessage(); + return msg as unknown as ChatMessage; +} + +export function useChatMessagePartText() { + return useMessagePartText(); +} + +export function usePromptInput(): ComposerActions | null { + const aui = useAui(); + if (!aui) return null; + const composer = aui.composer?.(); + if (!composer) return null; + return { + setText: (t) => composer.setText(t), + send: () => composer.send(), + addAttachment: (f) => composer.addAttachment(f), + setRunConfig: (cfg) => composer.setRunConfig(cfg as any), + getState: () => composer.getState() as unknown as ComposerStateSnapshot | undefined, + }; +} diff --git a/src/lib/chat/runtime/index.ts b/src/lib/chat/runtime/index.ts new file mode 100644 index 00000000..6e2e3156 --- /dev/null +++ b/src/lib/chat/runtime/index.ts @@ -0,0 +1,11 @@ +export type { + ChatMessage as ChatMessageData, + ChatMessagePart, + ChatMessageRole, + ChatTextPart, + ComposerActions, + ComposerStateSnapshot, + ThreadState, +} from "./types"; +export * from "./primitives"; +export * from "./hooks"; diff --git a/src/lib/chat/runtime/primitives.ts b/src/lib/chat/runtime/primitives.ts new file mode 100644 index 00000000..7f85ad6d --- /dev/null +++ b/src/lib/chat/runtime/primitives.ts @@ -0,0 +1,20 @@ +import { + ActionBarPrimitive, + AuiIf, + BranchPickerPrimitive, + ComposerPrimitive, + ErrorPrimitive, + MessagePrimitive, + ThreadPrimitive, +} from "@assistant-ui/react"; +import type { BranchPickerPrimitive as _BPP } from "@assistant-ui/react"; + +export const ChatThread = ThreadPrimitive; +export const ChatMessage = MessagePrimitive; +export const ChatPromptInput = ComposerPrimitive; +export const ChatActionBar = ActionBarPrimitive; +export const ChatBranchPicker = BranchPickerPrimitive; +export const ChatError = ErrorPrimitive; +export const ChatIf = AuiIf; + +export type ChatBranchPickerRootProps = _BPP.Root.Props; diff --git a/src/lib/chat/runtime/types.ts b/src/lib/chat/runtime/types.ts new file mode 100644 index 00000000..1907ca84 --- /dev/null +++ b/src/lib/chat/runtime/types.ts @@ -0,0 +1,45 @@ +export type ChatMessageRole = "user" | "assistant" | "system"; + +export type ChatTextPart = { type: "text"; text: string }; +export type ChatMessagePart = + | ChatTextPart + | { type: "file"; [k: string]: unknown } + | { type: "image"; [k: string]: unknown } + | { type: "source"; [k: string]: unknown } + | { type: "reasoning"; [k: string]: unknown } + | { type: "tool-call" | "tool-result" | "tool"; [k: string]: unknown } + | { type: string; [k: string]: unknown }; + +export interface ChatMessage { + id?: string; + role: ChatMessageRole; + content: ChatMessagePart[]; + metadata?: { custom?: Record; [k: string]: unknown }; +} + +export interface ThreadState { + messageCount: number; + isLoading: boolean; + isEmpty: boolean; + isRunning: boolean; +} + +export interface ComposerStateSnapshot { + text: string; + attachments: Array<{ + id?: string; + file?: File; + name?: string; + type?: string; + [k: string]: unknown; + }>; + runConfig?: { custom?: Record }; +} + +export interface ComposerActions { + setText(text: string): void; + send(): void; + addAttachment(file: File): Promise; + setRunConfig(config: { custom?: Record }): void; + getState(): ComposerStateSnapshot | undefined; +} diff --git a/src/lib/uploads/process-pdf-attachments-in-background.ts b/src/lib/uploads/process-pdf-attachments-in-background.ts new file mode 100644 index 00000000..89f77204 --- /dev/null +++ b/src/lib/uploads/process-pdf-attachments-in-background.ts @@ -0,0 +1,67 @@ +import { toast } from "sonner"; +import { buildWorkspaceItemDefinitionsFromAssets } from "@/lib/uploads/uploaded-asset"; +import { uploadSelectedFiles } from "@/lib/uploads/upload-selection"; +import { startAssetProcessing } from "@/lib/uploads/start-asset-processing"; +import { + getDocumentUploadFailureMessage, + getDocumentUploadPartialMessage, + getDocumentUploadSuccessMessage, +} from "@/lib/uploads/upload-feedback"; + +interface PdfAttachmentLike { + file?: File; +} + +interface WorkspaceOperationsLike { + createItems: ( + defs: ReturnType, + options?: { showSuccessToast?: boolean }, + ) => string[]; +} + +export async function processPdfAttachmentsInBackground( + pdfAttachments: PdfAttachmentLike[], + workspaceId: string, + operations: WorkspaceOperationsLike, +) { + let files: File[] = []; + try { + files = pdfAttachments + .map((attachment) => attachment.file) + .filter((file): file is File => !!file); + const { uploads, failedFiles } = await uploadSelectedFiles(files); + + if (uploads.length > 0) { + const pdfCardDefinitions = buildWorkspaceItemDefinitionsFromAssets(uploads); + const createdIds = operations.createItems(pdfCardDefinitions, { + showSuccessToast: false, + }); + + void startAssetProcessing({ + workspaceId, + assets: uploads, + itemIds: createdIds, + onOcrError: (error) => { + console.error("Error starting assistant file processing:", error); + }, + }); + + if (failedFiles.length === 0) { + toast.success(getDocumentUploadSuccessMessage(uploads.length)); + } else { + toast.warning( + getDocumentUploadPartialMessage(uploads.length, failedFiles.length), + ); + } + } else { + toast.error( + getDocumentUploadFailureMessage(failedFiles.length || files.length), + ); + } + } catch (error) { + console.error("Error creating PDF cards in background:", error); + toast.error( + getDocumentUploadFailureMessage(files.length || pdfAttachments.length), + ); + } +} From 57d1c58f9c50acc7fed1912dda2457a3278b67ca Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:50:02 +0000 Subject: [PATCH 2/7] Phase 2a: migrate rendering primitives (markdown-text, reasoning, tool-group, assistant-loader, file, image, sources, tool-fallback) to chat-runtime ACL --- .../assistant-ui/assistant-loader.tsx | 11 +-- src/components/assistant-ui/file.tsx | 6 +- src/components/assistant-ui/image.tsx | 6 +- src/components/assistant-ui/markdown-text.tsx | 15 ++-- src/components/assistant-ui/reasoning.tsx | 44 +++------- src/components/assistant-ui/sources.tsx | 6 +- src/components/assistant-ui/tool-fallback.tsx | 4 +- src/components/assistant-ui/tool-group.tsx | 23 ++--- src/lib/chat/runtime/hooks.ts | 85 +++++++++++++++++++ src/lib/chat/runtime/index.ts | 7 ++ src/lib/chat/runtime/types.ts | 21 +++++ 11 files changed, 159 insertions(+), 69 deletions(-) diff --git a/src/components/assistant-ui/assistant-loader.tsx b/src/components/assistant-ui/assistant-loader.tsx index 0ae83746..dc7bfc93 100644 --- a/src/components/assistant-ui/assistant-loader.tsx +++ b/src/components/assistant-ui/assistant-loader.tsx @@ -1,19 +1,14 @@ "use client"; -import { useAuiState } from "@assistant-ui/react"; +import { useIsMessageEmpty, useIsMessageRunning } from "@/lib/chat/runtime"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; import { useTheme } from "next-themes"; export const AssistantLoader = () => { const { resolvedTheme } = useTheme(); - const isRunning = useAuiState( - ({ message }) => (message as { status?: { type: string } })?.status?.type === "running" - ); + const isRunning = useIsMessageRunning(); - const isMessageEmpty = useAuiState(({ message }) => { - const msg = message as any; - return !msg?.content || (Array.isArray(msg.content) && msg.content.length === 0); - }); + const isMessageEmpty = useIsMessageEmpty(); if (!isRunning || !isMessageEmpty) return null; diff --git a/src/components/assistant-ui/file.tsx b/src/components/assistant-ui/file.tsx index 92d93a1b..cf8db319 100644 --- a/src/components/assistant-ui/file.tsx +++ b/src/components/assistant-ui/file.tsx @@ -11,7 +11,7 @@ import { BracesIcon, DownloadIcon, } from "lucide-react"; -import type { FileMessagePartComponent } from "@assistant-ui/react"; +import type { ChatFilePartComponent } from "@/lib/chat/runtime"; import { cn } from "@/lib/utils"; const fileVariants = cva( @@ -187,7 +187,7 @@ function FileDownload({ ); } -const FileImpl: FileMessagePartComponent = ({ filename, data, mimeType }) => { +const FileImpl: ChatFilePartComponent = ({ filename, data, mimeType }) => { const bytes = getBase64Size(data); return ( @@ -206,7 +206,7 @@ const FileImpl: FileMessagePartComponent = ({ filename, data, mimeType }) => { ); }; -const File = memo(FileImpl) as unknown as FileMessagePartComponent & { +const File = memo(FileImpl) as unknown as ChatFilePartComponent & { Root: typeof FileRoot; Icon: typeof FileIconDisplay; Name: typeof FileName; diff --git a/src/components/assistant-ui/image.tsx b/src/components/assistant-ui/image.tsx index 6f7b6ee6..da93c891 100644 --- a/src/components/assistant-ui/image.tsx +++ b/src/components/assistant-ui/image.tsx @@ -10,7 +10,7 @@ import { import { createPortal } from "react-dom"; import { cva, type VariantProps } from "class-variance-authority"; import { ImageIcon, ImageOffIcon } from "lucide-react"; -import type { ImageMessagePartComponent } from "@assistant-ui/react"; +import type { ChatImagePartComponent } from "@/lib/chat/runtime"; import { cn } from "@/lib/utils"; const imageVariants = cva( @@ -230,7 +230,7 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) { ); } -const ImageImpl: ImageMessagePartComponent = ({ image, filename }) => { +const ImageImpl: ChatImagePartComponent = ({ image, filename }) => { return ( @@ -241,7 +241,7 @@ const ImageImpl: ImageMessagePartComponent = ({ image, filename }) => { ); }; -const Image = memo(ImageImpl) as unknown as ImageMessagePartComponent & { +const Image = memo(ImageImpl) as unknown as ChatImagePartComponent & { Root: typeof ImageRoot; Preview: typeof ImagePreview; Filename: typeof ImageFilename; diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index ba0518b9..f9f13ec6 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -5,7 +5,12 @@ import "streamdown/styles.css"; import { createCodePlugin } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; import { createMathPlugin } from "@streamdown/math"; -import { useMessagePartText, useAuiState, type TextMessagePartProps } from "@assistant-ui/react"; +import { + useChatMessagePartText, + useCurrentMessageId, + useMainThreadId, + type ChatTextPartProps, +} from "@/lib/chat/runtime"; import { Children, isValidElement, @@ -164,17 +169,17 @@ const CitationRenderer = memo( CitationRenderer.displayName = "CitationRenderer"; /** Props from assistant-ui when used as Text component, or optional when used directly (e.g. in Reasoning) */ -type MarkdownTextProps = Partial & { +type MarkdownTextProps = Partial & { /** Use "reasoning" for smoother streaming in reasoning blocks (blurIn, longer duration) */ streamingVariant?: "default" | "reasoning"; }; const MarkdownTextImpl = (props: MarkdownTextProps) => { const streamingVariant = props.streamingVariant ?? "default"; - const { text, status } = useMessagePartText(); + const { text, status } = useChatMessagePartText(); - const threadId = useAuiState(({ threads }) => (threads as any)?.mainThreadId); - const messageId = useAuiState(({ message }) => (message as any)?.id); + const threadId = useMainThreadId(); + const messageId = useCurrentMessageId(); const animateConfig = streamingVariant === "reasoning" diff --git a/src/components/assistant-ui/reasoning.tsx b/src/components/assistant-ui/reasoning.tsx index 052a58dc..9314e47b 100644 --- a/src/components/assistant-ui/reasoning.tsx +++ b/src/components/assistant-ui/reasoning.tsx @@ -4,11 +4,13 @@ import { memo, useCallback, useRef, useState, useEffect, useLayoutEffect, forwar import { cva, type VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { - useScrollLock, - useAuiState, - type ReasoningMessagePartComponent, - type ReasoningGroupComponent, -} from "@assistant-ui/react"; + useChatScrollLock, + useIsLastMessage, + useIsMessagePartStreaming, + useMessagePartTextLengthSnapshot, + type ChatReasoningGroupComponent, + type ChatReasoningPartComponent, +} from "@/lib/chat/runtime"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { Collapsible, @@ -53,7 +55,7 @@ function ReasoningRoot({ }: ReasoningRootProps) { const collapsibleRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useChatScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; @@ -216,40 +218,22 @@ const ReasoningText = forwardRef>( } ); -const ReasoningImpl: ReasoningMessagePartComponent = () => ( +const ReasoningImpl: ChatReasoningPartComponent = () => ( ); -const ReasoningGroupImpl: ReasoningGroupComponent = ({ +const ReasoningGroupImpl: ChatReasoningGroupComponent = ({ children, startIndex, endIndex, }) => { const textContainerRef = useRef(null); - const isReasoningStreaming = useAuiState(({ message }) => { - if (message.status?.type !== "running") return false; - const lastIndex = message.parts.length - 1; - if (lastIndex < 0) return false; - const lastType = message.parts[lastIndex]?.type; - if (lastType !== "reasoning") return false; - return lastIndex >= startIndex && lastIndex <= endIndex; - }); + const isReasoningStreaming = useIsMessagePartStreaming("reasoning", startIndex, endIndex); - const isLastMessage = useAuiState(({ thread, message }) => { - const messages = (thread as unknown as { messages?: Array<{ id?: string }> })?.messages ?? []; - const idx = messages.findIndex((m) => m.id === message.id); - return idx >= 0 && idx === messages.length - 1; - }); + const isLastMessage = useIsLastMessage(); // Subscribe to reasoning text length so we re-run scroll effect on each stream chunk - const reasoningTextSnapshot = useAuiState(({ message }) => { - let len = 0; - for (let i = startIndex; i <= endIndex && i < message.parts.length; i++) { - const p = message.parts[i] as { type?: string; text?: string } | undefined; - if (p?.type === "reasoning" && typeof p.text === "string") len += p.text.length; - } - return len; - }); + const reasoningTextSnapshot = useMessagePartTextLengthSnapshot("reasoning", startIndex, endIndex); const [isManuallyOpen, setIsManuallyOpen] = useState(false); const isOpen = isReasoningStreaming || isManuallyOpen; @@ -296,7 +280,7 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ const Reasoning = memo( ReasoningImpl, -) as unknown as ReasoningMessagePartComponent & { +) as unknown as ChatReasoningPartComponent & { Root: typeof ReasoningRoot; Trigger: typeof ReasoningTrigger; Content: typeof ReasoningContent; diff --git a/src/components/assistant-ui/sources.tsx b/src/components/assistant-ui/sources.tsx index 51273b67..09e5501d 100644 --- a/src/components/assistant-ui/sources.tsx +++ b/src/components/assistant-ui/sources.tsx @@ -3,7 +3,7 @@ import { memo, useState } from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import type { SourceMessagePartComponent } from "@assistant-ui/react"; +import type { ChatSourcePartComponent } from "@/lib/chat/runtime"; import { cn } from "@/lib/utils"; const extractDomain = (url: string): string => { @@ -116,7 +116,7 @@ function Source({ ); } -const SourcesImpl: SourceMessagePartComponent = ({ +const SourcesImpl: ChatSourcePartComponent = ({ url, title, sourceType, @@ -134,7 +134,7 @@ const SourcesImpl: SourceMessagePartComponent = ({ ); }; -const Sources = memo(SourcesImpl) as unknown as SourceMessagePartComponent & { +const Sources = memo(SourcesImpl) as unknown as ChatSourcePartComponent & { Root: typeof Source; Icon: typeof SourceIcon; Title: typeof SourceTitle; diff --git a/src/components/assistant-ui/tool-fallback.tsx b/src/components/assistant-ui/tool-fallback.tsx index aca40305..5da1bf67 100644 --- a/src/components/assistant-ui/tool-fallback.tsx +++ b/src/components/assistant-ui/tool-fallback.tsx @@ -1,9 +1,9 @@ -import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import type { ChatToolCallPartComponent } from "@/lib/chat/runtime"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; -export const ToolFallback: ToolCallMessagePartComponent = ({ +export const ToolFallback: ChatToolCallPartComponent = ({ toolName, argsText, result, diff --git a/src/components/assistant-ui/tool-group.tsx b/src/components/assistant-ui/tool-group.tsx index dfa28481..8ab66bfb 100644 --- a/src/components/assistant-ui/tool-group.tsx +++ b/src/components/assistant-ui/tool-group.tsx @@ -11,7 +11,11 @@ import { } from "react"; import { ChevronDownIcon, LoaderIcon } from "lucide-react"; import { cva, type VariantProps } from "class-variance-authority"; -import { useAuiState, useScrollLock } from "@assistant-ui/react"; +import { + useChatScrollLock, + useIsLastMessage, + useIsMessagePartStreaming, +} from "@/lib/chat/runtime"; import { Collapsible, CollapsibleContent, @@ -53,7 +57,7 @@ function ToolGroupRoot({ }: ToolGroupRootProps) { const collapsibleRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useChatScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; @@ -192,20 +196,9 @@ const ToolGroupImpl: FC< // Match `ReasoningGroup` behavior: mark active while the *current streaming part* // is a tool-call within this group's index range. - const isToolGroupStreaming = useAuiState(({ message }) => { - if (message.status?.type !== "running") return false; - const lastIndex = message.parts.length - 1; - if (lastIndex < 0) return false; - const lastType = message.parts[lastIndex]?.type; - if (lastType !== "tool-call") return false; - return lastIndex >= startIndex && lastIndex <= endIndex; - }); + const isToolGroupStreaming = useIsMessagePartStreaming("tool-call", startIndex, endIndex); - const isLastMessage = useAuiState(({ thread, message }) => { - const messages = (thread as unknown as { messages?: Array<{ id?: string }> })?.messages ?? []; - const idx = messages.findIndex((m) => m.id === message.id); - return idx >= 0 && idx === messages.length - 1; - }); + const isLastMessage = useIsLastMessage(); const [isManuallyOpen, setIsManuallyOpen] = useState(isLastMessage); const isOpen = isToolGroupStreaming || isManuallyOpen; diff --git a/src/lib/chat/runtime/hooks.ts b/src/lib/chat/runtime/hooks.ts index cf5e9652..41ded0d3 100644 --- a/src/lib/chat/runtime/hooks.ts +++ b/src/lib/chat/runtime/hooks.ts @@ -5,6 +5,7 @@ import { useAuiState, useMessage, useMessagePartText, + useScrollLock, } from "@assistant-ui/react"; import type { ChatMessage, @@ -68,3 +69,87 @@ export function usePromptInput(): ComposerActions | null { getState: () => composer.getState() as unknown as ComposerStateSnapshot | undefined, }; } +/** + * Lock the given element's scroll position for a fixed duration. Used when + * collapsing streaming content to prevent scroll jumps. + */ +export const useChatScrollLock = useScrollLock; + +/** Current assistant/user message id from MessageRuntime context. */ +export function useCurrentMessageId(): string | null { + return useAuiState((s: any) => (s.message as { id?: string } | undefined)?.id ?? null); +} + +/** True while this message is actively streaming. */ +export function useIsMessageRunning(): boolean { + return useAuiState((s: any) => (s.message as { status?: { type?: string } } | undefined)?.status?.type === "running"); +} + +/** + * True when the current message has no content parts — used to show loading + * states before the first token arrives. + */ +export function useIsMessageEmpty(): boolean { + return useAuiState((s: any) => { + const msg = s.message as { content?: unknown[] } | undefined; + return !msg?.content || (Array.isArray(msg.content) && msg.content.length === 0); + }); +} + +/** + * True when the current message is the last message in the thread. + * Used to gate old reasoning/tool groups from rendering once newer messages exist. + */ +export function useIsLastMessage(): boolean { + return useAuiState((s: any) => { + const thread = s.thread as { messages?: Array<{ id?: string }> } | undefined; + const messageId = (s.message as { id?: string } | undefined)?.id; + const messages = thread?.messages ?? []; + const idx = messages.findIndex((m) => m.id === messageId); + return idx >= 0 && idx === messages.length - 1; + }); +} + +/** + * True when the current streaming part is of `partType` and falls within + * [startIndex, endIndex]. Shared by ReasoningGroup and ToolGroup to detect + * whether the group is actively streaming. + */ +export function useIsMessagePartStreaming( + partType: string, + startIndex: number, + endIndex: number, +): boolean { + return useAuiState((s: any) => { + const msg = s.message as + | { status?: { type?: string }; parts?: Array<{ type?: string }> } + | undefined; + if (msg?.status?.type !== "running") return false; + const parts = msg.parts ?? []; + const lastIndex = parts.length - 1; + if (lastIndex < 0) return false; + const lastType = parts[lastIndex]?.type; + if (lastType !== partType) return false; + return lastIndex >= startIndex && lastIndex <= endIndex; + }); +} + +/** + * Sum the `.text` length of all parts of `partType` within [startIndex, endIndex]. + * Used to force re-renders of scroll effects as streaming text grows. + */ +export function useMessagePartTextLengthSnapshot( + partType: string, + startIndex: number, + endIndex: number, +): number { + return useAuiState((s: any) => { + const parts = (s.message as { parts?: Array<{ type?: string; text?: string }> } | undefined)?.parts ?? []; + let len = 0; + for (let i = startIndex; i <= endIndex && i < parts.length; i++) { + const p = parts[i]; + if (p?.type === partType && typeof p.text === "string") len += p.text.length; + } + return len; + }); +} diff --git a/src/lib/chat/runtime/index.ts b/src/lib/chat/runtime/index.ts index 6e2e3156..e6e790d1 100644 --- a/src/lib/chat/runtime/index.ts +++ b/src/lib/chat/runtime/index.ts @@ -6,6 +6,13 @@ export type { ComposerActions, ComposerStateSnapshot, ThreadState, + ChatFilePartComponent, + ChatImagePartComponent, + ChatReasoningGroupComponent, + ChatReasoningPartComponent, + ChatSourcePartComponent, + ChatTextPartProps, + ChatToolCallPartComponent, } from "./types"; export * from "./primitives"; export * from "./hooks"; diff --git a/src/lib/chat/runtime/types.ts b/src/lib/chat/runtime/types.ts index 1907ca84..e0df171e 100644 --- a/src/lib/chat/runtime/types.ts +++ b/src/lib/chat/runtime/types.ts @@ -43,3 +43,24 @@ export interface ComposerActions { setRunConfig(config: { custom?: Record }): void; getState(): ComposerStateSnapshot | undefined; } +import type { + TextMessagePartProps, + FileMessagePartComponent, + ImageMessagePartComponent, + SourceMessagePartComponent, + ToolCallMessagePartComponent, + ReasoningMessagePartComponent, + ReasoningGroupComponent, +} from "@assistant-ui/react"; + +/** + * Type re-exports — the ACL owns the identity of these types so consumers never + * import them directly from @assistant-ui/react. + */ +export type ChatTextPartProps = TextMessagePartProps; +export type ChatFilePartComponent = FileMessagePartComponent; +export type ChatImagePartComponent = ImageMessagePartComponent; +export type ChatSourcePartComponent = SourceMessagePartComponent; +export type ChatToolCallPartComponent = ToolCallMessagePartComponent; +export type ChatReasoningPartComponent = ReasoningMessagePartComponent; +export type ChatReasoningGroupComponent = ReasoningGroupComponent; From bb4a10b27f200ddd5e9ed6e2324742bbbf40c275 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:05:46 +0000 Subject: [PATCH 3/7] Phase 2b: migrate attachment.tsx to chat-runtime ACL (adds useAttachmentSnapshot, useAttachmentScope, useAttachmentId, useIsAttachmentImage, ChatAttachment primitive) --- src/components/assistant-ui/attachment.tsx | 157 +++++++-------------- src/lib/chat/runtime/hooks.ts | 44 ++++++ src/lib/chat/runtime/primitives.ts | 2 + src/lib/chat/runtime/types.ts | 38 +++-- 4 files changed, 128 insertions(+), 113 deletions(-) diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index d1031709..91df9317 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -10,23 +10,21 @@ import React, { import { XIcon, Link as LinkIcon, - SearchIcon, - Plus, - Code as CodeIcon, - GalleryHorizontalEnd, FileText, Loader2, } from "lucide-react"; import { LuPaperclip } from "react-icons/lu"; import { toast } from "sonner"; import { - AttachmentPrimitive, - ComposerPrimitive, - MessagePrimitive, - useAui, -} from "@assistant-ui/react"; -import { useAuiState } from "@assistant-ui/react"; -import { useShallow } from "zustand/shallow"; + ChatAttachment, + ChatMessage, + ChatPromptInput, + useAttachmentId, + useAttachmentScope, + useAttachmentSnapshot, + useIsAttachmentImage, + usePromptInput, +} from "@/lib/chat/runtime"; import { Tooltip, TooltipContent, @@ -42,10 +40,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; -import { useUIStore } from "@/lib/stores/ui-store"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; -import { FaCheck } from "react-icons/fa"; const useFileSrc = (file: File | undefined) => { const [src, setSrc] = useState(undefined); @@ -109,46 +105,29 @@ function isUrlAttachment(att: UrlLikeAttachment | undefined): boolean { } const useAttachmentSrc = () => { - const attachmentState = useAuiState( - useShallow( - ({ - attachment, - }): { file?: File; src?: string; isUrl?: boolean; url?: string } => { - const att = attachment as - | { - type?: string; - name?: string; - file?: File & { name: string }; - content?: Array<{ type: string; text?: string; image?: string }>; - } - | undefined; - if (!att) - return { - file: undefined, - src: undefined, - isUrl: false, - url: undefined, - }; - if (isUrlAttachment(att)) { - const url = getHttpAttachmentUrl(att); - if (url) { - return { isUrl: true, src: getFaviconUrl(url), url }; - } - return { isUrl: true }; - } + const att = useAttachmentSnapshot(); - if (att.type !== "image") return {}; - if (att.file) return { file: att.file }; - const imageContent = att.content?.find( - (c: { type: string }) => c.type === "image", - ) as { type: "image"; image: string } | undefined; - if (imageContent?.image) { - return { src: imageContent.image }; - } - return {}; - }, - ), - ); + const attachmentState: { file?: File; src?: string; isUrl?: boolean; url?: string } = (() => { + if (!att) { + return { file: undefined, src: undefined, isUrl: false, url: undefined }; + } + if (isUrlAttachment(att)) { + const url = getHttpAttachmentUrl(att); + if (url) { + return { isUrl: true, src: getFaviconUrl(url), url }; + } + return { isUrl: true }; + } + if (att.type !== "image") return {}; + if (att.file) return { file: att.file }; + const imageContent = att.content?.find( + (c: { type: string }) => c.type === "image", + ) as { type: "image"; image: string } | undefined; + if (imageContent?.image) { + return { src: imageContent.image }; + } + return {}; + })(); return { src: useFileSrc(attachmentState.file) ?? attachmentState.src, @@ -205,9 +184,7 @@ const AttachmentPreviewDialog: FC = ({ children }) => { }; const AttachmentThumb: FC = () => { - const isImage = useAuiState( - ({ attachment }) => (attachment as { type?: string })?.type === "image", - ); + const isImage = useIsAttachmentImage(); const attachmentSrc = useAttachmentSrc(); const { src, isUrl } = attachmentSrc; @@ -253,34 +230,21 @@ const AttachmentThumb: FC = () => { }; const AttachmentUI: FC = () => { - const aui = useAui(); - const isComposer = aui.attachment.source === "composer"; - const attachmentId = useAuiState( - ({ attachment }) => (attachment as { id?: string })?.id, - ); + const scope = useAttachmentScope(); + const isComposer = scope === "composer"; + const attachmentId = useAttachmentId(); const isUploading = useAttachmentUploadStore( (s) => attachmentId != null && s.uploadingIds.has(attachmentId), ); + const isImage = useIsAttachmentImage(); - const isImage = useAuiState( - ({ attachment }) => (attachment as { type?: string })?.type === "image", - ); + // Snapshot is memoized in the ACL, so deriving multiple values from it is cheap + const attSnapshot = useAttachmentSnapshot(); - // Split into separate selectors to avoid creating new objects on each render - const typeLabel = useAuiState(({ attachment }) => { - const att = attachment as - | { - type?: string; - name?: string; - file?: { name: string }; - content?: Array<{ type: string; text?: string }>; - } - | undefined; + const typeLabel = (() => { + const att = attSnapshot; if (!att) return "File"; - if (isUrlAttachment(att)) { - return "URL"; - } - + if (isUrlAttachment(att)) return "URL"; const type = att.type; switch (type) { case "image": @@ -292,24 +256,11 @@ const AttachmentUI: FC = () => { default: return "File"; } - }); - - const isUrl = useAuiState(({ attachment }) => { - const att = attachment as - | { - type?: string; - name?: string; - file?: { name: string }; - content?: Array<{ type: string; text?: string }>; - } - | undefined; - if (!att) return false; - return isUrlAttachment(att); - }); + })(); return ( - {
{!(isComposer && isUploading) && (
- +
)} - + - + ); @@ -365,7 +316,7 @@ const AttachmentRemove: FC = () => { const { isUrl } = useAttachmentSrc(); return ( - + { > - + ); }; export const UserMessageAttachments: FC = () => { return (
- +
); }; @@ -388,9 +339,7 @@ export const UserMessageAttachments: FC = () => { export const PromptInputAttachments: FC = () => { return (
- +
); }; @@ -398,7 +347,7 @@ export const PromptInputAttachments: FC = () => { export const PromptInputAddAttachment: FC = () => { const fileInputRef = useRef(null); const containerRef = useRef(null); - const aui = useAui(); + const promptInput = usePromptInput(); const handleFileChange = async (e: React.ChangeEvent) => { const files = e.target.files; @@ -465,7 +414,7 @@ export const PromptInputAddAttachment: FC = () => { // Add valid files (non–Office-doc, non–password-protected) — others still upload successfully if (filesToAdd.length > 0) { filesToAdd.forEach((file) => { - aui.composer().addAttachment(file); + promptInput?.addAttachment(file); }); if (filesToAdd.length < fileArray.length) { @@ -480,7 +429,7 @@ export const PromptInputAddAttachment: FC = () => { } }; - const uploadInputId = "prompt-input-file-upload"; + const uploadInputId = "composer-file-upload"; return ( <> diff --git a/src/lib/chat/runtime/hooks.ts b/src/lib/chat/runtime/hooks.ts index 41ded0d3..9ca0bb3f 100644 --- a/src/lib/chat/runtime/hooks.ts +++ b/src/lib/chat/runtime/hooks.ts @@ -7,10 +7,13 @@ import { useMessagePartText, useScrollLock, } from "@assistant-ui/react"; +import { useShallow } from "zustand/shallow"; import type { + ChatAttachmentSnapshot, ChatMessage, ComposerActions, ComposerStateSnapshot, + AttachmentScope, ThreadState, } from "./types"; @@ -153,3 +156,44 @@ export function useMessagePartTextLengthSnapshot( return len; }); } + +/** + * Scope of the current attachment ("composer" for user uploads, "message" for rendered attachments in a chat message). + * Reads `aui.attachment.source` from the AttachmentPrimitive context. + */ +export function useAttachmentScope(): AttachmentScope { + const aui = useAui(); + const source = (aui as unknown as { attachment?: { source?: string } } | null)?.attachment?.source; + return source === "composer" ? "composer" : "message"; +} + +/** Attachment id from AttachmentPrimitive context. */ +export function useAttachmentId(): string | undefined { + return useAuiState((s: any) => (s.attachment as { id?: string } | undefined)?.id); +} + +/** Whether the current attachment is an image. */ +export function useIsAttachmentImage(): boolean { + return useAuiState((s: any) => (s.attachment as { type?: string } | undefined)?.type === "image"); +} + +/** + * Shallow-equal snapshot of the current attachment object. The returned object + * is stable across renders when the underlying assistant-ui attachment state + * hasn't changed. + */ +export function useAttachmentSnapshot(): ChatAttachmentSnapshot | undefined { + return useAuiState( + useShallow((s: any) => { + const att = s.attachment as ChatAttachmentSnapshot | undefined; + if (!att) return undefined; + return { + id: att.id, + type: att.type, + name: att.name, + file: att.file, + content: att.content, + }; + }), + ); +} diff --git a/src/lib/chat/runtime/primitives.ts b/src/lib/chat/runtime/primitives.ts index 7f85ad6d..b7e312dc 100644 --- a/src/lib/chat/runtime/primitives.ts +++ b/src/lib/chat/runtime/primitives.ts @@ -1,5 +1,6 @@ import { ActionBarPrimitive, + AttachmentPrimitive, AuiIf, BranchPickerPrimitive, ComposerPrimitive, @@ -12,6 +13,7 @@ import type { BranchPickerPrimitive as _BPP } from "@assistant-ui/react"; export const ChatThread = ThreadPrimitive; export const ChatMessage = MessagePrimitive; export const ChatPromptInput = ComposerPrimitive; +export const ChatAttachment = AttachmentPrimitive; export const ChatActionBar = ActionBarPrimitive; export const ChatBranchPicker = BranchPickerPrimitive; export const ChatError = ErrorPrimitive; diff --git a/src/lib/chat/runtime/types.ts b/src/lib/chat/runtime/types.ts index e0df171e..2cd22608 100644 --- a/src/lib/chat/runtime/types.ts +++ b/src/lib/chat/runtime/types.ts @@ -1,3 +1,14 @@ +import type { + AttachmentPrimitive as _AP, + TextMessagePartProps, + FileMessagePartComponent, + ImageMessagePartComponent, + SourceMessagePartComponent, + ToolCallMessagePartComponent, + ReasoningMessagePartComponent, + ReasoningGroupComponent, +} from '@assistant-ui/react'; + export type ChatMessageRole = "user" | "assistant" | "system"; export type ChatTextPart = { type: "text"; text: string }; @@ -43,15 +54,6 @@ export interface ComposerActions { setRunConfig(config: { custom?: Record }): void; getState(): ComposerStateSnapshot | undefined; } -import type { - TextMessagePartProps, - FileMessagePartComponent, - ImageMessagePartComponent, - SourceMessagePartComponent, - ToolCallMessagePartComponent, - ReasoningMessagePartComponent, - ReasoningGroupComponent, -} from "@assistant-ui/react"; /** * Type re-exports — the ACL owns the identity of these types so consumers never @@ -64,3 +66,21 @@ export type ChatSourcePartComponent = SourceMessagePartComponent; export type ChatToolCallPartComponent = ToolCallMessagePartComponent; export type ChatReasoningPartComponent = ReasoningMessagePartComponent; export type ChatReasoningGroupComponent = ReasoningGroupComponent; + +/** Scope an attachment belongs to — either the user composer or a rendered message. */ +export type AttachmentScope = "composer" | "message"; + +/** + * Snapshot of the current attachment in AttachmentPrimitive context. Read by + * AttachmentThumb / AttachmentUI to render previews, labels, and upload states. + */ +export interface ChatAttachmentSnapshot { + id?: string; + type?: string; + name?: string; + file?: File & { name: string }; + content?: Array<{ type: string; text?: string; image?: string }>; +} + +/** Props for the neutral ChatAttachment primitive — mirrors AttachmentPrimitive.Root. */ +export type ChatAttachmentRootProps = _AP.Root.Props; From 14478d712bb1defc62c107cd3aaad815b9318d19 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:18:51 +0000 Subject: [PATCH 4/7] Phase 2c: migrate 11 tool-UI files to chat-runtime ACL; fix Welcome Upload button id drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AddYoutubeVideoToolUI, CreateDocumentToolUI, CreateFlashcardToolUI, CreateQuizToolUI, EditItemToolUI, ExecuteCodeToolUI, ReadWorkspaceToolUI, SearchWorkspaceToolUI, URLContextToolUI, WebSearchToolUI, YouTubeSearchToolUI now use ChatToolUIProps and useChatScrollLock from @/lib/chat/runtime instead of importing from @assistant-ui/react directly. - Added ChatToolUIProps type alias to the ACL. - Fixed bug: Welcome-screen Upload suggestion clicked getElementById("prompt-input-file-upload") but attachment.tsx rendered id="composer-file-upload" — IDs now aligned. - Dropped unused useAui import from YouTubeSearchToolUI. --- src/components/assistant-ui/AddYoutubeVideoToolUI.tsx | 4 ++-- src/components/assistant-ui/CreateDocumentToolUI.tsx | 4 ++-- src/components/assistant-ui/CreateFlashcardToolUI.tsx | 4 ++-- src/components/assistant-ui/CreateQuizToolUI.tsx | 4 ++-- src/components/assistant-ui/EditItemToolUI.tsx | 4 ++-- src/components/assistant-ui/ExecuteCodeToolUI.tsx | 4 ++-- src/components/assistant-ui/ReadWorkspaceToolUI.tsx | 4 ++-- src/components/assistant-ui/SearchWorkspaceToolUI.tsx | 4 ++-- src/components/assistant-ui/URLContextToolUI.tsx | 6 +++--- src/components/assistant-ui/WebSearchToolUI.tsx | 6 +++--- src/components/assistant-ui/YouTubeSearchToolUI.tsx | 6 +++--- src/components/assistant-ui/attachment.tsx | 2 +- src/lib/chat/runtime/index.ts | 1 + src/lib/chat/runtime/types.ts | 2 ++ 14 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx b/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx index 77702116..535d421c 100644 --- a/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx +++ b/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { X, Eye, Play } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -107,7 +107,7 @@ const AddYoutubeVideoReceipt = ({ ); }; -export const renderAddYoutubeVideoToolUI: AssistantToolUIProps< +export const renderAddYoutubeVideoToolUI: ChatToolUIProps< AddYoutubeVideoArgs, WorkspaceResult >["render"] = ({ args, result, status }) => { diff --git a/src/components/assistant-ui/CreateDocumentToolUI.tsx b/src/components/assistant-ui/CreateDocumentToolUI.tsx index 3203300b..dfae445c 100644 --- a/src/components/assistant-ui/CreateDocumentToolUI.tsx +++ b/src/components/assistant-ui/CreateDocumentToolUI.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { X, Eye, FolderInput, FileText } from "lucide-react"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Button } from "@/components/ui/button"; @@ -179,7 +179,7 @@ const CreateDocumentReceipt = ({ ); }; -export const renderCreateDocumentToolUI: AssistantToolUIProps< +export const renderCreateDocumentToolUI: ChatToolUIProps< CreateDocumentArgs, WorkspaceResult >["render"] = (props) => { diff --git a/src/components/assistant-ui/CreateFlashcardToolUI.tsx b/src/components/assistant-ui/CreateFlashcardToolUI.tsx index 52d46cc3..1992e187 100644 --- a/src/components/assistant-ui/CreateFlashcardToolUI.tsx +++ b/src/components/assistant-ui/CreateFlashcardToolUI.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { X, Eye, FolderInput } from "lucide-react"; import { PiCardsThreeBold } from "react-icons/pi"; import { logger } from "@/lib/utils/logger"; @@ -219,7 +219,7 @@ const CreateFlashcardReceipt = ({ ); }; -export const renderCreateFlashcardToolUI: AssistantToolUIProps< +export const renderCreateFlashcardToolUI: ChatToolUIProps< CreateFlashcardArgs, FlashcardResult >["render"] = (props) => { diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index f32bd049..bfb10b22 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -3,7 +3,7 @@ import type { MouseEvent, ReactNode } from "react"; import { useEffect, useState, useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { X, Eye, FolderInput, Brain } from "lucide-react"; import { logger } from "@/lib/utils/logger"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; @@ -198,7 +198,7 @@ const CreateQuizReceipt = ({ ); }; -export const renderCreateQuizToolUI: AssistantToolUIProps< +export const renderCreateQuizToolUI: ChatToolUIProps< CreateQuizInput, QuizResult >["render"] = (props) => { diff --git a/src/components/assistant-ui/EditItemToolUI.tsx b/src/components/assistant-ui/EditItemToolUI.tsx index 938986ef..5310e6d8 100644 --- a/src/components/assistant-ui/EditItemToolUI.tsx +++ b/src/components/assistant-ui/EditItemToolUI.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { X, Eye } from "lucide-react"; import { Pencil } from "lucide-react"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; @@ -152,7 +152,7 @@ const EditItemReceipt = ({ args, result, status }: EditItemReceiptProps) => { ); }; -export const renderEditItemToolUI: AssistantToolUIProps< +export const renderEditItemToolUI: ChatToolUIProps< EditItemArgs, WorkspaceResult >["render"] = ({ args, result, status }) => { diff --git a/src/components/assistant-ui/ExecuteCodeToolUI.tsx b/src/components/assistant-ui/ExecuteCodeToolUI.tsx index 3004682d..a2f974e1 100644 --- a/src/components/assistant-ui/ExecuteCodeToolUI.tsx +++ b/src/components/assistant-ui/ExecuteCodeToolUI.tsx @@ -7,7 +7,7 @@ import { ChevronDownIcon, ChevronRightIcon, } from "lucide-react"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; @@ -31,7 +31,7 @@ function chartLabel(chartType: string, index: number): string { return `Chart ${n}`; } -export const renderExecuteCodeToolUI: AssistantToolUIProps< +export const renderExecuteCodeToolUI: ChatToolUIProps< { code: string }, CodeExecuteResult >["render"] = ({ status, args, result }) => { diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx index 7a30f969..95f21d22 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import { Eye } from "lucide-react"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; @@ -22,7 +22,7 @@ function stripExtension(s: string): string { return s.replace(/\.[^.]+$/, ""); } -export const renderReadWorkspaceToolUI: AssistantToolUIProps< +export const renderReadWorkspaceToolUI: ChatToolUIProps< ReadArgs, ReadResult >["render"] = ({ args, status, result }) => { diff --git a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx index e7f67383..aafd57eb 100644 --- a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import { Search } from "lucide-react"; -import type { AssistantToolUIProps } from "@assistant-ui/react"; +import type { ChatToolUIProps } from "@/lib/chat/runtime"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; @@ -9,7 +9,7 @@ import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell" type GrepArgs = { pattern: string; include?: string; path?: string }; type GrepResult = { success: boolean; matches?: number; output?: string; message?: string }; -export const renderSearchWorkspaceToolUI: AssistantToolUIProps< +export const renderSearchWorkspaceToolUI: ChatToolUIProps< GrepArgs, GrepResult >["render"] = ({ status, result }) => { diff --git a/src/components/assistant-ui/URLContextToolUI.tsx b/src/components/assistant-ui/URLContextToolUI.tsx index 3a32a790..6023596f 100644 --- a/src/components/assistant-ui/URLContextToolUI.tsx +++ b/src/components/assistant-ui/URLContextToolUI.tsx @@ -9,7 +9,7 @@ import { type PropsWithChildren, } from "react"; -import { useScrollLock, type AssistantToolUIProps } from "@assistant-ui/react"; +import { useChatScrollLock, type ChatToolUIProps } from "@/lib/chat/runtime"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { parseURLContextResult } from "@/lib/ai/tool-result-schemas"; @@ -36,7 +36,7 @@ const ToolRoot: FC< > = ({ className, children }) => { const collapsibleRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useChatScrollLock(collapsibleRef, ANIMATION_DURATION); const handleOpenChange = useCallback( (open: boolean) => { @@ -210,7 +210,7 @@ type ProcessUrlsResult = }; }; -export const renderURLContextToolUI: AssistantToolUIProps<{ +export const renderURLContextToolUI: ChatToolUIProps<{ urls?: string[]; jsonInput?: string; }, ProcessUrlsResult>["render"] = ({ args, status, result }) => { diff --git a/src/components/assistant-ui/WebSearchToolUI.tsx b/src/components/assistant-ui/WebSearchToolUI.tsx index cc73358a..576b31c2 100644 --- a/src/components/assistant-ui/WebSearchToolUI.tsx +++ b/src/components/assistant-ui/WebSearchToolUI.tsx @@ -9,7 +9,7 @@ import { type PropsWithChildren, } from "react"; -import { useScrollLock, type AssistantToolUIProps } from "@assistant-ui/react"; +import { useChatScrollLock, type ChatToolUIProps } from "@/lib/chat/runtime"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { parseWebSearchResult } from "@/lib/ai/tool-result-schemas"; @@ -35,7 +35,7 @@ const ToolRoot: FC< > = ({ className, children }) => { const collapsibleRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useChatScrollLock(collapsibleRef, ANIMATION_DURATION); const handleOpenChange = useCallback( (open: boolean) => { @@ -288,7 +288,7 @@ WebSearchContent.displayName = "WebSearchContent"; * Tool UI component for web_search tool. * Displays search query and results in a collapsible format similar to Reasoning. */ -export const renderWebSearchToolUI: AssistantToolUIProps< +export const renderWebSearchToolUI: ChatToolUIProps< { query: string }, WebSearchResult >["render"] = ({ diff --git a/src/components/assistant-ui/YouTubeSearchToolUI.tsx b/src/components/assistant-ui/YouTubeSearchToolUI.tsx index e8c4254f..d8688337 100644 --- a/src/components/assistant-ui/YouTubeSearchToolUI.tsx +++ b/src/components/assistant-ui/YouTubeSearchToolUI.tsx @@ -1,6 +1,6 @@ "use client"; -import { useAui, useScrollLock, type AssistantToolUIProps } from "@assistant-ui/react"; +import { useChatScrollLock, type ChatToolUIProps } from "@/lib/chat/runtime"; import { Loader2, Plus, Check, ChevronDownIcon } from "lucide-react"; import { YouTubeMark } from "@/components/icons/YouTubeMark"; import { Button } from "@/components/ui/button"; @@ -52,7 +52,7 @@ const ToolRoot: FC< > = ({ className, children, defaultOpen = false }) => { const collapsibleRef = useRef(null); const [isOpen, setIsOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useChatScrollLock(collapsibleRef, ANIMATION_DURATION); const handleOpenChange = useCallback( (open: boolean) => { @@ -380,7 +380,7 @@ const YouTubeSearchContent: FC<{ ); }; -export const renderYouTubeSearchToolUI: AssistantToolUIProps< +export const renderYouTubeSearchToolUI: ChatToolUIProps< SearchYoutubeArgs, SearchYoutubeResult >["render"] = ({ args, status, result }) => { diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 91df9317..cbe02bc1 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -429,7 +429,7 @@ export const PromptInputAddAttachment: FC = () => { } }; - const uploadInputId = "composer-file-upload"; + const uploadInputId = "prompt-input-file-upload"; return ( <> diff --git a/src/lib/chat/runtime/index.ts b/src/lib/chat/runtime/index.ts index e6e790d1..2d93f77f 100644 --- a/src/lib/chat/runtime/index.ts +++ b/src/lib/chat/runtime/index.ts @@ -13,6 +13,7 @@ export type { ChatSourcePartComponent, ChatTextPartProps, ChatToolCallPartComponent, + ChatToolUIProps, } from "./types"; export * from "./primitives"; export * from "./hooks"; diff --git a/src/lib/chat/runtime/types.ts b/src/lib/chat/runtime/types.ts index 2cd22608..72953d40 100644 --- a/src/lib/chat/runtime/types.ts +++ b/src/lib/chat/runtime/types.ts @@ -7,6 +7,7 @@ import type { ToolCallMessagePartComponent, ReasoningMessagePartComponent, ReasoningGroupComponent, + AssistantToolUIProps, } from '@assistant-ui/react'; export type ChatMessageRole = "user" | "assistant" | "system"; @@ -66,6 +67,7 @@ export type ChatSourcePartComponent = SourceMessagePartComponent; export type ChatToolCallPartComponent = ToolCallMessagePartComponent; export type ChatReasoningPartComponent = ReasoningMessagePartComponent; export type ChatReasoningGroupComponent = ReasoningGroupComponent; +export type ChatToolUIProps = AssistantToolUIProps; /** Scope an attachment belongs to — either the user composer or a rendered message. */ export type AttachmentScope = "composer" | "message"; From 1fb50a59503fa163affcbead8f27c37cf83bfe29 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:35:01 +0000 Subject: [PATCH 5/7] Phase 2d: migrate final 10 consumer files to chat-runtime ACL - AssistantDropzone, PromptBuilderDialog, SpeechToTextButton, AssistantTextSelectionManager, thread-list-dropdown, AppChatHeader, MessageContextBadges, PdfPanelHeader, QuizContent, and use-workspace-context-provider now import from @/lib/chat/runtime instead of @assistant-ui/react. - Added to the ACL: - Primitives: ChatThreadList, ChatThreadListItem - Hooks: useCurrentChatMessage, useThreadListItemId, useChatThreadListItem, usePromptInputThreadActions, useChatAssistantContext - Types: ChatThreadListItem, CurrentChatMessage, ChatAssistantContextOptions, PromptInputThreadActions After this, only the runtime boundary files still import directly from @assistant-ui/react: WorkspaceRuntimeProvider, chat-toolkit, custom-thread-history-adapter, custom-thread-list-adapter, supabase-attachment-adapter, and toCreateMessageWithContext. --- .../assistant-ui/AssistantDropzone.tsx | 12 ++--- .../AssistantTextSelectionManager.tsx | 12 ++--- .../assistant-ui/PromptBuilderDialog.tsx | 8 +-- .../assistant-ui/SpeechToTextButton.tsx | 10 ++-- .../assistant-ui/thread-list-dropdown.tsx | 51 +++++++++---------- src/components/chat/AppChatHeader.tsx | 9 ++-- src/components/chat/MessageContextBadges.tsx | 4 +- src/components/pdf/PdfPanelHeader.tsx | 10 ++-- .../workspace-canvas/QuizContent.tsx | 10 ++-- .../ai/use-workspace-context-provider.ts | 4 +- src/lib/chat/runtime/hooks.ts | 49 ++++++++++++++++++ src/lib/chat/runtime/primitives.ts | 4 ++ src/lib/chat/runtime/types.ts | 31 +++++++++++ 13 files changed, 146 insertions(+), 68 deletions(-) diff --git a/src/components/assistant-ui/AssistantDropzone.tsx b/src/components/assistant-ui/AssistantDropzone.tsx index 30f7d129..21c1accb 100644 --- a/src/components/assistant-ui/AssistantDropzone.tsx +++ b/src/components/assistant-ui/AssistantDropzone.tsx @@ -1,7 +1,7 @@ "use client"; import { useDropzone } from "react-dropzone"; -import { useAui } from "@assistant-ui/react"; +import { usePromptInput } from "@/lib/chat/runtime"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Upload } from "lucide-react"; import { useCallback, useState, useRef } from "react"; @@ -19,7 +19,7 @@ interface AssistantDropzoneProps { * Accepts all supported file types and adds them as attachments to the chat composer. */ export function AssistantDropzone({ children }: AssistantDropzoneProps) { - const aui = useAui(); + const promptInput = usePromptInput(); const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); const [isDragging, setIsDragging] = useState(false); @@ -34,7 +34,7 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { const onDrop = useCallback( async (acceptedFiles: File[]) => { - if (!currentWorkspaceId || !aui) return; + if (!currentWorkspaceId || !promptInput) return; const MAX_FILES = 10; const MAX_FILE_SIZE_MB = 50; @@ -113,7 +113,7 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { // Add each file to the composer const addPromises = filesToAdd.map(async (file) => { try { - await aui.composer().addAttachment(file); + await promptInput.addAttachment(file); } catch (error) { console.error("Failed to add attachment:", error); // Remove from processing set on error so it can be retried @@ -142,7 +142,7 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { }, 200); } }, - [aui, currentWorkspaceId] + [promptInput, currentWorkspaceId] ); // Clear processing state when drag ends (user drags away or cancels) @@ -157,7 +157,7 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { onDrop, noClick: true, // Don't trigger on click, only drag and drop noKeyboard: true, // Don't trigger on keyboard - disabled: !currentWorkspaceId || !aui, // Disable if no workspace is selected or api is not available + disabled: !currentWorkspaceId || !promptInput, // Disable if no workspace is selected or api is not available accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.heic', '.heif', '.avif', '.tiff', '.tif'], 'application/pdf': ['.pdf'], diff --git a/src/components/assistant-ui/AssistantTextSelectionManager.tsx b/src/components/assistant-ui/AssistantTextSelectionManager.tsx index 905e6d6e..e9064b6a 100644 --- a/src/components/assistant-ui/AssistantTextSelectionManager.tsx +++ b/src/components/assistant-ui/AssistantTextSelectionManager.tsx @@ -8,9 +8,9 @@ import { AssistantThreadSelection, type SelectionInfo, } from "@/components/assistant-ui/assistant-thread-selection"; +import { useMainThreadId, useThreadListItemId } from "@/lib/chat/runtime"; import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useAuiState } from "@assistant-ui/react"; export default function AssistantTextSelectionManager({ className, @@ -28,13 +28,9 @@ export default function AssistantTextSelectionManager({ const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const threadListItemId = useAuiState( - ({ threadListItem }) => (threadListItem as any)?.id, - ); - const mainThreadId = useAuiState( - ({ threads }) => (threads as any)?.mainThreadId, - ); - const currentThreadId = threadListItemId || mainThreadId; + const threadListItemId = useThreadListItemId(); + const mainThreadId = useMainThreadId(); + const currentThreadId = (threadListItemId || mainThreadId) ?? undefined; const prevWorkspaceIdRef = useRef(workspaceId); useEffect(() => { diff --git a/src/components/assistant-ui/PromptBuilderDialog.tsx b/src/components/assistant-ui/PromptBuilderDialog.tsx index aac90229..386c1523 100644 --- a/src/components/assistant-ui/PromptBuilderDialog.tsx +++ b/src/components/assistant-ui/PromptBuilderDialog.tsx @@ -38,7 +38,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; import type { Item } from "@/lib/workspace-state/types"; -import { useAui } from "@assistant-ui/react"; +import { usePromptInput } from "@/lib/chat/runtime"; import { focusComposerInput } from "@/lib/utils/composer-utils"; import { Brain, @@ -234,7 +234,7 @@ export function PromptBuilderDialog({ }: PromptBuilderDialogProps) { const config = ACTION_CONFIG[action]; const Icon = config.icon; - const aui = useAui(); + const promptInput = usePromptInput(); const formId = useId(); const { selectedCardIds } = useSelectedCardIds(); @@ -399,7 +399,7 @@ export function PromptBuilderDialog({ if (action !== "search" && selectedContextIds.size > 0) { selectMultipleCards(Array.from(selectedContextIds)); } - aui?.composer().setText(builtPrompt); + promptInput?.setText(builtPrompt); focusComposerInput(); } onOpenChange(false); @@ -409,7 +409,7 @@ export function PromptBuilderDialog({ hasValidTopic, onBeforeSubmit, onBuild, - aui, + promptInput, onOpenChange, selectedContextIds, selectMultipleCards, diff --git a/src/components/assistant-ui/SpeechToTextButton.tsx b/src/components/assistant-ui/SpeechToTextButton.tsx index 3cbc3aa6..fa8e4a94 100644 --- a/src/components/assistant-ui/SpeechToTextButton.tsx +++ b/src/components/assistant-ui/SpeechToTextButton.tsx @@ -2,12 +2,12 @@ import "regenerator-runtime/runtime"; import { Mic, MicOff } from "lucide-react"; import { FC, useEffect, useState } from "react"; import SpeechRecognition, { useSpeechRecognition } from "react-speech-recognition"; -import { useAui } from "@assistant-ui/react"; +import { usePromptInput } from "@/lib/chat/runtime"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; export const SpeechToTextButton: FC = () => { - const aui = useAui(); + const promptInput = usePromptInput(); const { transcript, listening, @@ -23,16 +23,16 @@ export const SpeechToTextButton: FC = () => { // We append the transcript to the original text // Ideally we would insert at cursor, but appending is safer for now const separator = originalText && !originalText.endsWith(' ') ? ' ' : ''; - aui.composer().setText(originalText + separator + transcript); + promptInput?.setText(originalText + separator + transcript); } - }, [transcript, listening, originalText, aui]); + }, [transcript, listening, originalText, promptInput]); const handleStartListening = () => { // Reset transcript before starting a new session resetTranscript(); // Capture current text before starting - const currentText = aui.composer().getState().text; + const currentText = promptInput?.getState()?.text ?? ""; setOriginalText(currentText); SpeechRecognition.startListening({ continuous: true }); diff --git a/src/components/assistant-ui/thread-list-dropdown.tsx b/src/components/assistant-ui/thread-list-dropdown.tsx index 4db92c8d..33d0c10f 100644 --- a/src/components/assistant-ui/thread-list-dropdown.tsx +++ b/src/components/assistant-ui/thread-list-dropdown.tsx @@ -3,13 +3,13 @@ import type { FC } from "react"; import { useState, useRef, useEffect } from "react"; import { - AuiIf, - ThreadListItemPrimitive, - ThreadListPrimitive, - useAui, -} from "@assistant-ui/react"; + ChatIf, + ChatThreadList, + ChatThreadListItem, + useChatThreadListItem, + usePromptInputThreadActions, +} from "@/lib/chat/runtime"; import { Trash2Icon, PencilIcon } from "lucide-react"; -import { useThreadListItem } from "@assistant-ui/react"; import { PiNotePencilBold } from "react-icons/pi"; import { toast } from "sonner"; @@ -55,18 +55,18 @@ export const ThreadListDropdown: FC = ({ trigger }) => align="end" className="w-80 bg-sidebar border-sidebar-border max-h-[500px] p-0 overflow-hidden" > - + setOpen(false)} />
- threads.isLoading}> + threads.isLoading}> - - !threads.isLoading}> - setOpen(false)} /> }} /> - + + !threads.isLoading}> + setOpen(false)} /> }} /> +
-
+ ); @@ -74,7 +74,7 @@ export const ThreadListDropdown: FC = ({ trigger }) => const ThreadListNew: FC<{ onSelect?: () => void }> = ({ onSelect }) => { return ( - + - + ); }; @@ -106,9 +106,9 @@ const ThreadListSkeleton: FC = () => { const ThreadListItem: FC<{ onSelect?: () => void }> = ({ onSelect }) => { return ( - + - + ); }; @@ -116,11 +116,11 @@ const ThreadListItemContent: FC<{ onSelect?: () => void }> = ({ onSelect }) => { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(""); const inputRef = useRef(null); - const aui = useAui(); + const threadActions = usePromptInputThreadActions(); // Get the current title and thread state - this is now inside the Root context // Using safe hook to handle race condition during thread switching (GitHub issue #2722) - const threadListItem = useThreadListItem(); + const threadListItem = useChatThreadListItem(); const title = threadListItem?.title || "New Chat"; const isThreadInitialized = !!threadListItem?.remoteId; @@ -162,7 +162,7 @@ const ThreadListItemContent: FC<{ onSelect?: () => void }> = ({ onSelect }) => { if (trimmedValue && trimmedValue !== title) { try { - await aui?.threadListItem().rename(trimmedValue); + await threadActions?.rename(trimmedValue); toast.success("Title updated"); } catch (error) { console.error("Failed to rename thread:", error); @@ -208,12 +208,12 @@ const ThreadListItemContent: FC<{ onSelect?: () => void }> = ({ onSelect }) => { /> ) : ( <> - - +