diff --git a/CHANGELOG.md b/CHANGELOG.md index b0725af08..e14eb5746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [EE] Added mermaid diagram rendering to Ask Sourcebot answers, with pan/zoom, copy/export, in-thread deep links, and an interleaved right-panel view. [#1369](https://github.com/sourcebot-dev/sourcebot/pull/1369) - [EE] Added a context-window usage gauge to the Ask Sourcebot chat details, showing how much of the selected model's context window each turn occupies. Window sizes are resolved from the models.dev catalog. [#1370](https://github.com/sourcebot-dev/sourcebot/pull/1370) - Added language model input-modality and document capability resolution, automatically resolved from the models.dev catalog (falls back to text-only for uncatalogued/self-hosted models). [#1372](https://github.com/sourcebot-dev/sourcebot/pull/1372) +- [EE] Added text file attachments to Ask Sourcebot, letting users attach text/code/config files to a chat message via the paperclip button, drag-and-drop, or paste, with large pastes auto-converted to attachments. [#1374](https://github.com/sourcebot-dev/sourcebot/pull/1374) ### Fixed - Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367) diff --git a/packages/web/package.json b/packages/web/package.json index d6c1c4db6..61f1dade0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -177,6 +177,7 @@ "react-day-picker": "^9.14.0", "react-device-detect": "^2.2.3", "react-dom": "19.2.4", + "react-dropzone": "^15.0.0", "react-hook-form": "^7.53.0", "react-hotkeys-hook": "^4.5.1", "react-icons": "^5.6.0", @@ -200,6 +201,7 @@ "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.3", "usehooks-ts": "^3.1.0", + "uuid": "^14.0.0", "vscode-icons-js": "^11.6.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.5" diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx index f18ea5c74..afc7af4c3 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx @@ -3,14 +3,15 @@ import Image from 'next/image'; import { SearchModeSelector } from "@/app/(app)/components/searchModeSelector"; import { Separator } from "@/components/ui/separator"; -import { ChatBox } from "@/features/chat/components/chatBox"; +import { ChatBox, ChatBoxHandle } from "@/features/chat/components/chatBox"; import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; +import { ChatPaneDropzone } from "@/features/chat/components/chatBox/chatPaneDropzone"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; import { LanguageModelInfo, RepoSearchScope } from "@/features/chat/types"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; import { getRepoImageSrc } from '@/lib/utils'; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; interface LandingPageProps { @@ -33,6 +34,7 @@ export const LandingPage = ({ const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [disabledMcpServerIds, setDisabledMcpServerIds] = useLocalStorage(DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); + const chatBoxRef = useRef(null); const isChatBoxDisabled = languageModels.length === 0; const selectedSearchScopes = useMemo(() => [ @@ -67,11 +69,16 @@ export const LandingPage = ({ {/* ChatBox */} -
+ chatBoxRef.current?.addFiles(files)} + disabled={isChatBoxDisabled} + >
{ - createNewChatThread(children, selectedSearchScopes, disabledMcpServerIds); + ref={chatBoxRef} + onSubmit={(children, _editor, attachments) => { + createNewChatThread(children, selectedSearchScopes, disabledMcpServerIds, attachments); }} className="min-h-[50px]" isRedirecting={isLoading} @@ -107,7 +114,7 @@ export const LandingPage = ({ {isChatBoxDisabled && ( )} -
+
) diff --git a/packages/web/src/app/(app)/chat/chatLandingPage.tsx b/packages/web/src/app/(app)/chat/chatLandingPage.tsx index 5bd84a3d0..f81e27247 100644 --- a/packages/web/src/app/(app)/chat/chatLandingPage.tsx +++ b/packages/web/src/app/(app)/chat/chatLandingPage.tsx @@ -5,6 +5,7 @@ import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; import { ServiceErrorException } from "@/lib/serviceError"; import { isServiceError, measure } from "@/lib/utils"; import { LandingPageChatBox } from "./components/landingPageChatBox"; +import { ChatLandingDropzone } from "./components/chatLandingDropzone"; import { RepositoryCarousel } from "../components/repositoryCarousel"; import { Separator } from "@/components/ui/separator"; import { DemoCards } from "./components/demoCards"; @@ -56,7 +57,7 @@ export async function ChatLandingPage() { })() : undefined; return ( -
+
)}
-
+
) } diff --git a/packages/web/src/app/(app)/chat/components/chatLandingDropzone.tsx b/packages/web/src/app/(app)/chat/components/chatLandingDropzone.tsx new file mode 100644 index 000000000..821b3a5ba --- /dev/null +++ b/packages/web/src/app/(app)/chat/components/chatLandingDropzone.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { ChatBoxHandle } from "@/features/chat/components/chatBox"; +import { ChatPaneDropzone } from "@/features/chat/components/chatBox/chatPaneDropzone"; +import { createContext, ReactNode, useCallback, useContext, useRef } from "react"; + +type RegisterChatBoxHandle = (handle: ChatBoxHandle | null) => void; + +const LandingChatBoxContext = createContext(null); + +// Lets the (nested) landing chat box register its imperative handle so the +// pane-level drop zone can forward dropped files into it. Returns a no-op when +// rendered outside the provider. +export const useRegisterLandingChatBox = (): RegisterChatBoxHandle => { + return useContext(LandingChatBoxContext) ?? (() => { }); +} + +interface ChatLandingDropzoneProps { + disabled?: boolean; + children: ReactNode; +} + +// Wraps the entire unstarted-chat landing pane in a drag-and-drop target. +// The chat box lives deeper in the tree (and behind a server/client boundary), +// so it registers its handle via context rather than a direct ref. +export const ChatLandingDropzone = ({ disabled, children }: ChatLandingDropzoneProps) => { + const handleRef = useRef(null); + + const register = useCallback((handle) => { + handleRef.current = handle; + }, []); + + return ( + + handleRef.current?.addFiles(files)} + disabled={disabled} + > + {children} + + + ) +} diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx index ed749450f..61f78bf4d 100644 --- a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx @@ -7,6 +7,7 @@ import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { useState } from "react"; +import { useRegisterLandingChatBox } from "./chatLandingDropzone"; import { useLocalStorage } from "usehooks-ts"; import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; import { SearchModeSelector } from "../../components/searchModeSelector"; @@ -31,14 +32,16 @@ export const LandingPageChatBox = ({ const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); const [disabledMcpServerIds, setDisabledMcpServerIds] = useLocalStorage(DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); + const registerChatBox = useRegisterLandingChatBox(); const isChatBoxDisabled = languageModels.length === 0; return (
{ - createNewChatThread(children, selectedSearchScopes, disabledMcpServerIds); + ref={registerChatBox} + onSubmit={(children, _editor, attachments) => { + createNewChatThread(children, selectedSearchScopes, disabledMcpServerIds, attachments); }} className="min-h-[50px]" isRedirecting={isLoading} @@ -74,6 +77,6 @@ export const LandingPageChatBox = ({ {isChatBoxDisabled && ( )} -
+
) } diff --git a/packages/web/src/ee/features/chat/agent.ts b/packages/web/src/ee/features/chat/agent.ts index c0f1e45bc..e6cc49ca3 100644 --- a/packages/web/src/ee/features/chat/agent.ts +++ b/packages/web/src/ee/features/chat/agent.ts @@ -22,7 +22,7 @@ import { randomUUID } from "crypto"; import _dedent from "dedent"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "@/features/chat/constants"; import { Source } from "@/features/chat/types"; -import { addLineNumbers, fileReferenceToString, getAnswerPartFromAssistantMessage, getTurnProgressState, getUserMessageText } from "@/features/chat/utils"; +import { addLineNumbers, fileReferenceToString, formatAttachmentsForPrompt, getAnswerPartFromAssistantMessage, getTurnProgressState, getUserMessageAttachments, getUserMessageText } from "@/features/chat/utils"; import { createTools } from "./tools"; import { getConnectedMcpClients } from "@/ee/features/chat/mcp/mcpClientFactory"; import { getMcpTools, McpToolsResult } from "@/ee/features/chat/mcp/mcpToolSets"; @@ -105,9 +105,16 @@ export const createMessageStream = async ({ let messageHistory: ModelMessage[] = messages.map((message, index): ModelMessage | undefined => { if (message.role === 'user') { + // Fold inline-text attachments into this turn's content (not the + // system prompt) so they stay bound to their turn, re-emitted from + // the persisted parts. + const text = getUserMessageText(message); + const attachmentsBlock = formatAttachmentsForPrompt( + getUserMessageAttachments(message), + ); return { role: 'user', - content: getUserMessageText(message), + content: attachmentsBlock ? `${text}\n\n${attachmentsBlock}` : text, }; } diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx index 87faf79f8..a7dfa82af 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx @@ -4,7 +4,7 @@ import { useToast } from '@/components/hooks/use-toast'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { CustomSlateEditor } from '@/features/chat/customSlateEditor'; -import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types'; +import { AdditionalChatRequestParams, AttachmentData, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types'; import { createUIMessage, getAllMentionElements, getTurnProgressState, getUserMessageText, resetEditor, slateContentToString } from '@/features/chat/utils'; import { useChat } from '@ai-sdk/react'; import { CreateUIMessage, DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'; @@ -15,8 +15,9 @@ import { useStickToBottom } from 'use-stick-to-bottom'; import { Descendant } from 'slate'; import { useMessagePairs } from '../../useMessagePairs'; import { useSelectedLanguageModel } from '@/features/chat/useSelectedLanguageModel'; -import { ChatBox } from '@/features/chat/components/chatBox'; +import { ChatBox, ChatBoxHandle } from '@/features/chat/components/chatBox'; import { ChatBoxToolbar } from '@/features/chat/components/chatBox/chatBoxToolbar'; +import { ChatPaneDropzone } from '@/features/chat/components/chatBox/chatPaneDropzone'; import { ChatThreadListItem } from './chatThreadListItem'; import { ErrorBanner } from './errorBanner'; import { McpFailedServersBanner } from './mcpFailedServersBanner'; @@ -72,6 +73,7 @@ export const ChatThread = ({ }: ChatThreadProps) => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false); const hasSubmittedInputMessage = useRef(false); + const chatBoxRef = useRef(null); const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: false }); const { toast } = useToast(); const router = useRouter(); @@ -347,11 +349,11 @@ export const ChatThread = ({ } }, [error]); - const onSubmit = useCallback(async (children: Descendant[], editor: CustomEditor) => { + const onSubmit = useCallback(async (children: Descendant[], editor: CustomEditor, attachments: AttachmentData[]) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); - const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes, disabledMcpServerIds); + const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes, disabledMcpServerIds, attachments); sendMessage(message); scrollToBottom(); @@ -381,6 +383,11 @@ export const ChatThread = ({ return ( + chatBoxRef.current?.addFiles(files)} + disabled={!isOwner || languageModels.length === 0} + > {error && ( )}
+ ); diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx index 31191ecc4..8d6e42827 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx @@ -8,8 +8,9 @@ import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRe import scrollIntoView from 'scroll-into-view-if-needed'; import { Reference, referenceSchema, SBChatMessage, Source } from "@/features/chat/types"; import { useExtractReferences } from '../../useExtractReferences'; -import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageText, groupMessageIntoSteps, isSBChatToolPart, repairReferences } from '@/features/chat/utils'; +import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageAttachments, getUserMessageText, groupMessageIntoSteps, isSBChatToolPart, repairReferences } from '@/features/chat/utils'; import { AnswerCard } from './answerCard'; +import { MessageAttachments } from './messageAttachments'; import { DetailsCard } from './detailsCard'; import { ApprovalRequestedToolPart, ToolApprovalBanner } from './toolApprovalBanner'; import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer'; @@ -68,6 +69,10 @@ const ChatThreadListItemComponent = forwardRef { + return getUserMessageAttachments(userMessage); + }, [userMessage]); + // Take the assistant message and repair any references that are not properly formatted. // This applies to parts that are text (i.e., text & reasoning). const assistantMessage = useMemo(() => { @@ -418,27 +423,30 @@ const ChatThreadListItemComponent = forwardRef -
- {isTurnInProgress ? ( - - ) : ( - +
+ {userAttachments.length > 0 && ( + )} - + +
+ {isTurnInProgress ? ( + + ) : ( + + )} + +
{isThinking && ( -
- -
- - - -
+
+ + +
)} diff --git a/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx index 7b6c7867f..c705b6be5 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx @@ -537,6 +537,7 @@ export const StepPartRenderer = ({ part, toolTokenUsageMap }: { part: SBChatMess case 'data-source': case 'data-mcp-server': case 'data-mcp-failed-server': + case 'data-attachment': case 'file': case 'source-document': case 'source-url': diff --git a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx new file mode 100644 index 000000000..7d2b5040e --- /dev/null +++ b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; +import { AttachmentViewerDialog } from "@/features/chat/components/chatBox/attachmentViewerDialog"; +import { AttachmentData } from "@/features/chat/types"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; + +interface MessageAttachmentsProps { + attachments: AttachmentData[]; + className?: string; +} + +export const MessageAttachments = ({ attachments, className }: MessageAttachmentsProps) => { + const [activeAttachment, setActiveAttachment] = useState(null); + + if (attachments.length === 0) { + return null; + } + + return ( + <> +
+ {attachments.map((attachment, index) => ( + + ))} +
+ !open && setActiveAttachment(null)} + filename={activeAttachment?.filename} + text={activeAttachment?.kind === 'text' ? activeAttachment.text : undefined} + /> + + ) +} diff --git a/packages/web/src/features/chat/attachmentUtils.ts b/packages/web/src/features/chat/attachmentUtils.ts new file mode 100644 index 000000000..4ab042532 --- /dev/null +++ b/packages/web/src/features/chat/attachmentUtils.ts @@ -0,0 +1,198 @@ +'use client'; + +import { + ATTACHMENT_ALLOWED_TEXT_EXTENSIONS, + ATTACHMENT_ALLOWED_TEXT_MIME_TYPES, + ATTACHMENT_MAX_TURN_TEXT_BYTES, + ATTACHMENT_PASTE_AUTO_CONVERT_MIN_CHARS, + ATTACHMENT_PASTE_AUTO_CONVERT_MIN_LINES, +} from "./constants"; +import { AttachmentData, TextAttachment } from "./types"; +import { v4 as uuidv4 } from "uuid"; + +// Normalizes an untrusted filename: basename only, strips control chars (which +// could break the `` tag or UI), collapses whitespace. +export const sanitizeFilename = (name: string): string => { + const basename = name.split(/[\\/]/).pop() ?? name; + return Array.from(basename) + .filter((char) => { + const code = char.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join('') + .replace(/\s+/g, ' ') + .trim() || 'attachment'; +} + +// A text attachment selected in the chat box but not yet submitted. The `id` +// is a client-only key for list rendering and removal; it is stripped before +// the attachment becomes part of a message. +export type PendingAttachment = TextAttachment & { id: string }; + +// Builds the comma-separated `accept` attribute for a native `` +// so the OS picker only surfaces supported text file types. +export const getAttachmentAcceptAttribute = (): string => { + return [ + 'text/*', + ...ATTACHMENT_ALLOWED_TEXT_MIME_TYPES, + ...ATTACHMENT_ALLOWED_TEXT_EXTENSIONS.map((extension) => `.${extension}`), + ].join(','); +} + +// Builds react-dropzone's `accept` map. Extensions are attached to `text/plain` +// so code files that report an empty/unusual MIME type are still selectable. +export const getAttachmentDropzoneAccept = (): Record => { + const accept: Record = { + 'text/*': [], + 'text/plain': ATTACHMENT_ALLOWED_TEXT_EXTENSIONS.map((extension) => `.${extension}`), + }; + for (const mimeType of ATTACHMENT_ALLOWED_TEXT_MIME_TYPES) { + accept[mimeType] = []; + } + return accept; +} + +// Total UTF-8 byte size of a turn's submitted text (prompt + attachment bodies), +// checked against ATTACHMENT_MAX_TURN_TEXT_BYTES at submit time. +export const getSubmittedTextBytes = (text: string, attachments: PendingAttachment[]): number => { + const textBytes = new TextEncoder().encode(text).length; + const attachmentBytes = attachments.reduce((sum, attachment) => sum + attachment.sizeBytes, 0); + return textBytes + attachmentBytes; +} + +export const toAttachmentData = (attachment: PendingAttachment): AttachmentData => { + return { + kind: attachment.kind, + filename: attachment.filename, + mediaType: attachment.mediaType, + sizeBytes: attachment.sizeBytes, + text: attachment.text, + }; +} + +const getExtension = (filename: string): string => { + const parts = filename.toLowerCase().split('.'); + return parts.length > 1 ? (parts[parts.length - 1] ?? '') : ''; +} + +export const isAllowedTextFile = (file: File): boolean => { + if (file.type.startsWith('text/')) { + return true; + } + if (ATTACHMENT_ALLOWED_TEXT_MIME_TYPES.includes(file.type)) { + return true; + } + + const extension = getExtension(file.name); + if (ATTACHMENT_ALLOWED_TEXT_EXTENSIONS.includes(extension)) { + return true; + } + + // Files with no extension (e.g. "Dockerfile") report an empty extension; + // fall back to matching the whole lowercased filename. + const nameLower = file.name.toLowerCase(); + if (ATTACHMENT_ALLOWED_TEXT_EXTENSIONS.includes(nameLower)) { + return true; + } + + return false; +} + +const readAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read file')); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.readAsText(file); + }); +} + +// Whether a plain-text paste is large enough to auto-convert into an attachment +// instead of being inserted inline. Gated on length or line count. +export const shouldAutoConvertPaste = (text: string): boolean => { + if (text.length >= ATTACHMENT_PASTE_AUTO_CONVERT_MIN_CHARS) { + return true; + } + return countLines(text) >= ATTACHMENT_PASTE_AUTO_CONVERT_MIN_LINES; +} + +export const countLines = (text: string): number => { + if (text.length === 0) { + return 0; + } + return text.split('\n').length; +} + +// Generates a non-colliding filename for an auto-converted paste, e.g. +// `pasted.txt`, then `pasted-2.txt`, `pasted-3.txt`, ... +const getPastedAttachmentFilename = (existing: PendingAttachment[]): string => { + const used = new Set(existing.map((attachment) => attachment.filename)); + if (!used.has('pasted.txt')) { + return 'pasted.txt'; + } + + let index = 2; + while (used.has(`pasted-${index}.txt`)) { + index++; + } + return `pasted-${index}.txt`; +} + +// Builds a pending text attachment from a pasted string. The per-turn text +// budget is enforced once at submit time, not here, so this can't fail. +export const createPastedTextAttachment = ( + text: string, + existing: PendingAttachment[], +): PendingAttachment => { + return { + id: uuidv4(), + kind: 'text', + filename: getPastedAttachmentFilename(existing), + mediaType: 'text/plain', + sizeBytes: new Blob([text]).size, + text, + }; +} + +export type ReadFilesResult = { + attachments: PendingAttachment[]; + errors: string[]; +}; + +// Reads files into pending text attachments, rejecting non-text files and any +// file larger than the per-turn budget (skipped before reading to avoid loading +// a huge file into memory). The aggregate budget is enforced at submit time. +export const readFilesAsAttachments = async ( + files: File[], +): Promise => { + const attachments: PendingAttachment[] = []; + const errors: string[] = []; + + for (const file of files) { + if (!isAllowedTextFile(file)) { + errors.push(`${file.name}: unsupported file type (text files only).`); + continue; + } + + if (file.size > ATTACHMENT_MAX_TURN_TEXT_BYTES) { + errors.push(`${file.name}: exceeds the ${Math.round(ATTACHMENT_MAX_TURN_TEXT_BYTES / 1024)}KB per-message limit.`); + continue; + } + + try { + const text = await readAsText(file); + attachments.push({ + id: uuidv4(), + kind: 'text', + filename: sanitizeFilename(file.name), + mediaType: file.type || 'text/plain', + sizeBytes: file.size, + text, + }); + } catch { + errors.push(`${file.name}: failed to read file.`); + } + } + + return { attachments, errors }; +} diff --git a/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx b/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx new file mode 100644 index 000000000..fef235c06 --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { getAttachmentAcceptAttribute } from "@/features/chat/attachmentUtils"; +import { Paperclip } from "lucide-react"; +import { useRef } from "react"; + +interface AttachmentButtonProps { + onAddFiles: (files: File[]) => void; + disabled?: boolean; +} + +export const AttachmentButton = ({ onAddFiles, disabled }: AttachmentButtonProps) => { + const inputRef = useRef(null); + + return ( + <> + { + const files = e.target.files ? Array.from(e.target.files) : []; + if (files.length > 0) { + onAddFiles(files); + } + // Reset so selecting the same file again re-triggers onChange. + e.target.value = ''; + }} + /> + + + + + + Attach text files + + + + ) +} diff --git a/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx new file mode 100644 index 000000000..2646fa93b --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; +import { cn } from "@/lib/utils"; +import { X } from "lucide-react"; +import { useState } from "react"; +import { PendingAttachment } from "../../attachmentUtils"; +import { AttachmentViewerDialog } from "./attachmentViewerDialog"; + +interface AttachmentTrayProps { + attachments: PendingAttachment[]; + // Omitted when the tray is read-only (e.g. while a submission is + // redirecting); the remove control is hidden in that case. + onRemove?: (id: string) => void; + className?: string; +} + +export const AttachmentTray = ({ attachments, onRemove, className }: AttachmentTrayProps) => { + const [activeAttachment, setActiveAttachment] = useState(null); + + if (attachments.length === 0) { + return null; + } + + return ( + <> +
+ {attachments.map((attachment) => ( +
+ + {onRemove && ( + + )} +
+ ))} +
+ !open && setActiveAttachment(null)} + filename={activeAttachment?.filename} + text={activeAttachment?.text} + /> + + ) +} diff --git a/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx b/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx new file mode 100644 index 000000000..46dc236dc --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useEffect } from "react"; + +interface AttachmentViewerDialogProps { + filename?: string; + text?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// Shared viewer for inspecting an inline-text attachment's contents. Used for +// both staged (not-yet-sent) and sent attachments. +export const AttachmentViewerDialog = ({ filename, text, open, onOpenChange }: AttachmentViewerDialogProps) => { + // The staged viewer is rendered inside the Slate `Editable` subtree, where + // Radix's built-in Escape-to-close can get swallowed by the editor's + // focus/key handling. A capture-phase listener guarantees Escape closes the + // dialog, matching every other modal in the app. + useEffect(() => { + if (!open) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onOpenChange(false); + } + }; + + document.addEventListener('keydown', handleKeyDown, true); + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, [open, onOpenChange]); + + return ( + + + + + {filename} + + + Preview of the attached file{filename ? ` ${filename}` : ''}. + + +
+                    {text}
+                
+
+
+ ) +} diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index e405e8266..1bd33235f 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -3,13 +3,16 @@ import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; +import { AttachmentData, CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; import { insertMention, slateContentToString } from "@/features/chat/utils"; +import { createPastedTextAttachment, getSubmittedTextBytes, PendingAttachment, readFilesAsAttachments, shouldAutoConvertPaste, toAttachmentData } from "@/features/chat/attachmentUtils"; +import { AttachmentButton } from "./attachmentButton"; +import { AttachmentTray } from "./attachmentTray"; import { cn } from "@/lib/utils"; import { useIsMac } from "@/hooks/useIsMac"; import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; import { ArrowUp, Loader2, StopCircleIcon } from "lucide-react"; -import { Fragment, KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { forwardRef, Fragment, KeyboardEvent, memo, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Descendant, insertText } from "slate"; import { Editable, ReactEditor, RenderElementProps, RenderLeafProps, useFocused, useSelected, useSlate } from "slate-react"; @@ -23,13 +26,17 @@ import { SearchContextQuery } from "@/lib/types"; import isEqual from "fast-deep-equal/react"; import { LoginDialog } from "./loginDialog"; import { usePathname } from "next/navigation"; -import { PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY } from "@/features/chat/constants"; +import { ATTACHMENT_MAX_TURN_TEXT_BYTES, PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY } from "@/features/chat/constants"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; import { UpsellDialog } from "@/features/billing/upsellDialog"; +export interface ChatBoxHandle { + addFiles: (files: File[]) => void; +} + interface ChatBoxProps { - onSubmit: (children: Descendant[], editor: CustomEditor) => void; + onSubmit: (children: Descendant[], editor: CustomEditor, attachments: AttachmentData[]) => void; onStop?: () => void; preferredSuggestionsBoxPlacement?: "top-start" | "bottom-start"; className?: string; @@ -56,7 +63,7 @@ const ChatBoxComponent = ({ isAuthenticated, selectedSearchScopes, searchContexts, -}: ChatBoxProps) => { +}: ChatBoxProps, ref: Ref) => { const suggestionsBoxRef = useRef(null); const [index, setIndex] = useState(0); const editor = useSlate(); @@ -82,11 +89,86 @@ const ChatBoxComponent = ({ }); const { selectedLanguageModel } = useSelectedLanguageModel(); const { toast } = useToast(); + const isMac = useIsMac(); const isAskEnabled = useHasEntitlement('ask'); const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false); + const [attachments, setAttachments] = useState([]); + const [submittedAttachments, setSubmittedAttachments] = useState([]); const pathname = usePathname(); + // Set when the user triggers a paste with the OS raw-paste chord + // (⌘⇧V / Ctrl+Shift+V). The subsequent `paste` event reads (and clears) + // this so the large-paste auto-conversion is skipped for that one paste. + const rawPasteRequestedRef = useRef(false); + + // Warning shown when prompt text + `nextAttachments` would exceed the per-turn + // budget, so an over-budget add surfaces immediately instead of just disabling submit. + const getOverBudgetWarning = useCallback((nextAttachments: PendingAttachment[]): string | null => { + const totalBytes = getSubmittedTextBytes(slateContentToString(editor.children), nextAttachments); + if (totalBytes <= ATTACHMENT_MAX_TURN_TEXT_BYTES) { + return null; + } + return `Attachments exceed the ${Math.round(ATTACHMENT_MAX_TURN_TEXT_BYTES / 1024)}KB per-message limit. Remove a file or shorten your message to send.`; + }, [editor]); + + const onAddPastedText = useCallback((text: string) => { + const attachment = createPastedTextAttachment(text, attachments); + setAttachments((prev) => [...prev, attachment]); + + const overBudgetWarning = getOverBudgetWarning([...attachments, attachment]); + if (overBudgetWarning) { + toast({ + description: `⚠️ ${overBudgetWarning}`, + variant: "destructive", + }); + } else { + toast({ + title: "Large paste added as an attachment", + duration: 5 * 1000, + className: "w-fit ml-auto", + description: `Use ${isMac ? "⌘+⇧+V" : "Ctrl+Shift+V"} to paste inline instead`, + }); + } + + ReactEditor.focus(editor); + }, [attachments, editor, toast, isMac, getOverBudgetWarning]); + + const onAddFiles = useCallback(async (files: File[]) => { + if (files.length === 0) { + return; + } + + const { attachments: added, errors } = await readFilesAsAttachments(files); + if (added.length > 0) { + setAttachments((prev) => [...prev, ...added]); + } + + const overBudgetWarning = added.length > 0 ? getOverBudgetWarning([...attachments, ...added]) : null; + const messages = [...errors, ...(overBudgetWarning ? [overBudgetWarning] : [])]; + if (messages.length > 0) { + toast({ + description: `⚠️ ${messages.join(' ')}`, + variant: "destructive", + }); + } + + // Return focus to the prompt input so the user can keep typing. + ReactEditor.focus(editor); + }, [attachments, toast, editor, getOverBudgetWarning]); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)); + }, []); + + // Allow an ancestor pane-level drop zone to forward dropped files into this + // chat box (which owns attachment state). See `ChatPaneDropzone`. + useImperativeHandle(ref, () => ({ + addFiles: (files: File[]) => { + void onAddFiles(files); + }, + }), [onAddFiles]); + // Reset the index when the suggestion mode changes. useEffect(() => { setIndex(0); @@ -118,18 +200,27 @@ const ChatBoxComponent = ({ const { isSubmitDisabled, isSubmitDisabledReason } = useMemo((): { isSubmitDisabled: true, - isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-language-model-selected" + isSubmitDisabledReason: "empty" | "too-large" | "redirecting" | "generating" | "no-language-model-selected" } | { isSubmitDisabled: false, isSubmitDisabledReason: undefined, } => { - if (slateContentToString(editor.children).trim().length === 0) { + const text = slateContentToString(editor.children); + if (text.trim().length === 0 && attachments.length === 0) { return { isSubmitDisabled: true, isSubmitDisabledReason: "empty", } } + // Single per-turn bound on the submitted text (prompt + attachments). + if (getSubmittedTextBytes(text, attachments) > ATTACHMENT_MAX_TURN_TEXT_BYTES) { + return { + isSubmitDisabled: true, + isSubmitDisabledReason: "too-large", + } + } + if (isRedirecting) { return { isSubmitDisabled: true, @@ -157,7 +248,7 @@ const ChatBoxComponent = ({ isSubmitDisabledReason: undefined, } - }, [editor.children, isRedirecting, isTurnInProgress, selectedLanguageModel]) + }, [editor.children, isRedirecting, isTurnInProgress, selectedLanguageModel, attachments]) const { requiresLogin, @@ -178,6 +269,11 @@ const ChatBoxComponent = ({ description: "⚠️ You must select a language model", variant: "destructive", }); + } else if (isSubmitDisabledReason === "too-large") { + toast({ + description: `⚠️ Message and attachments exceed the ${Math.round(ATTACHMENT_MAX_TURN_TEXT_BYTES / 1024)}KB per-message limit. Remove a file or shorten the text.`, + variant: "destructive", + }); } return; @@ -186,7 +282,7 @@ const ChatBoxComponent = ({ if (requiresLogin) { sessionStorage.setItem( PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, - JSON.stringify({ pathname, children: editor.children }), + JSON.stringify({ pathname, children: editor.children, attachments: attachments.map(toAttachmentData) }), ); captureEvent('wa_askgh_login_wall_prompted', {}); setIsLoginDialogOpen(true); @@ -196,13 +292,15 @@ const ChatBoxComponent = ({ if (requiresUpgrade) { sessionStorage.setItem( PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, - JSON.stringify({ pathname, children: editor.children }), + JSON.stringify({ pathname, children: editor.children, attachments: attachments.map(toAttachmentData) }), ); setIsUpsellDialogOpen(true); return; } - _onSubmit(editor.children, editor); + _onSubmit(editor.children, editor, attachments.map(toAttachmentData)); + setSubmittedAttachments(attachments); + setAttachments([]); }, [ isSubmitDisabled, requiresLogin, @@ -212,7 +310,8 @@ const ChatBoxComponent = ({ isSubmitDisabledReason, toast, pathname, - captureEvent + captureEvent, + attachments ]); useEffect(() => { @@ -229,13 +328,17 @@ const ChatBoxComponent = ({ } try { - const { pathname: storedPathname, children } = JSON.parse(stored) as { pathname: string; children: Descendant[] }; + const { pathname: storedPathname, children, attachments: storedAttachments = [] } = JSON.parse(stored) as { + pathname: string; + children: Descendant[]; + attachments?: AttachmentData[]; + }; if (storedPathname !== pathname) { return; } sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); - _onSubmit(children, editor); + _onSubmit(children, editor, storedAttachments); } catch (error) { console.error('Failed to restore pending chat submission:', error); sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); @@ -274,6 +377,16 @@ const ChatBoxComponent = ({ }, [editor, range]); const onKeyDown = useCallback((event: KeyboardEvent) => { + // Detect the OS raw-paste chord so the upcoming `paste` event can skip + // the large-paste auto-conversion and insert inline instead. + if ( + (event.key === 'v' || event.key === 'V') && + event.shiftKey && + (isMac ? event.metaKey : event.ctrlKey) + ) { + rawPasteRequestedRef.current = true; + } + if (suggestionMode === "none") { switch (event.key) { case 'Enter': { @@ -320,7 +433,7 @@ const ChatBoxComponent = ({ } } } - }, [suggestionMode, suggestions, onSubmit, editor, index, onInsertSuggestion]); + }, [suggestionMode, suggestions, onSubmit, editor, index, onInsertSuggestion, isMac]); useEffect(() => { if (!range || !suggestionsBoxRef.current) { @@ -364,6 +477,13 @@ const ChatBoxComponent = ({
+ {(isRedirecting ? submittedAttachments : attachments).length > 0 && ( + + )} { + const clipboardData = event.clipboardData; + const files = clipboardData?.files ? Array.from(clipboardData.files) : []; + if (files.length > 0) { + event.preventDefault(); + void onAddFiles(files); + return; + } + + // A raw-paste chord (⌘⇧V / Ctrl+Shift+V) bypasses + // auto-conversion for this one paste. Consume the flag + // regardless so it never leaks into the next paste. + const rawPasteRequested = rawPasteRequestedRef.current; + rawPasteRequestedRef.current = false; + if (rawPasteRequested) { + return; + } + + const text = clipboardData?.getData('text/plain') ?? ''; + if (!shouldAutoConvertPaste(text)) { + return; + } + + event.preventDefault(); + onAddPastedText(text); + }} /> -
+
+ {isRedirecting ? (
`) is left intact. +const escapeAttachmentBody = (text: string): string => { + return text.replace(/<(\/attachments?>)/gi, '<$1'); +} + +// Formats a user message's text attachments into a delimited block to inline +// into the turn's content. Returns '' when there are none. Size is bounded at +// submit, so nothing is truncated here. +export const formatAttachmentsForPrompt = (attachments: AttachmentData[]): string => { + const textAttachments = attachments.filter((attachment) => attachment.kind === 'text'); + if (textAttachments.length === 0) { + return ''; + } + + const blocks = textAttachments.map((attachment) => { + const text = escapeAttachmentBody(attachment.text); + // Keep the filename on a single line and escape quotes so the body + // can't break out of the tag (the client also sanitizes via + // sanitizeFilename). + const filename = attachment.filename + .replace(/\s+/g, ' ') + .replace(/"/g, '"'); + return `\n${text}\n`; + }); + + return `\n${blocks.join('\n')}\n`; +} + // Attempts to find the part of the assistant's message // that contains the answer. export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isTurnInProgress: boolean): TextUIPart | undefined => { diff --git a/yarn.lock b/yarn.lock index ce21f594b..c0c1cb7ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9512,6 +9512,7 @@ __metadata: react-day-picker: "npm:^9.14.0" react-device-detect: "npm:^2.2.3" react-dom: "npm:19.2.4" + react-dropzone: "npm:^15.0.0" react-email: "npm:^6.1.4" react-grab: "npm:^0.1.23" react-hook-form: "npm:^7.53.0" @@ -9542,6 +9543,7 @@ __metadata: typescript-eslint: "npm:^8.56.1" use-stick-to-bottom: "npm:^1.1.3" usehooks-ts: "npm:^3.1.0" + uuid: "npm:^14.0.0" vite-tsconfig-paths: "npm:^5.1.3" vitest: "npm:^4.1.4" vitest-mock-extended: "npm:^4.0.0" @@ -11634,6 +11636,13 @@ __metadata: languageName: node linkType: hard +"attr-accept@npm:^2.2.4": + version: 2.2.5 + resolution: "attr-accept@npm:2.2.5" + checksum: 10c0/9b4cb82213925cab2d568f71b3f1c7a7778f9192829aac39a281e5418cd00c04a88f873eb89f187e0bf786fa34f8d52936f178e62cbefb9254d57ecd88ada99b + languageName: node + linkType: hard + "available-typed-arrays@npm:^1.0.7": version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" @@ -15092,6 +15101,15 @@ __metadata: languageName: node linkType: hard +"file-selector@npm:^2.1.0": + version: 2.1.2 + resolution: "file-selector@npm:2.1.2" + dependencies: + tslib: "npm:^2.7.0" + checksum: 10c0/fe827e0e95410aacfcc3eabc38c29cc36055257f03c1c06b631a2b5af9730c142ad2c52f5d64724d02231709617bda984701f52bd1f4b7aca50fb6585a27c1d2 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -20476,6 +20494,19 @@ __metadata: languageName: node linkType: hard +"react-dropzone@npm:^15.0.0": + version: 15.0.0 + resolution: "react-dropzone@npm:15.0.0" + dependencies: + attr-accept: "npm:^2.2.4" + file-selector: "npm:^2.1.0" + prop-types: "npm:^15.8.1" + peerDependencies: + react: ">= 16.8 || 18.0.0" + checksum: 10c0/fb7b48a709fdd26273707f7aca5c0e77fce2b9c9201122645d3ecfb07ecfbb89e2495273ea141994f0ed0838ee79f27832c0855b2c598b377b342c3965608b54 + languageName: node + linkType: hard + "react-email@npm:^6.1.4": version: 6.1.4 resolution: "react-email@npm:6.1.4" @@ -23231,7 +23262,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.7.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62