feat: improve mobile UX (#1591)

Added a mobile MCP overlay so MCP tools can be opened and managed from the mobile UI without relying on desktop-only dropdown behavior.
Improved mobile session panel touch handling so tapping the status/session area opens the right panel reliably on phones and tablets.
Cleaned up mobile usage provider metadata by removing duplicate rows, hiding unset providers, and showing provider logos consistently.
Added eager loading for provider logos used in mobile usage views to avoid delayed or missing icons when the panel opens.
Refined the mobile update and about flows in OpenChamber settings so release/update information is easier to read on small screens.
Adjusted related layout, header, VS Code layout, command palette, and settings text/localization details needed for the mobile polish.
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 12:00:10 +03:00
committed by GitHub
parent 0153f8787d
commit eff6f46ad9
56 changed files with 1145 additions and 353 deletions
+752 -69
View File
@@ -1,38 +1,52 @@
import React from 'react';
import {
RiFileTextLine,
RiGitBranchLine,
RiMenuLine,
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { McpIcon } from '@/components/icons/McpIcon';
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ChatView } from '@/components/views/ChatView';
import { SettingsView } from '@/components/views/SettingsView';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useRouter } from '@/hooks/useRouter';
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
import { getDisplayModelName } from '@/lib/quota/model-families';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { sessionEvents } from '@/lib/sessionEvents';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
import { useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import type { QuotaProviderId, UsageWindow } from '@/types';
import type { WorktreeMetadata } from '@/types/worktree';
import { useUIStore } from '@/stores/useUIStore';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { SyncProvider, useSession } from '@/sync/sync-context';
import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-context';
import { SyncAppEffects } from './AppEffects';
import { MobileChangesSurface } from './MobileChangesSurface';
@@ -54,6 +68,7 @@ const MOBILE_SETTINGS_PAGES = [
'providers',
'usage',
'voice',
'about',
] as const;
type MobileAppProps = {
@@ -63,6 +78,22 @@ type MobileAppProps = {
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
if (!limit || typeof limit !== 'object') return undefined;
const value = (limit as Partial<Record<'context' | 'output', unknown>>)[key];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
};
const getTokenCount = (value: unknown): number => (
typeof value === 'number' && Number.isFinite(value) ? value : 0
);
const formatTokens = (value: number): string => {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return String(value);
};
const getProjectLabel = (path: string): string => {
const normalized = normalizePath(path);
if (!normalized) return '';
@@ -71,13 +102,254 @@ const getProjectLabel = (path: string): string => {
};
type OverflowItem = {
key: 'files' | 'changes' | 'settings';
Icon: typeof RiFileTextLine;
key: 'files' | 'changes' | 'mcp' | 'update' | 'settings';
icon?: IconName;
iconNode?: React.ReactNode;
label: string;
badge?: number;
onSelect: () => void;
};
type ContextDisplay = {
percentage: number;
tokens: string;
colorClass: string;
} | null;
const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => {
if (project) return project.label?.trim() || getProjectLabel(project.path);
return getProjectLabel(fallbackDirectory);
};
type MobileUsageLimitRow = {
key: string;
label: string;
subtitle?: string;
window: UsageWindow;
};
type MobileUsageProviderGroup = {
providerId: QuotaProviderId;
providerName: string;
rows: MobileUsageLimitRow[];
status: string | null;
};
const getWindowValueClass = (window: UsageWindow): string => {
const usedPercent = window.usedPercent;
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground';
if (usedPercent >= 80) return 'text-[var(--status-error)]';
if (usedPercent >= 50) return 'text-[var(--status-warning)]';
return 'text-foreground';
};
const MetadataRow: React.FC<{
icon: IconName;
label: string;
children: React.ReactNode;
}> = ({ icon, label, children }) => (
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Icon name={icon} className="size-[18px]" />
</span>
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
{children}
</span>
</div>
);
const SessionMetadataOverlay: React.FC<{
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
contextDisplay: ContextDisplay;
branchLabel: string;
usageGroups: MobileUsageProviderGroup[];
usageDisplayMode: 'usage' | 'remaining';
isUsageLoading: boolean;
timeFormatPreference: TimeFormatPreference;
}> = ({ open, onClose, anchorRef, contextDisplay, branchLabel, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => {
const { t } = useI18n();
const panelRef = React.useRef<HTMLDivElement>(null);
const [shouldRender, setShouldRender] = React.useState(open);
const [isExiting, setIsExiting] = React.useState(false);
React.useEffect(() => {
if (open) {
setShouldRender(true);
setIsExiting(false);
return;
}
if (!shouldRender) return;
setIsExiting(true);
const timeoutId = window.setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 140);
return () => window.clearTimeout(timeoutId);
}, [open, shouldRender]);
React.useEffect(() => {
if (!open) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose, open]);
React.useEffect(() => {
if (!open) return;
const closeIfOutside = (event: PointerEvent | WheelEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
onClose();
return;
}
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
onClose();
};
document.addEventListener('pointerdown', closeIfOutside, true);
document.addEventListener('wheel', closeIfOutside, true);
return () => {
document.removeEventListener('pointerdown', closeIfOutside, true);
document.removeEventListener('wheel', closeIfOutside, true);
};
}, [anchorRef, onClose, open]);
if (!shouldRender) return null;
return (
<div className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
<div
ref={panelRef}
role="dialog"
aria-label={t('mobile.header.openMetadataAria')}
className={cn(
'mx-3 mt-2 overflow-y-auto overscroll-contain rounded-[20px] border border-border/40 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
)}
style={{
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
}}
>
<div className="space-y-1">
<MetadataRow icon="git-branch" label={t('mobile.header.metadata.branch')}>
{branchLabel}
</MetadataRow>
{contextDisplay ? (
<MetadataRow icon="pie-chart" label={t('mobile.header.metadata.context')}>
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
</span>
</MetadataRow>
) : null}
<MobileUsageLimits
groups={usageGroups}
displayMode={usageDisplayMode}
isLoading={isUsageLoading}
timeFormatPreference={timeFormatPreference}
/>
</div>
</div>
<style>{`
@keyframes session-metadata-in {
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes session-metadata-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
}
`}</style>
</div>
);
};
const MobileUsageLimits: React.FC<{
groups: MobileUsageProviderGroup[];
displayMode: 'usage' | 'remaining';
isLoading: boolean;
timeFormatPreference: TimeFormatPreference;
}> = ({ groups, displayMode, isLoading, timeFormatPreference }) => {
const { t } = useI18n();
const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used');
if (groups.length === 0) return null;
return (
<div className="pt-2.5">
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="timer" className="size-[18px]" />
</span>
<span className="shrink-0 typography-ui-label text-muted-foreground">
{t('mobile.header.metadata.usage')}
</span>
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
<span className="truncate">{modeLabel}</span>
</span>
</div>
<div className="space-y-1.5">
{groups.map((group) => (
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
{group.providerName}
</span>
{group.status && group.rows.length === 0 ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground">
{group.status}
</span>
) : null}
</div>
{group.rows.length > 0 ? (
<div className="mt-1.5 space-y-1">
{group.rows.map((row) => {
const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent;
const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent);
const resetLabel = formatQuotaResetLabel(
row.window.resetAt,
row.window.resetAfterFormatted ?? row.window.resetAtFormatted,
timeFormatPreference,
);
return (
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate typography-ui-label text-muted-foreground">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
) : null}
</span>
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
);
})}
</div>
) : null}
{group.status && group.rows.length > 0 ? (
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
) : null}
</div>
))}
</div>
</div>
);
};
const MobileOverflowMenu: React.FC<{
open: boolean;
onClose: () => void;
@@ -123,7 +395,7 @@ const MobileOverflowMenu: React.FC<{
onClose();
}}
>
<item.Icon className="size-5 shrink-0 text-muted-foreground" />
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
{item.badge && item.badge > 0 ? (
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
@@ -136,72 +408,331 @@ const MobileOverflowMenu: React.FC<{
);
};
const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({
open,
onOpenChange,
currentSessionId,
effectiveDirectory,
gitDirectory,
isNewSessionDraftOpen,
primaryLabel,
secondaryLabel,
}: {
open: boolean;
onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void;
currentSessionId: string | null;
effectiveDirectory: string | null;
gitDirectory: string | null;
isNewSessionDraftOpen: boolean;
primaryLabel: string;
secondaryLabel: string;
}) {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const metadataTriggerRef = React.useRef<HTMLButtonElement>(null);
const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined);
const isGitRepo = useIsGitRepo(gitDirectory);
const gitStatus = useGitStatus(gitDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
useConfigStore((state) => state.modelsMetadata.size);
const savedSessionModel = useSelectionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null),
[currentSessionId],
),
);
const quotaResults = useQuotaStore((state) => state.results);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const selectedQuotaModels = useQuotaStore((state) => state.selectedModels);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
useQuotaAutoRefresh();
React.useEffect(() => {
if (!gitDirectory) return;
void ensureStatus(gitDirectory, git);
}, [ensureStatus, git, gitDirectory]);
React.useEffect(() => {
if (!gitDirectory) return;
return sessionEvents.onGitRefreshHint((hint) => {
if (normalizePath(hint.directory) !== gitDirectory) return;
void fetchStatus(gitDirectory, git);
});
}, [fetchStatus, git, gitDirectory]);
React.useEffect(() => {
void loadQuotaSettings();
}, [loadQuotaSettings]);
React.useEffect(() => {
preloadProviderLogos(dropdownProviderIds);
}, [dropdownProviderIds]);
React.useEffect(() => {
if (!open || isQuotaLoading) return;
const missingEnabledProvider = dropdownProviderIds.some((providerId) => (
!quotaResults.some((result) => result.providerId === providerId)
));
if (!missingEnabledProvider) return;
void fetchAllQuotas();
}, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]);
const latestMessageModel = React.useMemo(() => {
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
model?: { providerID?: string; modelID?: string };
};
if (message.role !== 'user') continue;
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
? message.model.providerID
: undefined;
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
? message.model.modelID
: undefined;
if (providerID && modelID) return { providerID, modelID };
}
return null;
}, [activeSessionMessages]);
const modelRef = latestMessageModel
?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null)
?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null);
const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined;
const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID);
const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined;
const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context')
?? metadata?.limit?.context
?? 0;
const totalTokens = React.useMemo(() => {
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
tokens?: {
input?: unknown;
output?: unknown;
reasoning?: unknown;
cache?: { read?: unknown; write?: unknown };
};
};
if (message.role !== 'assistant' || !message.tokens) continue;
const total = getTokenCount(message.tokens.input)
+ getTokenCount(message.tokens.output)
+ getTokenCount(message.tokens.reasoning)
+ getTokenCount(message.tokens.cache?.read)
+ getTokenCount(message.tokens.cache?.write);
if (total > 0) return total;
}
return 0;
}, [activeSessionMessages]);
const contextPercentage =
!isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0
? Math.min((totalTokens / contextLimit) * 100, 999)
: null;
const contextTokens = contextPercentage !== null
? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}`
: null;
const contextColorClass =
contextPercentage === null
? ''
: contextPercentage >= 90
? 'text-[var(--status-error)]'
: contextPercentage >= 75
? 'text-[var(--status-warning)]'
: 'text-[var(--status-success)]';
const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens
? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass }
: null;
const branchLabel = isGitRepo === true
? (gitStatus?.current?.trim() || t('gitView.branch.detachedHead'))
: t('common.unavailable');
const usageGroups = React.useMemo<MobileUsageProviderGroup[]>(() => {
const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result]));
return QUOTA_PROVIDERS
.filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id))
.filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true)
.map((providerMeta) => {
const result = resultsByProvider.get(providerMeta.id)!;
const rows: MobileUsageLimitRow[] = [];
for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) {
rows.push({
key: `window-${label}`,
label: formatWindowLabel(label),
window,
});
}
const modelEntries = Object.entries(result?.usage?.models ?? {});
const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? [];
const visibleModelEntries = providerSelectedModels.length > 0
? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName))
: modelEntries;
for (const [modelName, modelUsage] of visibleModelEntries) {
const entries = Object.entries(modelUsage.windows ?? {});
if (entries.length === 0) continue;
const [label, window] = entries[0];
rows.push({
key: `model-${modelName}-${label}`,
label: formatWindowLabel(label),
subtitle: getDisplayModelName(modelName),
window,
});
}
const status = !result.ok && result.error
? result.error
: rows.length === 0
? t('header.services.noRateLimitsReported')
: null;
return {
providerId: providerMeta.id,
providerName: providerMeta.name,
rows,
status,
};
});
}, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]);
React.useEffect(() => {
if (!open || usageGroups.length === 0) return;
preloadProviderLogos(usageGroups.map((group) => group.providerId));
}, [open, usageGroups]);
return (
<>
<button
ref={metadataTriggerRef}
type="button"
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.header.openMetadataAria')}
aria-expanded={open}
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
style={{ touchAction: 'manipulation' }}
>
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
{secondaryLabel ? (
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
) : null}
</span>
</button>
<SessionMetadataOverlay
open={open}
onClose={() => onOpenChange(false)}
anchorRef={metadataTriggerRef}
contextDisplay={contextDisplay}
branchLabel={branchLabel}
usageGroups={usageGroups}
usageDisplayMode={quotaDisplayMode}
isUsageLoading={isQuotaLoading}
timeFormatPreference={timeFormatPreference}
/>
</>
);
});
const MobileHeader: React.FC<{
onOpenSessions: () => void;
onOpenMenu: () => void;
}> = ({ onOpenSessions, onOpenMenu }) => {
const { t } = useI18n();
const [metadataOpen, setMetadataOpen] = React.useState(false);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]),
);
const effectiveDirectory = currentSessionDirectory || currentDirectory;
const gitDirectory = normalizePath(effectiveDirectory) || null;
const projects = useProjectsStore((state) => state.projects);
const currentSession = useSession(currentSessionId, currentDirectory || undefined);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentWorktreeMetadata = useSessionUIStore(
React.useCallback((state) => (currentSessionId ? state.worktreeMetadata.get(currentSessionId) ?? null : null), [currentSessionId]),
);
const currentSession = useSession(currentSessionId, effectiveDirectory || undefined);
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const projectLabel = React.useMemo(() => {
const directory = normalizePath(currentDirectory);
const directory = normalizePath(effectiveDirectory);
if (!directory) return t('mobile.header.noProject');
const project = projects.find((entry) => {
const projectPath = normalizePath(entry.path);
return directory === projectPath || directory.startsWith(`${projectPath}/`);
});
return project?.label?.trim() || getProjectLabel(project?.path || directory);
}, [currentDirectory, projects, t]);
const metadataProject = currentWorktreeMetadata?.projectDirectory
? resolveProjectForDirectory(projects, currentWorktreeMetadata.projectDirectory)
: null;
const project = metadataProject ?? resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return getProjectDisplayLabel(project, directory) || t('mobile.header.noProject');
}, [availableWorktreesByProject, currentWorktreeMetadata?.projectDirectory, effectiveDirectory, projects, t]);
const sessionTitle = currentSession?.title?.trim();
const primaryLabel = sessionTitle || projectLabel;
const secondaryLabel = sessionTitle ? projectLabel : currentSessionId ? t('mobile.sessions.untitled') : '';
const primaryLabel = sessionTitle || (currentSessionId ? t('mobile.sessions.untitled') : projectLabel);
const secondaryLabel = currentSessionId ? projectLabel : '';
React.useEffect(() => {
setMetadataOpen(false);
}, [currentSessionId, effectiveDirectory]);
const handleOpenSessions = React.useCallback(() => {
setMetadataOpen(false);
onOpenSessions();
}, [onOpenSessions]);
const handleOpenMenu = React.useCallback(() => {
setMetadataOpen(false);
onOpenMenu();
}, [onOpenMenu]);
return (
<header
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.openSheetAria')}
onClick={onOpenSessions}
style={{ touchAction: 'manipulation' }}
>
<RiMenuLine className="size-5" />
</button>
<>
<header
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.openSheetAria')}
onClick={handleOpenSessions}
style={{ touchAction: 'manipulation' }}
>
<Icon name="menu" className="size-5" />
</button>
<button
type="button"
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.openSheetAria')}
onClick={onOpenSessions}
style={{ touchAction: 'manipulation' }}
>
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
{secondaryLabel ? (
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
) : null}
</span>
</button>
<MobileSessionMetadataButton
open={metadataOpen}
onOpenChange={setMetadataOpen}
currentSessionId={currentSessionId}
effectiveDirectory={effectiveDirectory}
gitDirectory={gitDirectory}
isNewSessionDraftOpen={isNewSessionDraftOpen}
primaryLabel={primaryLabel}
secondaryLabel={secondaryLabel}
/>
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.header.openMenuAria')}
onClick={onOpenMenu}
style={{ touchAction: 'manipulation' }}
>
<RiMore2Line className="size-5" />
</button>
</div>
</header>
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.header.openMenuAria')}
onClick={handleOpenMenu}
style={{ touchAction: 'manipulation' }}
>
<Icon name="more-2" className="size-5" />
</button>
</div>
</header>
</>
);
};
@@ -210,11 +741,23 @@ const MobileShell: React.FC = () => {
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
const [filesOpen, setFilesOpen] = React.useState(false);
const [changesOpen, setChangesOpen] = React.useState(false);
const [mcpOpen, setMcpOpen] = React.useState(false);
const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [updateOpen, setUpdateOpen] = React.useState(false);
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
const [overflowOpen, setOverflowOpen] = React.useState(false);
// When set, the Changes surface opens directly into the per-file diff for this path.
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const updateAvailable = useUpdateStore((state) => state.available);
const updateRuntimeType = useUpdateStore((state) => state.runtimeType);
const mcpServers = useMcpConfigStore((state) => state.mcpServers);
const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft);
const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp);
const refreshMcpStatus = useMcpStore((state) => state.refresh);
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
@@ -225,7 +768,10 @@ const MobileShell: React.FC = () => {
setChangesOpen(true);
},
openFiles: () => setFilesOpen(true),
openSettings: () => setSettingsOpen(true),
openSettings: () => {
setSettingsInitialMobileStage('nav');
setSettingsOpen(true);
},
}),
[],
);
@@ -235,29 +781,92 @@ const MobileShell: React.FC = () => {
setPendingChangesDiff(null);
}, []);
const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web');
const openMcpCreateSettings = React.useCallback(() => {
const baseName = 'new-mcp-server';
let newName = baseName;
let counter = 1;
while (mcpServers.some((server) => server.name === newName)) {
newName = `${baseName}-${counter}`;
counter += 1;
}
const draft: McpDraft = {
name: newName,
scope: 'user',
type: 'local',
command: [],
url: '',
environment: [],
headers: [],
oauthEnabled: true,
oauthClientId: '',
oauthClientSecret: '',
oauthScope: '',
oauthRedirectUri: '',
timeout: '',
enabled: true,
};
setMcpDraft(draft);
setSelectedMcp(newName);
setSettingsPage('mcp');
setMcpOpen(false);
setSettingsInitialMobileStage('page-content');
setSettingsOpen(true);
}, [mcpServers, setMcpDraft, setSelectedMcp, setSettingsPage]);
const refreshMcpOverlay = React.useCallback(() => {
if (isMcpRefreshing) return;
setIsMcpRefreshing(true);
const directory = currentDirectory || null;
const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500));
void Promise.all([
refreshMcpStatus({ directory, silent: true }),
loadMcpConfigs({ force: true }),
minSpinPromise,
]).finally(() => setIsMcpRefreshing(false));
}, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]);
const overflowItems: OverflowItem[] = React.useMemo(
() => [
{
key: 'files',
Icon: RiFileTextLine,
icon: 'file-text',
label: t('mobile.menu.files'),
onSelect: () => setFilesOpen(true),
},
{
key: 'changes',
Icon: RiGitBranchLine,
icon: 'git-branch',
label: t('mobile.menu.changes'),
badge: dirtyChangeCount,
onSelect: () => setChangesOpen(true),
},
{
key: 'mcp',
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
label: t('mobile.menu.mcp'),
onSelect: () => setMcpOpen(true),
},
...(showUpdateItem ? [{
key: 'update' as const,
icon: 'download' as const,
label: t('mobile.menu.update'),
onSelect: () => setUpdateOpen(true),
}] : []),
{
key: 'settings',
Icon: RiSettings3Line,
icon: 'settings-3',
label: t('mobile.menu.settings'),
onSelect: () => setSettingsOpen(true),
onSelect: () => {
setSettingsInitialMobileStage('nav');
setSettingsOpen(true);
},
},
],
[dirtyChangeCount, t],
[dirtyChangeCount, showUpdateItem, t],
);
return (
@@ -320,6 +929,62 @@ const MobileShell: React.FC = () => {
</MobileSurfaceShell>
) : null}
{mcpOpen ? (
<MobileOverlayPanel
open
onClose={() => setMcpOpen(false)}
title={t('mcpDropdown.title')}
className="h-[72vh]"
contentMaxHeightClassName="max-h-full"
renderHeader={(closeButton) => (
<div className="shrink-0">
<div className="flex justify-center pt-2.5 pb-1">
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
</div>
<div className="flex items-center justify-between gap-2 px-4 pb-2">
<h2 className="text-[16px] font-semibold text-[var(--surface-foreground)]">
{t('mcpDropdown.title')}
</h2>
<div className="flex items-center gap-3">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
onClick={openMcpCreateSettings}
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
title={t('settings.mcp.sidebar.actions.addServerTitle')}
style={{ touchAction: 'manipulation' }}
>
<Icon name="add" className="h-5 w-5" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] disabled:opacity-60"
onClick={refreshMcpOverlay}
disabled={isMcpRefreshing}
aria-label={t('mcpDropdown.actions.refreshAria')}
title={t('mcpDropdown.actions.refreshAria')}
style={{ touchAction: 'manipulation' }}
>
<Icon name="refresh" className={cn('h-5 w-5', isMcpRefreshing && 'animate-spin')} />
</button>
{closeButton}
</div>
</div>
</div>
)}
>
<ErrorBoundary>
<McpDropdownContent
active
className="h-full"
listClassName="max-h-none"
hideHeader
mobileListDensity
/>
</ErrorBoundary>
</MobileOverlayPanel>
) : null}
{settingsOpen ? (
<MobileSurfaceShell
open
@@ -331,12 +996,28 @@ const MobileShell: React.FC = () => {
<SettingsView
forceMobile
isWindowed
initialMobileStage={settingsInitialMobileStage}
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
onClose={() => setSettingsOpen(false)}
/>
</ErrorBoundary>
</MobileSurfaceShell>
) : null}
{updateOpen ? (
<MobileSurfaceShell
open
onClose={() => setUpdateOpen(false)}
ariaLabel={t('mobile.menu.update')}
title={t('mobile.menu.update')}
>
<ErrorBoundary>
<div className="h-full overflow-auto px-5 py-4">
<AboutSettings initialUpdateDialogOpen />
</div>
</ErrorBoundary>
</MobileSurfaceShell>
) : null}
</div>
</DedicatedMobileAppProvider>
);
@@ -457,6 +1138,7 @@ export function MobileApp({ apis }: MobileAppProps) {
useAppFontEffects();
usePushVisibilityBeacon({ enabled: true });
useUpdatePolling();
useWindowTitle();
useRouter();
@@ -467,6 +1149,7 @@ export function MobileApp({ apis }: MobileAppProps) {
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full bg-background text-foreground">
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
<OpenCodeUpdateToast />
<MobileShell />
<Toaster />
</div>