2026-06-02 00:43:05 +03:00
|
|
|
import React from 'react';
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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';
|
2026-06-10 15:55:54 +03:00
|
|
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { ChatView } from '@/components/views/ChatView';
|
|
|
|
|
import { SettingsView } from '@/components/views/SettingsView';
|
|
|
|
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
2026-06-02 00:43:05 +03:00
|
|
|
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';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useRouter } from '@/hooks/useRouter';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
|
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
2026-06-10 12:00:10 +03:00
|
|
|
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
|
|
|
|
import { formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
|
|
|
|
|
import { getDisplayModelName } from '@/lib/quota/model-families';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
2026-06-02 00:43:05 +03:00
|
|
|
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';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
|
|
|
|
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
|
|
|
|
import { useMcpStore } from '@/stores/useMcpStore';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
2026-06-10 12:00:10 +03:00
|
|
|
import type { QuotaProviderId, UsageWindow } from '@/types';
|
2026-06-02 00:43:05 +03:00
|
|
|
import type { WorktreeMetadata } from '@/types/worktree';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
|
|
|
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
|
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-context';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
import { SyncAppEffects } from './AppEffects';
|
|
|
|
|
import { MobileChangesSurface } from './MobileChangesSurface';
|
|
|
|
|
import { MobileFilesSurface } from './MobileFilesSurface';
|
|
|
|
|
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
|
|
|
|
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
|
|
|
|
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
|
|
|
|
import { useAppFontEffects } from './useAppFontEffects';
|
|
|
|
|
|
|
|
|
|
const MOBILE_SETTINGS_PAGES = [
|
|
|
|
|
'appearance',
|
|
|
|
|
'chat',
|
|
|
|
|
'notifications',
|
|
|
|
|
'sessions',
|
|
|
|
|
'git',
|
|
|
|
|
'magic-prompts',
|
|
|
|
|
'behavior',
|
|
|
|
|
'mcp',
|
|
|
|
|
'providers',
|
|
|
|
|
'usage',
|
|
|
|
|
'voice',
|
2026-06-10 12:00:10 +03:00
|
|
|
'about',
|
2026-06-02 00:43:05 +03:00
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
type MobileAppProps = {
|
|
|
|
|
apis: RuntimeAPIs;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizePath = (value?: string | null): string =>
|
|
|
|
|
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const getProjectLabel = (path: string): string => {
|
|
|
|
|
const normalized = normalizePath(path);
|
|
|
|
|
if (!normalized) return '';
|
|
|
|
|
const segments = normalized.split('/').filter(Boolean);
|
|
|
|
|
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type OverflowItem = {
|
2026-06-10 12:00:10 +03:00
|
|
|
key: 'files' | 'changes' | 'mcp' | 'update' | 'settings';
|
|
|
|
|
icon?: IconName;
|
|
|
|
|
iconNode?: React.ReactNode;
|
2026-06-02 00:43:05 +03:00
|
|
|
label: string;
|
|
|
|
|
badge?: number;
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const MobileOverflowMenu: React.FC<{
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
items: OverflowItem[];
|
|
|
|
|
}> = ({ open, onClose, items }) => {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-06-02 19:00:14 +03:00
|
|
|
className="absolute inset-0 cursor-default"
|
2026-06-02 00:43:05 +03:00
|
|
|
aria-label={t('mobile.surface.closeAria')}
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
className="absolute right-2 top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
|
|
|
|
|
role="menu"
|
|
|
|
|
style={{ animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)' }}
|
|
|
|
|
>
|
|
|
|
|
{items.map((item, index) => (
|
|
|
|
|
<button
|
|
|
|
|
key={item.key}
|
|
|
|
|
type="button"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
|
|
|
|
index > 0 && 'border-t border-border/30',
|
|
|
|
|
)}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
item.onSelect();
|
|
|
|
|
onClose();
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-10 12:00:10 +03:00
|
|
|
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
|
2026-06-02 00:43:05 +03:00
|
|
|
<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 />
|
|
|
|
|
) : null}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const MobileHeader: React.FC<{
|
|
|
|
|
onOpenSessions: () => void;
|
|
|
|
|
onOpenMenu: () => void;
|
|
|
|
|
}> = ({ onOpenSessions, onOpenMenu }) => {
|
|
|
|
|
const { t } = useI18n();
|
2026-06-10 12:00:10 +03:00
|
|
|
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
2026-06-02 00:43:05 +03:00
|
|
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
|
|
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
2026-06-10 12:00:10 +03:00
|
|
|
const currentSessionDirectory = useSessionUIStore(
|
|
|
|
|
React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]),
|
|
|
|
|
);
|
|
|
|
|
const effectiveDirectory = currentSessionDirectory || currentDirectory;
|
|
|
|
|
const gitDirectory = normalizePath(effectiveDirectory) || null;
|
2026-06-02 00:43:05 +03:00
|
|
|
const projects = useProjectsStore((state) => state.projects);
|
2026-06-10 12:00:10 +03:00
|
|
|
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));
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
const projectLabel = React.useMemo(() => {
|
2026-06-10 12:00:10 +03:00
|
|
|
const directory = normalizePath(effectiveDirectory);
|
2026-06-02 00:43:05 +03:00
|
|
|
if (!directory) return t('mobile.header.noProject');
|
2026-06-10 12:00:10 +03:00
|
|
|
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]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
const sessionTitle = currentSession?.title?.trim();
|
2026-06-10 12:00:10 +03:00
|
|
|
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]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
return (
|
2026-06-10 12:00:10 +03:00
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<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={handleOpenMenu}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="more-2" className="size-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
</>
|
2026-06-02 00:43:05 +03:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MobileShell: React.FC = () => {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
|
|
|
|
|
const [filesOpen, setFilesOpen] = React.useState(false);
|
|
|
|
|
const [changesOpen, setChangesOpen] = React.useState(false);
|
2026-06-10 12:00:10 +03:00
|
|
|
const [mcpOpen, setMcpOpen] = React.useState(false);
|
|
|
|
|
const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false);
|
2026-06-02 00:43:05 +03:00
|
|
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
2026-06-10 12:00:10 +03:00
|
|
|
const [updateOpen, setUpdateOpen] = React.useState(false);
|
|
|
|
|
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
2026-06-02 00:43:05 +03:00
|
|
|
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);
|
2026-06-10 12:00:10 +03:00
|
|
|
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);
|
2026-06-02 00:43:05 +03:00
|
|
|
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
|
|
|
|
|
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
|
|
|
|
|
|
|
|
|
|
const mobileActions = React.useMemo<MobileAppActions>(
|
|
|
|
|
() => ({
|
|
|
|
|
openChanges: ({ diffPath, staged } = {}) => {
|
|
|
|
|
setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null);
|
|
|
|
|
setChangesOpen(true);
|
|
|
|
|
},
|
|
|
|
|
openFiles: () => setFilesOpen(true),
|
2026-06-10 12:00:10 +03:00
|
|
|
openSettings: () => {
|
|
|
|
|
setSettingsInitialMobileStage('nav');
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
},
|
2026-06-02 00:43:05 +03:00
|
|
|
}),
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const closeChanges = React.useCallback(() => {
|
|
|
|
|
setChangesOpen(false);
|
|
|
|
|
setPendingChangesDiff(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const overflowItems: OverflowItem[] = React.useMemo(
|
|
|
|
|
() => [
|
|
|
|
|
{
|
|
|
|
|
key: 'files',
|
2026-06-10 12:00:10 +03:00
|
|
|
icon: 'file-text',
|
2026-06-02 00:43:05 +03:00
|
|
|
label: t('mobile.menu.files'),
|
|
|
|
|
onSelect: () => setFilesOpen(true),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'changes',
|
2026-06-10 12:00:10 +03:00
|
|
|
icon: 'git-branch',
|
2026-06-02 00:43:05 +03:00
|
|
|
label: t('mobile.menu.changes'),
|
|
|
|
|
badge: dirtyChangeCount,
|
|
|
|
|
onSelect: () => setChangesOpen(true),
|
|
|
|
|
},
|
2026-06-10 12:00:10 +03:00
|
|
|
{
|
|
|
|
|
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),
|
|
|
|
|
}] : []),
|
2026-06-02 00:43:05 +03:00
|
|
|
{
|
|
|
|
|
key: 'settings',
|
2026-06-10 12:00:10 +03:00
|
|
|
icon: 'settings-3',
|
2026-06-02 00:43:05 +03:00
|
|
|
label: t('mobile.menu.settings'),
|
2026-06-10 12:00:10 +03:00
|
|
|
onSelect: () => {
|
|
|
|
|
setSettingsInitialMobileStage('nav');
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
},
|
2026-06-02 00:43:05 +03:00
|
|
|
},
|
|
|
|
|
],
|
2026-06-10 12:00:10 +03:00
|
|
|
[dirtyChangeCount, showUpdateItem, t],
|
2026-06-02 00:43:05 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DedicatedMobileAppProvider actions={mobileActions}>
|
|
|
|
|
<div
|
|
|
|
|
className="main-content-safe-area flex h-[100dvh] flex-col bg-background text-foreground"
|
|
|
|
|
data-page-scroll-lock="true"
|
|
|
|
|
>
|
|
|
|
|
<MobileHeader
|
|
|
|
|
onOpenSessions={() => setSessionsSheetOpen(true)}
|
|
|
|
|
onOpenMenu={() => setOverflowOpen(true)}
|
|
|
|
|
/>
|
|
|
|
|
<main className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<ChatView />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<MobileOverflowMenu
|
|
|
|
|
open={overflowOpen}
|
|
|
|
|
onClose={() => setOverflowOpen(false)}
|
|
|
|
|
items={overflowItems}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{sessionsSheetOpen ? (
|
|
|
|
|
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-02 19:00:14 +03:00
|
|
|
{/* Mounted only while open (like the sessions sheet) so each surface
|
|
|
|
|
computes its safe-area / fixed-position layout fresh on open. Keeping
|
|
|
|
|
them always-mounted left a stale startup layout, which made the
|
|
|
|
|
top-inset dimming appear only intermittently on iOS. */}
|
|
|
|
|
{filesOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setFilesOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.files')}
|
|
|
|
|
headerless
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
2026-06-02 19:00:14 +03:00
|
|
|
{changesOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={closeChanges}
|
|
|
|
|
ariaLabel={t('mobile.menu.changes')}
|
|
|
|
|
headerless
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<MobileChangesSurface
|
|
|
|
|
onClose={closeChanges}
|
|
|
|
|
initialDiffPath={pendingChangesDiff?.path ?? null}
|
|
|
|
|
initialDiffStaged={pendingChangesDiff?.staged === true}
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
{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}
|
|
|
|
|
|
2026-06-02 19:00:14 +03:00
|
|
|
{settingsOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.settings')}
|
|
|
|
|
headerless
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<SettingsView
|
|
|
|
|
forceMobile
|
|
|
|
|
isWindowed
|
2026-06-10 12:00:10 +03:00
|
|
|
initialMobileStage={settingsInitialMobileStage}
|
2026-06-02 19:00:14 +03:00
|
|
|
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-10 12:00:10 +03:00
|
|
|
|
|
|
|
|
{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}
|
2026-06-02 00:43:05 +03:00
|
|
|
</div>
|
|
|
|
|
</DedicatedMobileAppProvider>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function MobileApp({ apis }: MobileAppProps) {
|
|
|
|
|
const initializeApp = useConfigStore((state) => state.initializeApp);
|
|
|
|
|
const isInitialized = useConfigStore((state) => state.isInitialized);
|
|
|
|
|
const isConnected = useConfigStore((state) => state.isConnected);
|
|
|
|
|
const providersCount = useConfigStore((state) => state.providers.length);
|
|
|
|
|
const agentsCount = useConfigStore((state) => state.agents.length);
|
|
|
|
|
const loadProviders = useConfigStore((state) => state.loadProviders);
|
|
|
|
|
const loadAgents = useConfigStore((state) => state.loadAgents);
|
|
|
|
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
|
|
|
|
const error = useSessionUIStore((state) => state.error);
|
|
|
|
|
const clearError = useSessionUIStore((state) => state.clearError);
|
|
|
|
|
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
|
|
|
|
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
|
|
|
|
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
|
|
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
registerRuntimeAPIs(apis);
|
|
|
|
|
return () => registerRuntimeAPIs(null);
|
|
|
|
|
}, [apis]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
setIsMobile(true);
|
|
|
|
|
}, [setIsMobile]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
void initializeApp();
|
|
|
|
|
}, [initializeApp]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isConnected) return;
|
2026-06-05 15:09:24 +03:00
|
|
|
if (providersCount === 0) void loadProviders({ source: 'mobileApp:recovery' });
|
|
|
|
|
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:recovery' });
|
2026-06-02 00:43:05 +03:00
|
|
|
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isConnected) return;
|
|
|
|
|
opencodeClient.setDirectory(currentDirectory);
|
|
|
|
|
}, [currentDirectory, isConnected]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
|
|
|
|
}, [apis.github, refreshGitHubAuthStatus]);
|
|
|
|
|
|
|
|
|
|
// Discover all worktrees for every known project so the draft session's
|
|
|
|
|
// worktree/branch dropdown can list every available branch — not only the
|
|
|
|
|
// current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar.
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (projects.length === 0) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
const run = async () => {
|
|
|
|
|
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
|
|
|
|
const allWorktrees: WorktreeMetadata[] = [];
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
projects.map(async (project) => {
|
|
|
|
|
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
|
|
|
if (!projectPath) return;
|
|
|
|
|
try {
|
|
|
|
|
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
|
|
|
|
const isGitRepo =
|
|
|
|
|
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
|
|
|
|
|
if (!isGitRepo) return;
|
|
|
|
|
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
|
|
|
|
if (cancelled || worktrees.length === 0) return;
|
|
|
|
|
worktreesByProject.set(projectPath, worktrees);
|
|
|
|
|
allWorktrees.push(...worktrees);
|
|
|
|
|
} catch {
|
|
|
|
|
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
useSessionUIStore.setState({
|
|
|
|
|
availableWorktrees: allWorktrees,
|
|
|
|
|
availableWorktreesByProject: worktreesByProject,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void run();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [projects]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
const run = async () => {
|
|
|
|
|
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
|
|
|
|
|
if (!res || !res.ok || cancelled) return;
|
|
|
|
|
const data = (await res.json().catch(() => null)) as null | { planModeExperimentalEnabled?: unknown };
|
|
|
|
|
if (!data || cancelled) return;
|
|
|
|
|
const raw = data.planModeExperimentalEnabled;
|
|
|
|
|
setPlanModeEnabled(raw === true || raw === 1 || raw === '1' || raw === 'true');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void run();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [setPlanModeEnabled]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!error) return;
|
|
|
|
|
const timeout = window.setTimeout(() => clearError(), 5000);
|
|
|
|
|
return () => window.clearTimeout(timeout);
|
|
|
|
|
}, [clearError, error]);
|
|
|
|
|
|
|
|
|
|
useAppFontEffects();
|
|
|
|
|
usePushVisibilityBeacon({ enabled: true });
|
2026-06-10 12:00:10 +03:00
|
|
|
useUpdatePolling();
|
2026-06-02 00:43:05 +03:00
|
|
|
useWindowTitle();
|
|
|
|
|
useRouter();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
|
|
|
<RuntimeAPIProvider apis={apis}>
|
|
|
|
|
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
|
|
|
|
<div className="h-full bg-background text-foreground">
|
|
|
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
|
2026-06-10 12:00:10 +03:00
|
|
|
<OpenCodeUpdateToast />
|
2026-06-02 00:43:05 +03:00
|
|
|
<MobileShell />
|
|
|
|
|
<Toaster />
|
2026-06-10 15:55:54 +03:00
|
|
|
{isInitialized ? <ConfigUpdateOverlay /> : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
</div>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</RuntimeAPIProvider>
|
|
|
|
|
</SyncProvider>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
);
|
|
|
|
|
}
|