Attachments: consolidated/unified menu

This commit is contained in:
Enrico Ros
2026-03-03 09:23:48 -08:00
parent a807bdd6b6
commit 5198fa66cf
2 changed files with 476 additions and 64 deletions
+36 -64
View File
@@ -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 && <ButtonMicMemo variant={micVariant} color={micColor === 'danger' ? 'danger' : showTint || micColor} errorMessage={recognitionState.errorMessage} onClick={handleToggleMic} />}
{/* Responsive Camera OCR button */}
{showChatAttachments && <ButtonAttachCameraMemo color={showTint} isMobile onOpenCamera={handleOpenCamera} />}
{/* [mobile] Attach file button (in draw with image mode) */}
{showChatAttachments === 'only-images' && <ButtonAttachFilesMemo color={showTint} isMobile onAttachFiles={handleAttachFiles} fullWidth multiple />}
{/* [mobile] [+] button */}
{/* [mobile] [+] attachment sources menu */}
{showChatAttachments === true && (
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
</MenuButton>
<Menu>
{/* Responsive Open Files button */}
<MenuItem>
<ButtonAttachFilesMemo onAttachFiles={handleAttachFiles} fullWidth multiple />
</MenuItem>
{/* Responsive Web button */}
<MenuItem>
<ButtonAttachWebMemo disabled={!hasComposerBrowseCapability} onOpenWebInput={openWebInputDialog} />
</MenuItem>
{/* Responsive Google Drive button */}
{hasGoogleDriveCapability && <MenuItem>
<ButtonAttachGoogleDriveMemo onOpenGoogleDrivePicker={openGoogleDrivePicker} fullWidth />
</MenuItem>}
{/* Responsive Paste button */}
{supportsClipboardRead() && <MenuItem>
<ButtonAttachClipboardMemo onAttachClipboard={attachAppendClipboardItems} />
</MenuItem>}
</Menu>
</Dropdown>
<AttachmentSourcesMemo
mode='menu-compact'
canBrowse={hasComposerBrowseCapability}
hasScreenCapture={false}
hasCamera={true}
onlyImages={false}
onAttachClipboard={attachAppendClipboardItems}
onAttachFiles={handleAttachFiles}
onAttachScreenCapture={handleAttachScreenCapture}
onOpenCamera={handleOpenCamera}
onOpenGoogleDrivePicker={openGoogleDrivePicker}
onOpenWebInput={openWebInputDialog}
/>
)}
{/* [Mobile] MultiChat button */}
@@ -837,31 +814,26 @@ export function Composer(props: {
{/* [Desktop, Col1] Insert Multi-modal content buttons */}
{isDesktop && showChatAttachments && (
<Box sx={{ flexGrow: 0, display: 'grid', gap: (labsAttachScreenCapture && labsCameraDesktop) ? 0.5 : 1, alignSelf: 'flex-start' }}>
<Box sx={{ flexGrow: 0, display: 'grid', gap: 0.5, alignSelf: 'flex-start' }}>
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
{/* Attach*/}
{/*</FormHelperText>*/}
{/* [desktop] Attachment Sources: inline buttons */}
<AttachmentSourcesMemo
mode='inline-buttons'
color={showTint}
canBrowse={hasComposerBrowseCapability}
hasScreenCapture={labsAttachScreenCapture && supportsScreenCapture}
hasCamera={labsCameraDesktop && supportsCameraCapture()}
onlyImages={showChatAttachments === 'only-images'}
onAttachClipboard={attachAppendClipboardItems}
onAttachFiles={handleAttachFiles}
onAttachScreenCapture={handleAttachScreenCapture}
onOpenCamera={handleOpenCamera}
onOpenGoogleDrivePicker={openGoogleDrivePicker}
onOpenWebInput={openWebInputDialog}
/>
{/* Responsive Open Files button */}
<ButtonAttachFilesMemo color={showTint} onAttachFiles={handleAttachFiles} fullWidth multiple />
{/* Responsive Web button */}
{showChatAttachments !== 'only-images' && <ButtonAttachWebMemo color={showTint} disabled={!hasComposerBrowseCapability} onOpenWebInput={openWebInputDialog} />}
{/* Responsive Google Drive button */}
{hasGoogleDriveCapability && showChatAttachments !== 'only-images' && <ButtonAttachGoogleDriveMemo color={showTint} onOpenGoogleDrivePicker={openGoogleDrivePicker} />}
{/* Responsive Paste button */}
{supportsClipboardRead() && showChatAttachments !== 'only-images' && <ButtonAttachClipboardMemo color={showTint} onAttachClipboard={attachAppendClipboardItems} />}
{/* Responsive Screen Capture button */}
{labsAttachScreenCapture && supportsScreenCapture && <ButtonAttachScreenCaptureMemo color={showTint} onAttachScreenCapture={handleAttachScreenCapture} />}
{/* Responsive Camera OCR button */}
{labsCameraDesktop && supportsCameraCapture() && <ButtonAttachCameraMemo color={showTint} onOpenCamera={handleOpenCamera} />}
</Box>)}
</Box>
)}
{/* Top: Textarea & Mic & Overlays, Bottom, Attachment Drafts */}
@@ -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 (
<MenuItem
onClick={props.onClick}
disabled={props.disabled}
color={props.color}
sx={!props.delay ? _style.menuItem : {
..._style.menuItem,
animation: `${animationMenuItem} 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${props.delay}s both`,
}}
>
<ListItemDecorator>
{props.icon}
</ListItemDecorator>
<Box sx={_style.menuItemContent}>
<Box sx={_style.menuItemName}>
{props.name}
</Box>
<Box sx={_style.menuItemDescription}>
{props.description}
</Box>
</Box>
</MenuItem>
);
}
// 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<HTMLInputElement>) => {
event.stopPropagation();
useBrowseStore.getState().setEnableComposerAttach(event.target.checked);
}, []);
return <>
<Divider sx={{ my: 0.5 }} />
<ListItem
sx={{
..._style.menuItem,
animation: `${animationMenuItem} 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${props.delay}s both`,
}}
// onClick={(event) => {
// event.preventDefault();
// event.stopPropagation();
// setEnableComposerAttach(!enableComposerAttach);
// }}
>
<ListItemDecorator>
<Checkbox
size='sm'
color='neutral'
checked={enableComposerAttach}
onChange={handleToggle}
onClick={(event) => event.stopPropagation()}
sx={{ ml: 0.375 }}
/>
</ListItemDecorator>
<Box sx={_style.menuItemContent}>
<Box sx={{ typography: 'title-sm' }}>
Attach pasted URLs
</Box>
<Box sx={_style.menuItemDescription}>
Download and attach pasted web links
</Box>
</Box>
</ListItem>
</>;
}
/**
* 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<string | null>(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 */}
<ButtonAttachFilesMemo color={props.color} onAttachFiles={props.onAttachFiles} fullWidth multiple />
{/* Web */}
{!props.onlyImages && <ButtonAttachWebMemo color={props.color} disabled={!props.canBrowse} onOpenWebInput={props.onOpenWebInput} />}
{/* Google Drive */}
{hasGoogleDriveCapability && !props.onlyImages && !!props.onOpenGoogleDrivePicker && (
<ButtonAttachGoogleDriveMemo color={props.color} onOpenGoogleDrivePicker={props.onOpenGoogleDrivePicker} />
)}
{/* Clipboard */}
{supportsClipboardRead() && !props.onlyImages && (
<ButtonAttachClipboardMemo color={props.color} onAttachClipboard={props.onAttachClipboard} />
)}
{/* Screen Capture */}
{props.hasScreenCapture && (
<ButtonAttachScreenCaptureMemo color={props.color} onAttachScreenCapture={props.onAttachScreenCapture} />
)}
{/* Camera */}
{props.hasCamera && (
<ButtonAttachCameraMemo color={props.color} onOpenCamera={props.onOpenCamera} />
)}
</>;
// menu-compact mode (mobile) — simple icon trigger with flat menu items
if (props.mode === 'menu-compact')
return <>
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddRoundedIcon />
</MenuButton>
<Menu sx={{ '--List-padding': '0.5rem' }}>
{/* Files */}
{/*<MenuItem onClick={handleAttachFilePicker}>*/}
{/* <ListItemDecorator><AttachFileRoundedIcon /></ListItemDecorator>*/}
{/* {props.onlyImages ? 'Images' : 'File'}*/}
{/*</MenuItem>*/}
<RichMenuItem name={props.onlyImages ? 'Images' : 'Files'} description='PDF, DOCX, images, code' color={props.color} icon={<AttachFileRoundedIcon />} onClick={handleAttachFilePicker} />
{/* Web */}
{!props.onlyImages && (
// <MenuItem onClick={props.onOpenWebInput} disabled={!props.canBrowse}>
// <ListItemDecorator><LanguageRoundedIcon /></ListItemDecorator>
// Web
// </MenuItem>
<RichMenuItem name='Web' description='Import from web pages' color={props.color} icon={<LanguageRoundedIcon />} onClick={props.onOpenWebInput} disabled={!props.canBrowse} />
)}
{/* Google Drive */}
{!props.onlyImages && hasGoogleDriveCapability && !!props.onOpenGoogleDrivePicker && (
// <MenuItem onClick={props.onOpenGoogleDrivePicker}>
// <ListItemDecorator><AddToDriveRoundedIcon /></ListItemDecorator>
// Drive
// </MenuItem>
<RichMenuItem name='Drive' description='Attach Google Drive files' color={props.color} icon={<AddToDriveRoundedIcon />} onClick={props.onOpenGoogleDrivePicker} />
)}
{/* Clipboard */}
{!props.onlyImages && supportsClipboardRead() && (
// <MenuItem onClick={props.onAttachClipboard}>
// <ListItemDecorator><ContentPasteGoIcon /></ListItemDecorator>
// Paste
// </MenuItem>
<RichMenuItem name='Clipboard' description='Auto-convert to the best format' color={props.color} icon={<ContentPasteGoIcon />} onClick={props.onAttachClipboard} />
)}
{/* Screen Capture */}
{props.hasScreenCapture && (
// <MenuItem onClick={handleTakeScreenCapture} disabled={capturingScreen}>
// <ListItemDecorator><ScreenshotMonitorIcon /></ListItemDecorator>
// Screen
// </MenuItem>
<RichMenuItem name='Screen' description={screenCaptureError ? `Error: ${screenCaptureError}` : 'Capture windows, tabs, or screens'} color={screenCaptureError ? 'danger' : props.color} icon={<ScreenshotMonitorIcon />} onClick={handleTakeScreenCapture} disabled={capturingScreen} />
)}
{/* Camera */}
{/*{props.hasCamera && (*/}
{/* // <MenuItem onClick={props.onOpenCamera}>*/}
{/* // <ListItemDecorator><CameraAltOutlinedIcon /></ListItemDecorator>*/}
{/* // Camera*/}
{/* // </MenuItem>*/}
{/* <RichMenuItem name='Camera' description='Capture photos and optional OCR' color={props.color} icon={<CameraAltOutlinedIcon />} onClick={props.onOpenCamera} />*/}
{/*)}*/}
</Menu>
</Dropdown>
{/* [mobile] Responsive Camera OCR button */}
{props.hasCamera && <ButtonAttachCameraMemo isMobile color={props.color} onOpenCamera={props.onOpenCamera} />}
</>;
// menu-rich mode (desktop) — labeled button trigger with animated, descriptive menu items
return (
<Dropdown>
<MenuButton
slots={{ root: Button }}
slotProps={{
root: {
// size: 'sm',
variant: 'plain',
color: props.color,
startDecorator: <AddRoundedIcon />,
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
</MenuButton>
<Menu
// variant='soft'
color={props.color}
placement='top-start'
popperOptions={{ modifiers: [{ name: 'offset', options: { offset: [-10 /* 62 */, -2] } }] }}
sx={{
minWidth: 280,
'--List-padding': '0.5rem',
animation: `${animationMenu} 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
// boxShadow: '0 16px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
boxShadow: 'md',
borderRadius: ATTACH_BUTTON_RADIUS,
border: '1px solid',
borderColor: `${props.color || 'neutral'}.outlinedBorder`,
backgroundColor: 'background.popup',
overflow: 'hidden',
}}
>
{/* File Attachment */}
<RichMenuItem
name={props.onlyImages ? 'Images' : 'Files'}
icon={<AttachFileRoundedIcon />}
description={props.onlyImages ? 'PNG, JPG, WEBP images to edit' : 'PDF, DOCX, images, code'}
onClick={handleAttachFilePicker}
delay={0}
/>
{/* Web/URL Attachment */}
{!props.onlyImages && (
<RichMenuItem
name='Web'
icon={<LanguageRoundedIcon />}
description='Import from websites, including screenshots'
onClick={props.onOpenWebInput}
disabled={!props.canBrowse}
delay={0.02}
/>
)}
{/* Google Drive Attachment */}
{!props.onlyImages && hasGoogleDriveCapability && !!props.onOpenGoogleDrivePicker && (
<RichMenuItem
name='Drive'
icon={<AddToDriveRoundedIcon />}
description='Attach Google Drive files'
onClick={props.onOpenGoogleDrivePicker}
delay={0.04}
/>
)}
{/* Clipboard Attachment */}
{!props.onlyImages && supportsClipboardRead() && (
<RichMenuItem
name='Clipboard'
icon={<ContentPasteGoIcon />}
description='Auto-converts images and text to the best format'
onClick={props.onAttachClipboard}
delay={0.06}
/>
)}
{/* Divider before labs features */}
{(props.hasScreenCapture || props.hasCamera) && <Divider sx={{ my: 0.5 }} />}
{/* Screen Capture */}
{props.hasScreenCapture && (
<RichMenuItem
name='Screen'
icon={<ScreenshotMonitorIcon />}
description={screenCaptureError ? `Error: ${screenCaptureError}` : 'Capture windows, tabs, or screens'}
onClick={handleTakeScreenCapture}
disabled={capturingScreen}
color={screenCaptureError ? 'danger' : undefined}
delay={0.08}
/>
)}
{/* Camera */}
{props.hasCamera && (
<RichMenuItem
name='Camera'
icon={<CameraAltOutlinedIcon />}
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 && (
<AutoDownloadToggle delay={0.12} />
)}
</Menu>
</Dropdown>
);
}