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:
Bohdan Triapitsyn
2026-08-01 21:16:36 +03:00
committed by GitHub
parent ea8cc5d7b0
commit 86ef96302d
69 changed files with 5006 additions and 4291 deletions
+207
View File
@@ -0,0 +1,207 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSession } from '@/sync/sync-context';
import { MobileSessionMetadataButton } from './MobileSessionMetadata';
import { MobileSessionSwitcher } from './MobileSessionSwitcher';
export type MobileHeaderSurfaceShortcuts = {
activePanel: 'files' | 'changes' | null;
changesDirty: boolean;
onToggleFiles: () => void;
onToggleChanges: () => void;
};
export const MobileHeader: React.FC<{
onOpenSessions: () => void;
/** iPad only for now: the legacy overflow menu. Phones distribute its items
across the sessions drawer footer and the workspace drawer tabs. */
onOpenMenu?: () => void;
/** Phone only: opens the right workspace drawer (Changes / Files / Terminal). */
onOpenWorkspace?: () => void;
/** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */
surfaceShortcuts?: MobileHeaderSurfaceShortcuts;
}> = ({ onOpenSessions, onOpenMenu, onOpenWorkspace, surfaceShortcuts }) => {
const { t } = useI18n();
const [metadataOpen, setMetadataOpen] = React.useState(false);
const [switcherOpen, setSwitcherOpen] = React.useState(false);
const titleRef = React.useRef<HTMLButtonElement>(null);
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 currentSession = useSession(currentSessionId, effectiveDirectory || undefined);
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const sessionTitle = currentSession?.title?.trim();
// Single-line title, desktop-style: session title, or the "New session"
// placeholder on the draft screen. No project/branch metadata line.
const primaryLabel = sessionTitle
|| (currentSessionId ? t('mobile.sessions.untitled') : t('sessions.switcher.draftTitle'));
React.useEffect(() => {
setMetadataOpen(false);
setSwitcherOpen(false);
}, [currentSessionId, effectiveDirectory]);
const handleOpenSessions = React.useCallback(() => {
setMetadataOpen(false);
setSwitcherOpen(false);
onOpenSessions();
}, [onOpenSessions]);
const handleOpenMenu = React.useCallback(() => {
setMetadataOpen(false);
setSwitcherOpen(false);
onOpenMenu?.();
}, [onOpenMenu]);
// The two header popovers are mutually exclusive.
const handleMetadataOpenChange = React.useCallback((value: boolean | ((open: boolean) => boolean)) => {
setMetadataOpen((current) => {
const next = typeof value === 'function' ? value(current) : value;
if (next) setSwitcherOpen(false);
return next;
});
}, []);
const toggleSwitcher = React.useCallback(() => {
setSwitcherOpen((current) => {
const next = !current;
if (next) setMetadataOpen(false);
return next;
});
}, []);
return (
<>
<header
className="oc-mobile-header relative z-30 flex shrink-0 items-center gap-1 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="list-unordered" className="size-5" />
</button>
{/* Session title doubles as the recent-sessions switcher trigger. */}
<button
ref={titleRef}
type="button"
className="flex min-w-0 flex-1 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('sessions.switcher.openAria')}
aria-haspopup="dialog"
aria-expanded={switcherOpen}
onClick={toggleSwitcher}
style={{ touchAction: 'manipulation' }}
>
<span className="flex min-w-0 items-center gap-1">
<span className="block min-w-0 truncate typography-ui-label text-foreground">{primaryLabel}</span>
{/* Discoverability: the chevron marks the title as a disclosure
trigger and flips while the switcher is open. */}
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150',
switcherOpen && 'rotate-180',
)}
/>
</span>
</button>
<MobileSessionMetadataButton
open={metadataOpen}
onOpenChange={handleMetadataOpenChange}
currentSessionId={currentSessionId}
effectiveDirectory={effectiveDirectory}
isNewSessionDraftOpen={isNewSessionDraftOpen}
/>
{surfaceShortcuts ? (
<>
<button
type="button"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
surfaceShortcuts.activePanel === 'files'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
)}
aria-label={t('mobile.menu.files')}
aria-pressed={surfaceShortcuts.activePanel === 'files'}
onClick={surfaceShortcuts.onToggleFiles}
style={{ touchAction: 'manipulation' }}
>
<Icon name="file-text" className="size-5" />
</button>
<button
type="button"
className={cn(
'relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
surfaceShortcuts.activePanel === 'changes'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
)}
aria-label={t('mobile.menu.changes')}
aria-pressed={surfaceShortcuts.activePanel === 'changes'}
onClick={surfaceShortcuts.onToggleChanges}
style={{ touchAction: 'manipulation' }}
>
<Icon name="git-branch" className="size-5" />
{surfaceShortcuts.changesDirty ? (
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
) : null}
</button>
</>
) : null}
{onOpenMenu ? (
<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>
) : null}
{onOpenWorkspace ? (
<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.openWorkspaceAria')}
onClick={() => {
setMetadataOpen(false);
setSwitcherOpen(false);
onOpenWorkspace();
}}
style={{ touchAction: 'manipulation' }}
>
<Icon name="pencil-ruler-2" className="size-5" />
</button>
) : null}
</div>
</header>
<MobileSessionSwitcher
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
anchorRef={titleRef}
/>
</>
);
};