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,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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user