Focus-mode for mobile

This commit is contained in:
Enrico Ros
2026-02-28 01:59:16 -08:00
parent f21fe41188
commit 8bd6fd40fd
7 changed files with 112 additions and 17 deletions
+5 -3
View File
@@ -30,7 +30,7 @@ import { createErrorContentFragment, createTextContentFragment, DMessageAttachme
import { gcChatImageAssets } from '~/common/stores/chat/chat.gc';
import { getChatLLMId } from '~/common/stores/llms/store-llms';
import { getConversation, getConversationSystemPurposeId, useConversation } from '~/common/stores/chat/store-chats';
import { optimaActions, optimaOpenModels, optimaOpenPreferences } from '~/common/layout/optima/useOptima';
import { optimaActions, optimaOpenModels, optimaOpenPreferences, useOptimaChromeless } from '~/common/layout/optima/useOptima';
import { useFolderStore } from '~/common/stores/folders/store-chat-folders';
import { useIsMobile, useIsTallScreen } from '~/common/components/useMatchMedia';
import { useLLM } from '~/common/stores/llms/llms.hooks';
@@ -209,7 +209,8 @@ export function AppChat() {
});
// Composer Auto-hiding
const forceComposerHide = !!beamOpenStoreInFocusedPane /* || !focusedPaneConversationId */; // auto-hide when no chat (the 'please select a conversation...' state) doesn't feel good
const isChromeless = useOptimaChromeless() && isMobile; // auto-hide on Chromeless too
const forceComposerHide = isChromeless || !!beamOpenStoreInFocusedPane /* || !focusedPaneConversationId */; // auto-hide when no chat (the 'please select a conversation...' state) doesn't feel good
const composerAutoHide = useComposerAutoHide(forceComposerHide, composerHasContent);
// Window actions
@@ -492,6 +493,7 @@ export function AppChat() {
const focusedChatPanelContent = React.useMemo(() => !focusedPaneConversationId ? null :
<ChatPane
isMobile={isMobile}
conversationId={focusedPaneConversationId}
disableItems={!focusedPaneConversationId || isFocusedChatEmpty}
hasConversations={hasConversations}
@@ -768,7 +770,7 @@ export function AppChat() {
</Box>
{/* Hover zone for auto-hide */}
{!forceComposerHide && composerAutoHide.isHidden && <Box {...composerAutoHide.detectorProps} />}
{!isChromeless && !forceComposerHide && composerAutoHide.isHidden && <Box {...composerAutoHide.detectorProps} />}
{/* Diagrams */}
{!!diagramConfig && (
@@ -13,6 +13,7 @@ import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutl
import UnarchiveOutlinedIcon from '@mui/icons-material/UnarchiveOutlined';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { ChromelessItemButton } from '~/common/layout/optima/ChromelessItemButton';
import { CodiconSplitHorizontal } from '~/common/components/icons/CodiconSplitHorizontal';
import { CodiconSplitHorizontalRemove } from '~/common/components/icons/CodiconSplitHorizontalRemove';
import { CodiconSplitVertical } from '~/common/components/icons/CodiconSplitVertical';
@@ -37,6 +38,7 @@ function VariformPaneFrame() {
export function ChatPane(props: {
isMobile: boolean,
conversationId: DConversationId | null,
disableItems: boolean,
hasConversations: boolean,
@@ -143,6 +145,8 @@ export function ChatPane(props: {
</ListItemButton>
</ListItem>
{props.isMobile && <ChromelessItemButton />}
</OptimaPanelGroupedList>
{/* Chat Actions group */}
@@ -0,0 +1,43 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { IconButton } from '@mui/joy';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import MenuIcon from '@mui/icons-material/Menu';
import { LayoutSidebarRight } from '~/common/components/icons/LayoutSidebarRight';
import { optimaExitChromeless, optimaOpenDrawer, optimaOpenPanel } from './useOptima';
const buttonSx: SxProps = {
position: 'fixed',
top: '0.5rem',
zIndex: 25,
// backdropFilter: 'blur(8px)',
backgroundColor: 'background.surface',
boxShadow: 'md',
borderRadius: '50%',
} as const;
export function ChromelessFloatingButtons() {
return <>
{/* Left — where the drawer toggle usually is */}
<IconButton aria-label='Open Drawer' variant='soft' onClick={optimaOpenDrawer} style={{ left: '0.5rem' }} sx={buttonSx}>
<MenuIcon />
</IconButton>
{/* Center — exit chromeless (styled like the scroll-to-bottom button) */}
<IconButton aria-label='Exit Chrome-less' variant='soft' onClick={optimaExitChromeless} sx={buttonSx} style={{ left: '50%', transform: 'translateX(-50%)' }}>
<FullscreenExitIcon />
</IconButton>
{/* Right — where the panel toggle usually is */}
<IconButton aria-label='Open Menu' variant='soft' onClick={optimaOpenPanel} style={{ right: '0.5rem' }} sx={buttonSx}>
<LayoutSidebarRight />
</IconButton>
</>;
}
@@ -0,0 +1,18 @@
import * as React from 'react';
import { ListItemButton, ListItemDecorator } from '@mui/joy';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import { optimaToggleChromeless, useOptimaChromeless } from './useOptima';
export function ChromelessItemButton() {
const isChromeless = useOptimaChromeless();
return (
<ListItemButton onClick={optimaToggleChromeless}>
<ListItemDecorator>{isChromeless ? <FullscreenExitIcon /> : <FullscreenIcon />}</ListItemDecorator>
{isChromeless ? 'Exit Focus-mode' : 'Focus mode'}
</ListItemButton>
);
}
+22 -10
View File
@@ -5,10 +5,13 @@ import { Box } from '@mui/joy';
import { themeBgApp, themeZIndexPageBar } from '~/common/app.theme';
import type { NavItemApp } from '~/common/app.nav';
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
// import { MobileNav } from './MobileNav';
import { OptimaBar } from '~/common/layout/optima/bar/OptimaBar';
import { optimaHasMOTD, OptimaMOTD } from '~/common/layout/optima/OptimaMOTD';
import { ChromelessFloatingButtons } from './ChromelessFloatingButtons';
import { useOptimaChromeless } from './useOptima';
const pageCoreSx: SxProps = {
@@ -44,8 +47,12 @@ export const PageCore = (props: {
isFull: boolean,
isMobile: boolean,
children: React.ReactNode,
}) =>
<Box
}) => {
// external state
const isChromeless = useOptimaChromeless();
return <Box
component={props.component}
sx={props.currentApp?.pageBrighter ? pageCoreBrighterSx : props.isFull ? pageCoreFullSx : pageCoreSx}
>
@@ -53,13 +60,17 @@ export const PageCore = (props: {
{/* Optional deployment MOTD */}
{optimaHasMOTD && <OptimaMOTD />}
{/* Responsive page bar (pluggable App Center Items and App Menu) */}
<OptimaBar
component='header'
currentApp={props.currentApp}
isMobile={props.isMobile}
sx={pageCoreBarSx}
/>
{/* Responsive page bar (pluggable App Center Items and App Menu) - collapsible for chromeless mode */}
<ExpanderControlledBox expanded={!isChromeless}>
<OptimaBar
component='header'
currentApp={props.currentApp}
isMobile={props.isMobile}
sx={pageCoreBarSx}
/>
</ExpanderControlledBox>
{/* Chromeless alternative to the OptimaBar */}
{isChromeless && <ChromelessFloatingButtons />}
{/* Page (NextJS) must make the assumption they're in a flex-col layout */}
{props.children}
@@ -74,4 +85,5 @@ export const PageCore = (props: {
{/* />*/}
{/*)}*/}
</Box>;
</Box>;
};
@@ -16,7 +16,7 @@ export type ModelOptionsContext = 'full' | 'parameters';
interface OptimaState {
// modes
// isFocusedMode: boolean; // when active, the Mobile App menu is not displayed
isChromeless: boolean; // when active, the top bar and composer are hidden, with floating buttons
// panes
drawerIsOpen: boolean;
@@ -62,7 +62,7 @@ const modalsClosedState = {
const initialState: OptimaState = {
// modes
// isFocusedMode: false,
isChromeless: false,
// panes
drawerIsOpen: initialDrawerOpen(),
@@ -81,7 +81,7 @@ const initialState: OptimaState = {
export interface OptimaActions {
// setIsFocusedMode: (isFocusedMode: boolean) => void;
setChromeless: (isChromeless: boolean) => void;
closeDrawer: () => void;
openDrawer: () => void;
@@ -168,7 +168,7 @@ export const useLayoutOptimaStore = create<OptimaState & OptimaActions>((_set, _
...initialState,
// setIsFocusedMode: (isFocusedMode) => _set({ isFocusedMode }),
setChromeless: (isChromeless) => _set({ isChromeless }),
closeDrawer: () => {
// prevent accidental immediate close (e.g. double-click, animation protection)
+16
View File
@@ -6,6 +6,22 @@ import { NavItemApp } from '~/common/app.nav';
import { useOptimaPortalHasInputs } from '~/common/layout/optima/portals/useOptimaPortalHasInputs';
// Chromeless Mode
export function optimaToggleChromeless() {
const state = useLayoutOptimaStore.getState();
state.setChromeless(!state.isChromeless);
}
export function optimaExitChromeless() {
useLayoutOptimaStore.getState().setChromeless(false);
}
export function useOptimaChromeless() {
return useLayoutOptimaStore(({ isChromeless }) => isChromeless);
}
// Drawer
export function optimaCloseDrawer() {