import React from 'react'; import { createPortal } from 'react-dom'; import { Icon } from '@/components/icon/Icon'; import { McpIcon } from '@/components/icons/McpIcon'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; import { TerminalView } from '@/components/views/TerminalView'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMcpConfigStore } from '@/stores/useMcpConfigStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { MobileChangesSurface } from './MobileChangesSurface'; import { MobileFilesSurface } from './MobileFilesSurface'; const DRAWER_ROOT_ID = 'mobile-surface-root'; const ENTER_DELAY_MS = 16; // Slightly long, decelerating slide — matches the sessions drawer so both // sides feel like the same piece of chrome. const ENTER_DURATION_MS = 320; const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; export type MobileWorkspaceTab = 'changes' | 'files' | 'terminal' | 'notes' | 'mcp'; /** Quick MCP enable/disable toggles as a workspace pane, with its own slim action row (add server → settings, refresh) replacing the old fullscreen surface's header actions. */ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenMcpSettings }) => { const { t } = useI18n(); const [isRefreshing, setIsRefreshing] = React.useState(false); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const refreshMcpStatus = useMcpStore((state) => state.refresh); const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); const refresh = () => { if (isRefreshing) return; setIsRefreshing(true); const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500)); void Promise.all([ refreshMcpStatus({ directory: currentDirectory || null, silent: true }), loadMcpConfigs({ force: true }), minSpinPromise, ]).finally(() => setIsRefreshing(false)); }; return (
); }; /** The workspace surfaces as tabs (Changes / Files / Terminal / Notes / MCP). Two hosts, same content and same state: - `drawer` (default) covers the app and slides in from the right edge — the phone, and a tablet in portrait where a side panel would leave no usable chat column; - `panel` renders inline so the caller can size it as a real sidebar beside the chat (tablet, landscape). The caller owns the width and the open/close animation there; this component only fills it. Closes via the header X, Escape (unless the terminal tab owns the keys), or the Android back button (handled by MobileShell). */ export const MobileWorkspaceDrawer: React.FC<{ open: boolean; onClose: () => void; tab: MobileWorkspaceTab; onTabChange: (tab: MobileWorkspaceTab) => void; /** When set, the Changes tab opens directly into the per-file diff. */ pendingChangesDiff: { path: string; staged: boolean } | null; /** Notes tab: opens a plan fullscreen (layered above the drawer). */ onOpenPlan: (plan: { path: string; title: string }) => void; /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ onOpenMcpSettings: () => void; variant?: 'drawer' | 'panel'; }> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => { const { t } = useI18n(); const rootRef = React.useRef(null); const [entered, setEntered] = React.useState(false); // Kept visible through the exit slide; flipped to hidden once it finishes. const [visible, setVisible] = React.useState(open); const onCloseRef = React.useRef(onClose); React.useEffect(() => { onCloseRef.current = onClose; }, [onClose]); const tabRef = React.useRef(tab); React.useEffect(() => { tabRef.current = tab; }, [tab]); // Tabs the user has actually opened — their panes stay mounted afterwards. const [visitedTabs, setVisitedTabs] = React.useState>(() => new Set()); React.useEffect(() => { if (!open) return; setVisitedTabs((current) => { if (current.has(tab)) return current; const next = new Set(current); next.add(tab); return next; }); }, [open, tab]); if (typeof document !== 'undefined' && !rootRef.current) { let root = document.getElementById(DRAWER_ROOT_ID); if (!root) { root = document.createElement('div'); root.id = DRAWER_ROOT_ID; document.body.appendChild(root); } rootRef.current = root; } React.useEffect(() => { if (open) { setVisible(true); const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS); return () => window.clearTimeout(id); } setEntered(false); const id = window.setTimeout(() => setVisible(false), ENTER_DURATION_MS + 40); return () => window.clearTimeout(id); }, [open]); React.useEffect(() => { if (!open) return; // Only the full-cover drawer owns the page scroll; the inline panel sits // inside the shell and must leave the chat beside it scrollable. const previousOverflow = document.body.style.overflow; if (variant === 'drawer') document.body.style.overflow = 'hidden'; const handleKeyDown = (event: KeyboardEvent) => { // The terminal owns Escape (it goes to the PTY) — don't hijack it. if (event.key === 'Escape' && tabRef.current !== 'terminal') onCloseRef.current(); }; document.addEventListener('keydown', handleKeyDown); return () => { if (variant === 'drawer') document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', handleKeyDown); }; }, [open, variant]); if (variant === 'drawer' && !rootRef.current) return null; const tabItems: SortableTabsStripItem[] = [ { id: 'changes', label: t('mobile.menu.changes'), icon: }, { id: 'files', label: t('mobile.menu.files'), icon: }, { id: 'terminal', label: t('mobile.menu.terminal'), icon: }, { id: 'notes', label: t('contextRail.surface.notes'), icon: }, { id: 'mcp', label: t('mobile.menu.mcp'), icon: }, ]; const body = ( <>
{/* Mounted only while shown; nonCompositedIndicator keeps the active pill off its own compositing layer — creating one inside the drawer's slide flickers in WKWebView. */} {visible ? ( onTabChange(id as MobileWorkspaceTab)} layoutMode="fit" variant="active-pill" nonCompositedIndicator // Five tabs don't fit with labels — the active tab keeps // icon + label, the rest collapse to icons. inactiveTabsIconOnly className="h-full" /> ) : null}
{/* Panes stay MOUNTED once visited (hidden when inactive/closed), so reopening the drawer lands exactly where the user left off — an open diff, an edited file, an attached terminal. */} {visitedTabs.has('changes') ? (
) : null} {visitedTabs.has('files') ? (
) : null} {visitedTabs.has('terminal') ? (
) : null} {visitedTabs.has('notes') ? (
) : null} {visitedTabs.has('mcp') ? (
) : null}
); if (variant === 'panel') { // The caller animates the width; the content itself is plain flow so it // never gets its own compositing layer (iOS clips those to the safe-area // viewport, which is exactly what the drawer's settled `transform: none` // avoids on the other host). return
{body}
; } return createPortal(
{body}
, rootRef.current as HTMLElement, ); };