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
+89
View File
@@ -0,0 +1,89 @@
import React from 'react';
export const IPAD_LEFT_SIDEBAR_WIDTH = 320;
export const IPAD_RIGHT_SIDEBAR_WIDTH = 380;
const IPAD_SIDEBAR_MIN_WIDTH = 280;
const IPAD_SIDEBAR_MAX_WIDTH = 560;
/** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop
Sidebar (imperative styles during the drag, committed to state at the end),
but with a finger-sized grab strip instead of a 3px hover handle. */
export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, defaultWidth: number) {
const asideRef = React.useRef<HTMLElement | null>(null);
const [width, setWidth] = React.useState(() => {
if (typeof window === 'undefined') return defaultWidth;
const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10);
if (!Number.isFinite(stored)) return defaultWidth;
return Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
});
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const liveWidthRef = React.useRef<number | null>(null);
const pointerIdRef = React.useRef<number | null>(null);
const clampWidth = React.useCallback((value: number) => (
Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
), []);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const aside = asideRef.current;
if (!aside) return;
aside.style.width = `${nextWidth}px`;
aside.style.minWidth = `${nextWidth}px`;
aside.style.maxWidth = `${nextWidth}px`;
aside.style.setProperty('--oc-ipad-sidebar-width', `${nextWidth}px`);
}, []);
const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
pointerIdRef.current = event.pointerId;
startXRef.current = event.clientX;
startWidthRef.current = width;
liveWidthRef.current = width;
setIsResizing(true);
event.preventDefault();
}, [width]);
const handlePointerMove = React.useCallback((event: React.PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
const delta = event.clientX - startXRef.current;
const next = clampWidth(startWidthRef.current + (side === 'left' ? delta : -delta));
if (liveWidthRef.current === next) return;
liveWidthRef.current = next;
applyLiveWidth(next);
}, [applyLiveWidth, clampWidth, side]);
const handlePointerEnd = React.useCallback((event: React.PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampWidth(liveWidthRef.current ?? startWidthRef.current);
pointerIdRef.current = null;
liveWidthRef.current = null;
setIsResizing(false);
setWidth(finalWidth);
try {
window.localStorage.setItem(storageKey, String(finalWidth));
} catch {
// ignore
}
}, [clampWidth, storageKey]);
const handleProps = React.useMemo(() => ({
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd,
}), [handlePointerDown, handlePointerEnd, handlePointerMove]);
return { asideRef, width, isResizing, handleProps };
}