diff --git a/src/apps/chat/components/composer/ButtonCameraCapture.tsx b/src/apps/chat/components/composer/ButtonCameraCapture.tsx new file mode 100644 index 000000000..c69691af0 --- /dev/null +++ b/src/apps/chat/components/composer/ButtonCameraCapture.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { Button, IconButton } from '@mui/joy'; +import AddAPhotoIcon from '@mui/icons-material/AddAPhoto'; + +import { CameraCaptureModal } from './CameraCaptureModal'; + +const CAMERA_ENABLE_ON_DESKTOP = false; // process.env.NODE_ENV === 'development'; + + +export function ButtonCameraCapture(props: { isMobile: boolean, onOCR: (ocrText: string) => void }) { + // state + const [open, setOpen] = React.useState(false); + + return <> + + {/* The Button */} + {props.isMobile ? ( + setOpen(true)}> + + + ) : CAMERA_ENABLE_ON_DESKTOP ? ( + + ) : undefined} + + {/* The actual capture dialog, which will stream the video */} + {open && setOpen(false)} onOCR={props.onOCR} />} + + ; +} \ No newline at end of file diff --git a/src/apps/chat/components/composer/ButtonClipboardPaste.tsx b/src/apps/chat/components/composer/ButtonClipboardPaste.tsx new file mode 100644 index 000000000..178cb0a95 --- /dev/null +++ b/src/apps/chat/components/composer/ButtonClipboardPaste.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { Box, Button, IconButton, Tooltip } from '@mui/joy'; +import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo'; + +import { KeyStroke } from '~/common/components/KeyStroke'; + + +const pasteClipboardLegend = + + Paste as ๐Ÿ“š Markdown attachment
+ Also converts Code and Tables
+ +
; + +export function ButtonClipboardPaste(props: { isMobile: boolean, isDeveloperMode: boolean, onPaste: () => void }) { + return props.isMobile ? ( + + + + ) : ( + + + + ); +} \ No newline at end of file diff --git a/src/apps/chat/components/composer/ButtonFileAttach.tsx b/src/apps/chat/components/composer/ButtonFileAttach.tsx new file mode 100644 index 000000000..7dfbe3971 --- /dev/null +++ b/src/apps/chat/components/composer/ButtonFileAttach.tsx @@ -0,0 +1,70 @@ +import { Box, Button, IconButton, Stack, Tooltip } from '@mui/joy'; +import * as React from 'react'; +import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined'; + +const attachFileLegend = + + + Attach a file + + + + + + + + + + + + + + + + + + + +
Textโ†’๐Ÿ“ As-is
Codeโ†’๐Ÿ“š Markdown
PDFโ†’๐Ÿ“ Text (summarized)
+ + Drag & drop in chat for faster loads โšก + +
; + + +export function ButtonFileAttach(props: { isMobile: boolean, onAttachFiles: (files: FileList) => Promise }) { + + // state + const attachmentFileInputRef = React.useRef(null); + + const handleShowFilePicker = () => attachmentFileInputRef.current?.click(); + + const handleLoadAttachment = (event: React.ChangeEvent) => { + // NOTE: resetting the target value allows for the selector dialog to pop-up again + const files = event.target?.files; + if (files && files.length >= 1) + props.onAttachFiles(files).finally(() => event.target.value = ''); + else + event.target.value = ''; + }; + + return <> + + {/* Mobile icon or Desktop button */} + {props.isMobile ? ( + + + + ) : ( + + + + )} + + + + ; +} \ No newline at end of file diff --git a/src/apps/chat/components/composer/CameraCaptureButton.tsx b/src/apps/chat/components/composer/CameraCaptureButton.tsx deleted file mode 100644 index 969a6975c..000000000 --- a/src/apps/chat/components/composer/CameraCaptureButton.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; - -import { Button, IconButton } from '@mui/joy'; -import AddAPhotoIcon from '@mui/icons-material/AddAPhoto'; - -import { hideOnDesktop, hideOnMobile } from '~/common/app.theme'; - -import { CameraCaptureModal } from './CameraCaptureModal'; - -const showOnDesktop = false; // process.env.NODE_ENV === 'development'; - - -export function CameraCaptureButton(props: { onOCR: (ocrText: string) => void }) { - // state - const [open, setOpen] = React.useState(false); - - return <> - - {/* The Button */} - setOpen(true)} sx={hideOnDesktop}> - - - - {/* Also show a button on desktop while in development */} - {showOnDesktop && } - - {/* The actual capture dialog, which will stream the video */} - {open && setOpen(false)} onOCR={props.onOCR} />} - - ; -} \ No newline at end of file diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index c1b545052..75a984e39 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -3,10 +3,8 @@ import { shallow } from 'zustand/shallow'; import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy'; import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types'; -import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined'; import AutoModeIcon from '@mui/icons-material/AutoMode'; import CallIcon from '@mui/icons-material/Call'; -import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import FormatPaintIcon from '@mui/icons-material/FormatPaint'; import MicIcon from '@mui/icons-material/Mic'; @@ -26,21 +24,23 @@ import { KeyStroke } from '~/common/components/KeyStroke'; import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition'; import { countModelTokens } from '~/common/util/token-counter'; import { extractFilePathsWithCommonRadix } from '~/common/util/dropTextUtils'; -import { hideOnDesktop, hideOnMobile } from '~/common/app.theme'; import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown'; import { launchAppCall } from '~/common/app.routes'; import { openLayoutPreferences } from '~/common/layout/store-applayout'; import { pdfToText } from '~/common/util/pdfToText'; import { useChatStore } from '~/common/state/store-chats'; +import { useDebouncer } from '~/common/components/useDebouncer'; import { useGlobalShortcut } from '~/common/components/useGlobalShortcut'; +import { useIsMobile } from '~/common/components/useMatchMedia'; import { useUIPreferencesStore } from '~/common/state/store-ui'; -import { CameraCaptureButton } from './CameraCaptureButton'; +import { ButtonCameraCapture } from './ButtonCameraCapture'; +import { ButtonClipboardPaste } from './ButtonClipboardPaste'; +import { ButtonFileAttach } from './ButtonFileAttach'; import { ChatModeId, useComposerStartupText } from './store-composer'; import { ChatModeMenu } from './ChatModeMenu'; import { TokenBadge } from './TokenBadge'; import { TokenProgressbar } from './TokenProgressbar'; -import { useDebouncer } from '~/common/components/useDebouncer'; /// Text template helpers @@ -59,42 +59,6 @@ const expandPromptTemplate = (template: string, dict: object) => (inputValue: st }; -const attachFileLegend = - - - Attach a file - - - - - - - - - - - - - - - - - - - -
Textโ†’๐Ÿ“ As-is
Codeโ†’๐Ÿ“š Markdown
PDFโ†’๐Ÿ“ Text (summarized)
- - Drag & drop in chat for faster loads โšก - -
; - -const pasteClipboardLegend = - - Paste as ๐Ÿ“š Markdown attachment
- Also converts Code and Tables
- -
; - const MicButton = (props: { variant: VariantProp, color: ColorPaletteProp, onClick: () => void, sx?: SxProps }) => @@ -163,9 +127,9 @@ export function Composer(props: { const [reducerText, setReducerText] = React.useState(''); const [reducerTextTokens, setReducerTextTokens] = React.useState(0); const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState(null); - const attachmentFileInputRef = React.useRef(null); // external state + const isMobile = useIsMobile(); const [chatModeId, setChatModeId] = React.useState('immediate'); const [startupText, setStartupText] = useComposerStartupText(); const [enterIsNewline, experimentalLabs] = useUIPreferencesStore(state => [state.enterIsNewline, state.experimentalLabs], shallow); @@ -188,7 +152,9 @@ export function Composer(props: { } }, [setComposeText, setStartupText, startupText]); + // derived state + const isDesktop = !isMobile; const tokenLimit = chatLLM?.contextTokens || 0; const directTokens = React.useMemo(() => { return (!debouncedText || !chatLLMId) ? 4 : 4 + countModelTokens(debouncedText, chatLLMId, 'composer text'); @@ -286,7 +252,7 @@ export function Composer(props: { const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'outlined' : 'plain'; - async function loadAndAttachFiles(files: FileList, overrideFileNames: string[]) { + async function loadAndAttachFiles(files: FileList, overrideFileNames?: string[]): Promise { // NOTE: we tried to get the common 'root prefix' of the files here, so that we could attach files with a name that's relative // to the common root, but the files[].webkitRelativePath property is not providing that information @@ -295,7 +261,7 @@ export function Composer(props: { let newText = ''; for (let i = 0; i < files.length; i++) { const file = files[i]; - const fileName = overrideFileNames.length === files.length ? overrideFileNames[i] : file.name; + const fileName = overrideFileNames?.length === files.length ? overrideFileNames[i] : file.name; let fileText = ''; try { if (file.type === 'application/pdf') @@ -335,20 +301,9 @@ export function Composer(props: { setComposeText(text => text + newText); }; - const handleShowFilePicker = () => attachmentFileInputRef.current?.click(); - - const handleLoadAttachment = async (e: React.ChangeEvent) => { - const files = e.target?.files; - if (files && files.length >= 1) - await loadAndAttachFiles(files, []); - - // this is needed to allow the same file to be selected again - e.target.value = ''; - }; - const handleCameraOCR = (text: string) => text && setComposeText(expandPromptTemplate(PromptTemplates.PasteMarkdown, { clipboard: text })); - const handlePasteButtonClicked = React.useCallback(async () => { + const handlePasteClipboard = React.useCallback(async () => { for (const clipboardItem of await navigator.clipboard.read()) { // when pasting html, only process tables as markdown (e.g. from Excel), or fallback to text @@ -381,14 +336,14 @@ export function Composer(props: { } }, [setComposeText]); - useGlobalShortcut('v', true, true, false, handlePasteButtonClicked); + useGlobalShortcut('v', true, true, false, handlePasteClipboard); - const handleTextareaCtrlV = async (e: React.ClipboardEvent) => { + const handleTextareaCtrlV = async (event: React.ClipboardEvent) => { // paste local files - if (e.clipboardData.files.length > 0) { - e.preventDefault(); - await loadAndAttachFiles(e.clipboardData.files, []); + if (event.clipboardData.files?.length) { + event.preventDefault(); + await loadAndAttachFiles(event.clipboardData.files, []); return; } @@ -445,6 +400,7 @@ export function Composer(props: { console.log('Unhandled Drop event. Contents: ', e.dataTransfer.types.map(t => `${t}: ${e.dataTransfer.getData(t)}`)); }; + const isImmediate = chatModeId === 'immediate'; const isWriteUser = chatModeId === 'write-user'; const isChat = isImmediate || isWriteUser; @@ -464,6 +420,7 @@ export function Composer(props: { ? 'Chat with me ยท drop source files ยท attach code...' : /*isProdiaConfigured ?*/ 'Chat ยท /react ยท /imagine ยท drop text files...' /*: 'Chat ยท /react ยท drop text files...'*/; + return ( @@ -475,40 +432,16 @@ export function Composer(props: { {/* [mobile] Mic button */} - {isSpeechEnabled && - - } + {isMobile && isSpeechEnabled && } {/* Responsive Camera OCR button */} - + {/* Responsive Attach button */} - - - - - - + {/* Responsive Paste button */} - - - - - - - - + @@ -556,7 +489,7 @@ export function Composer(props: { m: 1, display: 'flex', flexDirection: 'column', gap: 1, }}> - + {isDesktop && } {micIsRunning && ( + {/* [mobile, corner] Call secondary button */} + {isMobile && isChat && } - {/* [mobile] [corner] Call button */} - {isChat && } - - {/* [mobile] [corner] Draw button */} - {(isDraw || isDrawPlus) && } + {/* [mobile, corner] Draw Options secondary button */} + {isMobile && (isDraw || isDrawPlus) && } {/* Responsive Send/Stop buttons */} {assistantTyping @@ -666,13 +591,16 @@ export function Composer(props: { - {/* [desktop] other buttons (aligned to bottom for now, and mutually exclusive) */} - + {/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */} + {isDesktop && + {/* [desktop] Call secondary button */} {isChat && } + {/* [desktop] Draw Options secondary button */} {(isDraw || isDrawPlus) && } - + + } diff --git a/src/apps/models-modal/ModelsSourceSelector.tsx b/src/apps/models-modal/ModelsSourceSelector.tsx index 946158dd6..b339723df 100644 --- a/src/apps/models-modal/ModelsSourceSelector.tsx +++ b/src/apps/models-modal/ModelsSourceSelector.tsx @@ -11,7 +11,7 @@ import { createModelSourceForVendor, findAllVendors, findVendorById } from '~/mo import { CloseableMenu } from '~/common/components/CloseableMenu'; import { ConfirmationModal } from '~/common/components/ConfirmationModal'; -import { hideOnDesktop, hideOnMobile } from '~/common/app.theme'; +import { useIsMobile } from '~/common/components/useMatchMedia'; /*function locationIcon(vendor?: IModelVendor | null) { @@ -43,6 +43,7 @@ export function ModelsSourceSelector(props: { const [confirmDeletionSourceId, setConfirmDeletionSourceId] = React.useState(null); // external state + const isMobile = useIsMobile(); const { modelSources, addModelSource, removeModelSource } = useModelsStore(state => ({ modelSources: state.sources, addModelSource: state.addSource, removeModelSource: state.removeSource, @@ -115,9 +116,9 @@ export function ModelsSourceSelector(props: { {/* Models: [Select] Add Delete */} - + {!isMobile && Service: - + } - - - - + {isMobile ? ( + + + + ) : ( + + )}