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