diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index 1872d1f23..2e71c1bd1 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -2,9 +2,8 @@ import * as React from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { FileWithHandle } from 'browser-fs-access'; -import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Typography } from '@mui/joy'; -import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types'; -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import type { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types'; +import { Box, Button, ButtonGroup, Card, Grid, IconButton, Textarea, Typography } from '@mui/joy'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import PsychologyIcon from '@mui/icons-material/Psychology'; import SendIcon from '@mui/icons-material/Send'; @@ -57,14 +56,10 @@ import { providerStarredMessages, StarredMessageItem } from './actile/providerSt import { useActileManager } from './actile/useActileManager'; import type { AttachmentDraftId, AttachmentDraftsAction } from '~/common/attachment-drafts/attachment.types'; -import { ButtonAttachCameraMemo } from '~/common/attachment-drafts/attachment-sources/ButtonAttachCamera'; -import { ButtonAttachClipboardMemo } from '~/common/attachment-drafts/attachment-sources/ButtonAttachClipboard'; -import { ButtonAttachGoogleDriveMemo } from '~/common/attachment-drafts/attachment-sources/ButtonAttachGoogleDrive'; -import { ButtonAttachScreenCaptureMemo } from '~/common/attachment-drafts/attachment-sources/ButtonAttachScreenCapture'; -import { ButtonAttachWebMemo } from '~/common/attachment-drafts/attachment-sources/ButtonAttachWeb'; -import { hasGoogleDriveCapability, useGoogleDrivePicker } from '~/common/attachment-drafts/attachment-sources/useGoogleDrivePicker'; +import { AttachmentSourcesMemo } from '~/common/attachment-drafts/attachment-sources/AttachmentSources'; import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts'; import { useAttachmentDraftsEnrichment } from '~/common/attachment-drafts/llm-enrichment/useAttachmentDraftsEnrichment'; +import { useGoogleDrivePicker } from '~/common/attachment-drafts/attachment-sources/useGoogleDrivePicker'; import { useWebAttachmentModal } from '~/common/attachment-drafts/attachment-sources/useWebAttachmentModal'; import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types'; @@ -791,42 +786,24 @@ export function Composer(props: { {/* [mobile] Mic button */} {recognitionState.isAvailable && } - {/* Responsive Camera OCR button */} - {showChatAttachments && } - {/* [mobile] Attach file button (in draw with image mode) */} {showChatAttachments === 'only-images' && } - {/* [mobile] [+] button */} + {/* [mobile] [+] attachment sources menu */} {showChatAttachments === true && ( - - - - - - - {/* Responsive Open Files button */} - - - - - {/* Responsive Web button */} - - - - - {/* Responsive Google Drive button */} - {hasGoogleDriveCapability && - - } - - {/* Responsive Paste button */} - {supportsClipboardRead() && - - } - - - + )} {/* [Mobile] MultiChat button */} @@ -837,31 +814,26 @@ export function Composer(props: { {/* [Desktop, Col1] Insert Multi-modal content buttons */} {isDesktop && showChatAttachments && ( - + - {/**/} - {/* Attach*/} - {/**/} + {/* [desktop] Attachment Sources: inline buttons */} + - {/* Responsive Open Files button */} - - - {/* Responsive Web button */} - {showChatAttachments !== 'only-images' && } - - {/* Responsive Google Drive button */} - {hasGoogleDriveCapability && showChatAttachments !== 'only-images' && } - - {/* Responsive Paste button */} - {supportsClipboardRead() && showChatAttachments !== 'only-images' && } - - {/* Responsive Screen Capture button */} - {labsAttachScreenCapture && supportsScreenCapture && } - - {/* Responsive Camera OCR button */} - {labsCameraDesktop && supportsCameraCapture() && } - - )} + + )} {/* Top: Textarea & Mic & Overlays, Bottom, Attachment Drafts */} diff --git a/src/common/attachment-drafts/attachment-sources/AttachmentSources.tsx b/src/common/attachment-drafts/attachment-sources/AttachmentSources.tsx new file mode 100644 index 000000000..6a9742d7e --- /dev/null +++ b/src/common/attachment-drafts/attachment-sources/AttachmentSources.tsx @@ -0,0 +1,440 @@ +import * as React from 'react'; +import { keyframes } from '@emotion/react'; +import type { FileWithHandle } from 'browser-fs-access'; + +import { Box, Button, Checkbox, ColorPaletteProp, Divider, Dropdown, IconButton, ListItem, ListItemDecorator, Menu, MenuButton, MenuItem } from '@mui/joy'; +import AddRoundedIcon from '@mui/icons-material/AddRounded'; +import AddToDriveRoundedIcon from '@mui/icons-material/AddToDriveRounded'; +import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded'; +import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined'; +import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo'; +import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded'; +import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor'; + +import { useBrowseStore } from '~/modules/browse/store-module-browsing'; + +import { ButtonAttachFilesMemo, openFileForAttaching } from '~/common/components/ButtonAttachFiles'; +import { supportsClipboardRead } from '~/common/util/clipboardUtils'; +import { takeScreenCapture } from '~/common/util/screenCaptureUtils'; + +import { ButtonAttachCameraMemo } from './ButtonAttachCamera'; +import { ButtonAttachClipboardMemo } from './ButtonAttachClipboard'; +import { ButtonAttachGoogleDriveMemo } from './ButtonAttachGoogleDrive'; +import { ButtonAttachScreenCaptureMemo } from './ButtonAttachScreenCapture'; +import { ButtonAttachWebMemo } from './ButtonAttachWeb'; +import { hasGoogleDriveCapability } from './useGoogleDrivePicker'; + + +// configuration +export const ATTACH_BUTTON_RADIUS = '18px'; // for the rich (non-compact) menu button + + +// animations for the rich (non-compact) menu +const animationMenu = keyframes` from {opacity: 0;} to {opacity: 1;}`; +const animationMenuItem = keyframes` from {opacity: 0;transform: translateY(-6px);} to {opacity: 1;transform: translateY(0);}`; + +const _style = { + menuItem: { + py: 1, + // pl: 3, + // pr: 2, + minHeight: 60, + } as const, + menuItemContent: { + display: 'flex', + flexDirection: 'column', + gap: 0.125, + } as const, + menuItemName: { + typography: 'title-sm', + fontWeight: 600, + // fontSize: '15px', + } as const, + menuItemDescription: { + fontSize: 'xs', + color: 'text.tertiary', + fontWeight: 400, + } as const, +}; + + +// Rich menu item (used in menu-rich mode) +function RichMenuItem(props: { + name: React.ReactNode; + description: React.ReactNode; + icon: React.ReactNode; + onClick: () => void; + delay?: number; + disabled?: boolean; + color?: ColorPaletteProp; +}) { + return ( + + + {props.icon} + + + + {props.name} + + + {props.description} + + + + ); +} + + +// Auto-download toggle (shown when browsing capability exists) +function AutoDownloadToggle(props: { delay?: number }) { + + // external state + const enableComposerAttach = useBrowseStore(s => s.enableComposerAttach); + + const handleToggle = React.useCallback((event: React.ChangeEvent) => { + event.stopPropagation(); + useBrowseStore.getState().setEnableComposerAttach(event.target.checked); + }, []); + + return <> + + + + { + // event.preventDefault(); + // event.stopPropagation(); + // setEnableComposerAttach(!enableComposerAttach); + // }} + > + + event.stopPropagation()} + sx={{ ml: 0.375 }} + /> + + + + Attach pasted URLs + + + Download and attach pasted web links + + + + ; +} + + +/** + * Portable attachment sources component. + * + * Three modes: + * - **menu-compact**: Mobile-style — icon trigger, simple MenuItems (no descriptions/animations) + * - **menu-rich**: Desktop-style — labeled button trigger, rich items with descriptions and animations + * - **inline-buttons**: Individual source buttons rendered inline (no dropdown) + */ +export const AttachmentSourcesMemo = React.memo(AttachmentSources); + +function AttachmentSources(props: { + // mode + mode: 'menu-compact' | 'menu-rich' | 'inline-buttons', + color?: ColorPaletteProp, // menu-rich and inline-buttons + richButtonStandOut?: boolean, // menu-rich only + // source availability - note that hasGoogleDriveCapability is local + canBrowse: boolean, // whether browsing is available (for Web button and showing the auto-attach toggle) + hasCamera: boolean, + // hasGoogleDrive: boolean, // it's now local: hasGoogleDriveCapability + hasScreenCapture: boolean, + // configuration + onlyImages?: boolean, // makes clipboard/drive/web unavailable + // callbacks + onAttachClipboard: () => void, + onAttachFiles: (files: FileWithHandle[], errorMessage: string | null) => void, + onAttachScreenCapture: (file: File) => void, + onOpenCamera: () => void, + onOpenGoogleDrivePicker?: () => void, // optional because requires additional external setup (e.g. user-storage of tokens) + onOpenWebInput: () => void, +}) { + + // state (screen capture — used in menu modes where the component handles the capture) + const [capturingScreen, setCapturingScreen] = React.useState(false); + const [screenCaptureError, setScreenCaptureError] = React.useState(null); + + + // handlers + + const { onAttachFiles, onAttachScreenCapture } = props; + + const handleAttachFilePicker = React.useCallback(() => { + return openFileForAttaching(true, onAttachFiles); + }, [onAttachFiles]); + + const handleTakeScreenCapture = React.useCallback(async () => { + setScreenCaptureError(null); + setCapturingScreen(true); + try { + const file = await takeScreenCapture(); + file && onAttachScreenCapture(file); + } catch (error: any) { + const message = error instanceof Error ? error.message : String(error); + setScreenCaptureError(message); + } + setCapturingScreen(false); + }, [onAttachScreenCapture]); + + + // inline-buttons mode — individual buttons rendered flat (no dropdown) + if (props.mode === 'inline-buttons') + return <> + + {/* Files */} + + + {/* Web */} + {!props.onlyImages && } + + {/* Google Drive */} + {hasGoogleDriveCapability && !props.onlyImages && !!props.onOpenGoogleDrivePicker && ( + + )} + + {/* Clipboard */} + {supportsClipboardRead() && !props.onlyImages && ( + + )} + + {/* Screen Capture */} + {props.hasScreenCapture && ( + + )} + + {/* Camera */} + {props.hasCamera && ( + + )} + + ; + + + // menu-compact mode (mobile) — simple icon trigger with flat menu items + if (props.mode === 'menu-compact') + return <> + + + + + + + + {/* Files */} + {/**/} + {/* */} + {/* {props.onlyImages ? 'Images' : 'File'}*/} + {/**/} + } onClick={handleAttachFilePicker} /> + + {/* Web */} + {!props.onlyImages && ( + // + // + // Web + // + } onClick={props.onOpenWebInput} disabled={!props.canBrowse} /> + )} + + {/* Google Drive */} + {!props.onlyImages && hasGoogleDriveCapability && !!props.onOpenGoogleDrivePicker && ( + // + // + // Drive + // + } onClick={props.onOpenGoogleDrivePicker} /> + )} + + {/* Clipboard */} + {!props.onlyImages && supportsClipboardRead() && ( + // + // + // Paste + // + } onClick={props.onAttachClipboard} /> + )} + + {/* Screen Capture */} + {props.hasScreenCapture && ( + // + // + // Screen + // + } onClick={handleTakeScreenCapture} disabled={capturingScreen} /> + )} + + {/* Camera */} + {/*{props.hasCamera && (*/} + {/* // */} + {/* // */} + {/* // Camera*/} + {/* // */} + {/* } onClick={props.onOpenCamera} />*/} + {/*)}*/} + + + + + {/* [mobile] Responsive Camera OCR button */} + {props.hasCamera && } + + ; + + + // menu-rich mode (desktop) — labeled button trigger with animated, descriptive menu items + return ( + + , + fullWidth: true, // to match other buttons in the col + sx: { + minWidth: 100, + justifyContent: 'flex-start', + borderRadius: ATTACH_BUTTON_RADIUS, + textWrap: 'nowrap', + ...(props.richButtonStandOut && { + backgroundColor: 'background.popup', + border: '1px solid', + borderColor: `${props.color || 'neutral'}.outlinedBorder`, + }), + // when aria-expanded is true (menu open), remove top border radius + '&[aria-expanded="true"]': { + borderTopRightRadius: 0, + borderTopLeftRadius: 0, + backgroundColor: `${props.color || 'neutral'}.softHoverBg`, + }, + }, + }, + }} + > + Attach + + + + + {/* File Attachment */} + } + description={props.onlyImages ? 'PNG, JPG, WEBP images to edit' : 'PDF, DOCX, images, code'} + onClick={handleAttachFilePicker} + delay={0} + /> + + {/* Web/URL Attachment */} + {!props.onlyImages && ( + } + description='Import from websites, including screenshots' + onClick={props.onOpenWebInput} + disabled={!props.canBrowse} + delay={0.02} + /> + )} + + {/* Google Drive Attachment */} + {!props.onlyImages && hasGoogleDriveCapability && !!props.onOpenGoogleDrivePicker && ( + } + description='Attach Google Drive files' + onClick={props.onOpenGoogleDrivePicker} + delay={0.04} + /> + )} + + {/* Clipboard Attachment */} + {!props.onlyImages && supportsClipboardRead() && ( + } + description='Auto-converts images and text to the best format' + onClick={props.onAttachClipboard} + delay={0.06} + /> + )} + + {/* Divider before labs features */} + {(props.hasScreenCapture || props.hasCamera) && } + + {/* Screen Capture */} + {props.hasScreenCapture && ( + } + description={screenCaptureError ? `Error: ${screenCaptureError}` : 'Capture windows, tabs, or screens'} + onClick={handleTakeScreenCapture} + disabled={capturingScreen} + color={screenCaptureError ? 'danger' : undefined} + delay={0.08} + /> + )} + + {/* Camera */} + {props.hasCamera && ( + } + description='Capture photos with optional text recognition' + onClick={props.onOpenCamera} + delay={0.1} + /> + )} + + {/* URL Auto-Download Toggle - only show when browse capability exists */} + {!props.onlyImages && props.canBrowse && ( + + )} + + + + ); +}