feat(mobile): mobile app navigation rework and beta-feedback closeout (#2561)
Navigation model rebuilt around two full-width drawers and a minimal header (sessions / title-switcher / usage ring / workspace): - Left sessions drawer: cross-project tree with live status indicators, swipe actions on sessions (rename/archive/delete) and on group headers (project edit / two-step close, worktree delete), reorder-only edit mode with collapsible project cards and draggable worktrees, app-level footer (connected instance, settings, pending web update). - Right workspace drawer: Changes / Files / Terminal / Notes / MCP as pill tabs (inactive tabs icon-only); panes stay mounted once visited. The full desktop file editor serves the Files tab; read/skill tool taps in chat open the file there at the requested line. - Header session switcher on title tap: 10 cross-project recents with live busy/attention indicators and project · branch metadata; the usage ring opens a metadata overlay with an explicit loading state. - The overflow menu is gone on phones (its destinations moved into the drawers); iPad keeps it until its dedicated layout pass. Correctness and continuity: - /auth/session answers bearer-first, so a stale WebView cookie can no longer mask a revoked device token; cold launches classify failures fast and land on an explicit connect screen. - Authoritative session snapshots raise frozen ordering baselines and stale live ranks — recents stay truthful after the app slept. - Cold launches reopen the last active session per instance (persisted pointer, confirmed against a sessions snapshot; a user-opened draft clears it), with a logo hold instead of a draft flash. Also: collapsed pill composer gains the stop control; chat tool rows share one 36px rhythm; Task subtool rows truncate; larger bottom safe area so the composer clears big-screen corner radii; Capacitor build hides About/Update (store updates apply there); widgets link to the sessions drawer with a list icon; MobileApp split into focused modules; five mobile-surface detectors unified; translucent borders normalized to 70%; all new strings translated across the 10 locales. iPad and foldable layouts are intentionally untouched - separate next version PR.
This commit is contained in:
committed by
GitHub
parent
ea8cc5d7b0
commit
86ef96302d
@@ -0,0 +1,274 @@
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end gap-1 px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 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"
|
||||
onClick={onOpenMcpSettings}
|
||||
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
title={t('mcpDropdown.actions.refreshAria')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="refresh" className={cn('size-5', isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<McpDropdownContent
|
||||
active
|
||||
className="h-full"
|
||||
listClassName="max-h-none"
|
||||
hideHeader
|
||||
mobileListDensity
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Full-width right drawer with the phone workspace surfaces as tabs
|
||||
(Changes / Files / Terminal / Notes / MCP). Slides in from the right edge;
|
||||
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;
|
||||
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings }) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(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<ReadonlySet<MobileWorkspaceTab>>(() => 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;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
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 () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!rootRef.current) return null;
|
||||
|
||||
const tabItems: SortableTabsStripItem[] = [
|
||||
{ id: 'changes', label: t('mobile.menu.changes'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'files', label: t('mobile.menu.files'), icon: <Icon name="file-text" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'terminal', label: t('mobile.menu.terminal'), icon: <Icon name="terminal" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'notes', label: t('contextRail.surface.notes'), icon: <Icon name="sticky-note" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: t('mobile.menu.mcp'), icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
aria-hidden={!open}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center">
|
||||
{/* 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 ? (
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={tab}
|
||||
onSelect={(id) => 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}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 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.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="close" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{/* 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') ? (
|
||||
<div
|
||||
// A newly requested per-file diff remounts the pane so
|
||||
// initialDiffPath applies; plain reopens keep the state.
|
||||
key={pendingChangesDiff ? `changes:${pendingChangesDiff.path}:${pendingChangesDiff.staged}` : 'changes'}
|
||||
className={cn('h-full', tab !== 'changes' && 'hidden')}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileChangesSurface
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('files') ? (
|
||||
<div className={cn('h-full', tab !== 'files' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<MobileFilesSurface />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('terminal') ? (
|
||||
<div className={cn('h-full', tab !== 'terminal' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<TerminalView visible={open && tab === 'terminal'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('notes') ? (
|
||||
<div className={cn('h-full', tab !== 'notes' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<ProjectContextPanel onActionComplete={onClose} onOpenPlan={onOpenPlan} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('mcp') ? (
|
||||
<div className={cn('h-full', tab !== 'mcp' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<McpWorkspacePane onOpenMcpSettings={onOpenMcpSettings} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user