diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 24e38544..0a6a659f 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -194,6 +194,24 @@ describe('Content-only pages render without errors', () => { expect(html).not.toContain('animate-spin') }) + it('ContextUsageIndicator opens tap details in compact mobile mode', async () => { + render( + , + ) + + fireEvent.click(screen.getByLabelText('Context usage not calculated')) + + expect(await screen.findByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getAllByText('kimi-k2.6')).toHaveLength(2) + expect(screen.getAllByText('Context usage will be calculated after the session starts.')).toHaveLength(2) + }) + it('EmptySession plus menu exposes uploads and slash commands before chat starts', async () => { await act(async () => { render() diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 08d363eb..b3f21f83 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -725,25 +725,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro isHeroComposer ? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}` : compact - ? 'border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-3' - : `bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 py-3' : 'px-4 py-4'}` + ? `border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-3 py-3'}` + : `bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-4 py-4'}` } >
event.preventDefault()} onDrop={handleDrop} > @@ -964,7 +964,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro : t('common.run') : undefined } - className={`flex items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${ + className={`flex shrink-0 items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${ iconOnlyAction ? `${isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'h-8 w-8 px-0 py-0'}` : 'w-[112px] px-3 py-1.5' } ${ !isMemberSession && isActive diff --git a/desktop/src/components/chat/ContextUsageIndicator.tsx b/desktop/src/components/chat/ContextUsageIndicator.tsx index 08a17082..512894e3 100644 --- a/desktop/src/components/chat/ContextUsageIndicator.tsx +++ b/desktop/src/components/chat/ContextUsageIndicator.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions' import { useTranslation } from '../../i18n' import type { ChatState } from '../../types/chat' +import { MobileBottomSheet } from '../shared/MobileBottomSheet' type Props = { sessionId?: string @@ -69,6 +70,7 @@ export function ContextUsageIndicator({ const [error, setError] = useState(null) const [updatedAt, setUpdatedAt] = useState(null) const [inspectionModel, setInspectionModel] = useState(null) + const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false) const requestSeq = useRef(0) const contextIdentityRef = useRef('') @@ -172,7 +174,12 @@ export function ContextUsageIndicator({ -
+
@@ -268,6 +277,81 @@ export function ContextUsageIndicator({
)}
+ + {compact && ( + setMobileDetailsOpen(false)} + title={t('contextIndicator.title')} + closeLabel={t('tabs.close')} + ariaLabel={t('contextIndicator.title')} + headerExtra={( +
+ {displayModel ?? t('contextIndicator.modelUnknown')} +
+ )} + contentClassName="p-4" + > +
+
+ {displayContext ? formatPercent(percentage) : '--'} +
+ {contextSource === 'estimate' && ( + + {t('contextIndicator.estimate')} + + )} +
+ + {displayContext ? ( +
+
+
+
{t('contextIndicator.used')}
+
{formatNumber(usedTokens)}
+
+
+
{t('contextIndicator.free')}
+
{formatNumber(freeTokens)}
+
+
+
{t('contextIndicator.window')}
+
{maxTokens > 0 ? formatNumber(maxTokens) : '--'}
+
+
+ {details.length > 0 && ( +
+ {details.map((category) => { + const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0 + return ( +
+
+ {category.name} + {formatNumber(category.tokens)} +
+
+
+
+
+ ) + })} +
+ )} +
+ {formatUpdatedAt(updatedAt, t)} +
+
+ ) : ( +
+ {isPendingContext + ? t('contextIndicator.pendingDetail') + : loading + ? t('contextIndicator.loading') + : t('contextIndicator.unavailableDetail')} +
+ )} + + )}
) } diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 606eaccb..a1a88014 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -9,6 +9,9 @@ import { useSettingsStore } from '../../stores/settingsStore' import type { SavedProvider } from '../../types/provider' import type { RuntimeSelection } from '../../types/runtime' import type { EffortLevel, ModelInfo } from '../../types/settings' +import { useMobileViewport } from '../../hooks/useMobileViewport' +import { isTauriRuntime } from '../../lib/desktopRuntime' +import { MobileBottomSheet } from '../shared/MobileBottomSheet' type ProviderChoice = { providerId: string | null @@ -127,6 +130,7 @@ export function ModelSelector({ compact = false, }: Props = {}) { const t = useTranslation() + const isMobileBrowser = useMobileViewport() && !isTauriRuntime() const { currentModel: storeModel, availableModels, @@ -306,8 +310,172 @@ export function ModelSelector({ setOpen(false) } + const dropdownContent = ( + <> +
+ {!isMobileBrowser && ( +
+ {t('model.configuration')} +
+ )} + + {isRuntimeScoped ? ( +
+ {providerChoices.map((choice) => ( +
+
+ + {choice.providerName} + + {choice.isDefault && ( + + {t('settings.providers.default')} + + )} +
+ +
+ {choice.models.map((model) => { + const isSelected = + activeRuntimeSelection?.providerId === choice.providerId && + activeRuntimeSelection.modelId === model.id + return ( + + ) + })} +
+
+ ))} +
+ ) : ( +
+ {availableModels.map((model) => { + const isSelected = model.id === selectedModel?.id + return ( + + ) + })} +
+ )} +
+ + {!isControlled && !isRuntimeScoped && ( +
+
+ {t('model.effort')} +
+
+ {EFFORT_OPTIONS.map((opt) => { + const isSelected = opt.value === effortLevel + return ( + + ) + })} +
+
+ )} + + ) + const dropdown = open && dropdownPosition - ? createPortal( + ? isMobileBrowser ? ( + setOpen(false)} + title={t('model.configuration')} + closeLabel={t('tabs.close')} + ariaLabel={t('model.configuration')} + contentClassName="p-3" + panelRef={dropdownRef} + testId="model-selector-dropdown" + > + {dropdownContent} + + ) : createPortal(
-
-
- {t('model.configuration')} -
- - {isRuntimeScoped ? ( -
- {providerChoices.map((choice) => ( -
-
- - {choice.providerName} - - {choice.isDefault && ( - - {t('settings.providers.default')} - - )} -
- -
- {choice.models.map((model) => { - const isSelected = - activeRuntimeSelection?.providerId === choice.providerId && - activeRuntimeSelection.modelId === model.id - return ( - - ) - })} -
-
- ))} -
- ) : ( -
- {availableModels.map((model) => { - const isSelected = model.id === selectedModel?.id - return ( - - ) - })} -
- )} -
- - {!isControlled && !isRuntimeScoped && ( -
-
- {t('model.effort')} -
-
- {EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel - return ( - - ) - })} -
-
- )} + {dropdownContent}
, document.body, ) : null return ( -
+
))} +
+ ) + + const menuContent = ( + <> +
+ {t('permMode.executionPermissions')} +
+ {permissionOptions} ) @@ -187,19 +194,17 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, {open && ( - isMobile ? createPortal( -
setOpen(false)}> - -
, - document.body, + isMobile ? ( + setOpen(false)} + title={t('permMode.executionPermissions')} + closeLabel={t('tabs.close')} + ariaLabel={t('permMode.executionPermissions')} + contentClassName="py-2" + > + {permissionOptions} + ) : ( ) : null} - + {!isMobileShell ? : null} diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index d9786801..8961068f 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -204,7 +204,7 @@ describe('Sidebar', () => { expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') }) - it('closes the mobile drawer after navigation actions', async () => { + it('keeps mobile navigation focused on chat sessions', async () => { const onRequestClose = vi.fn() createSession.mockResolvedValue('session-mobile-new') useSessionStore.setState({ @@ -224,11 +224,11 @@ describe('Sidebar', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'Scheduled' })) - expect(onRequestClose).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Open Session/ })) - expect(onRequestClose).toHaveBeenCalledTimes(2) + expect(onRequestClose).toHaveBeenCalledTimes(1) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New Session' })) @@ -237,7 +237,7 @@ describe('Sidebar', () => { await waitFor(() => { expect(createSession).toHaveBeenCalled() }) - expect(onRequestClose).toHaveBeenCalledTimes(3) + expect(onRequestClose).toHaveBeenCalledTimes(2) }) it('shows a loading state instead of an empty session list while initial fetch is pending', () => { diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 728e43d3..ff8f87e1 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -218,19 +218,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { > {t('sidebar.newSession')} - { - useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled') - closeMobileDrawer() - }} - icon={} - > - {t('sidebar.scheduled')} - + {!isMobile && ( + { + useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled') + closeMobileDrawer() + }} + icon={} + > + {t('sidebar.scheduled')} + + )}
{expanded ? ( @@ -359,21 +361,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {