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
@@ -0,0 +1,188 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon';
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context';
const RECENT_SESSIONS_LIMIT = 10;
const getSessionTitle = (session: Session, fallback: string): string =>
session.title?.trim() || fallback;
/** One switcher row: live status (busy spinner / attention dot), title,
"project · branch", compact time. Mirrors the desktop SessionSwitcherDropdown
indicator conventions; no subsession chevrons on mobile by design. */
const SwitcherRow: React.FC<{
session: Session;
meta: string;
active: boolean;
onSelect: () => void;
}> = ({ session, meta, active, onSelect }) => {
const { t } = useI18n();
const status = useGlobalSessionStatus(session.id);
const unseenCount = useSessionUnseenCount(session.id);
const statusType = status?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry';
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0);
return (
<button
type="button"
className={cn(
'flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
active && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
)}
onClick={onSelect}
style={{ touchAction: 'manipulation' }}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className={cn('block truncate typography-ui-label', active ? 'text-primary' : 'text-foreground')}>
{getSessionTitle(session, t('sessions.sidebar.session.untitled'))}
</span>
{meta ? (
<span className="block truncate typography-micro text-muted-foreground">{meta}</span>
) : null}
</span>
{/* Activity sits on the right, before the time — no reserved left gutter. */}
{isStreaming ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin text-primary" aria-hidden />
) : showUnreadDot ? (
<span className="size-1.5 shrink-0 rounded-full bg-[var(--status-info)]" aria-hidden />
) : null}
{timeLabel ? (
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{timeLabel}</span>
) : null}
</button>
);
};
/** Recent-sessions popover under the mobile header, opened by tapping the
session title. Same visual family as the metadata/usage overlay. */
export const MobileSessionSwitcher: React.FC<{
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
}> = ({ open, onClose, anchorRef }) => {
const { t } = useI18n();
const panelRef = React.useRef<HTMLDivElement>(null);
const [shouldRender, setShouldRender] = React.useState(open);
const [isExiting, setIsExiting] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const items = useSwitcherItems(open || shouldRender, { maxParents: RECENT_SESSIONS_LIMIT });
React.useEffect(() => {
if (open) {
// Fresh authoritative snapshot on open — updated stamps re-sort recents
// (see raiseSessionOrderingBaselines) while the cached list shows first.
void refreshGlobalSessions();
setShouldRender(true);
setIsExiting(false);
return;
}
if (!shouldRender) return;
setIsExiting(true);
const timeoutId = window.setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 140);
return () => window.clearTimeout(timeoutId);
}, [open, shouldRender]);
React.useEffect(() => {
if (!open) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose, open]);
React.useEffect(() => {
if (!open) return;
const closeIfOutside = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
onClose();
return;
}
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
onClose();
};
document.addEventListener('pointerdown', closeIfOutside, true);
return () => document.removeEventListener('pointerdown', closeIfOutside, true);
}, [anchorRef, onClose, open]);
const handleSelect = React.useCallback((session: Session) => {
void setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
onClose();
}, [onClose, setCurrentSession]);
if (!shouldRender) return null;
return (
<div className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
<div
ref={panelRef}
role="dialog"
aria-label={t('sessions.switcher.openAria')}
className={cn(
'mx-3 mt-2 flex flex-col overflow-hidden rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
)}
style={{
animation: `${isExiting ? 'session-switcher-out' : 'session-switcher-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
}}
>
<div className="oc-hide-scrollbar min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain">
{items.length === 0 ? (
<p className="px-3 py-6 text-center typography-small text-muted-foreground">
{t('sessions.switcher.empty')}
</p>
) : (
items.map((item) => {
const session = item.node.session;
const meta = [item.secondaryMeta?.projectLabel, item.secondaryMeta?.branchLabel]
.filter(Boolean)
.join(' · ');
return (
<SwitcherRow
key={session.id}
session={session}
meta={meta}
active={session.id === currentSessionId}
onSelect={() => {
if (item.projectId) setActiveProjectIdOnly(item.projectId);
handleSelect(session);
}}
/>
);
})
)}
</div>
</div>
<style>{`
@keyframes session-switcher-in {
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes session-switcher-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
}
`}</style>
</div>
);
};