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,575 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
import { useI18n } from '@/lib/i18n';
import { isIPadApp } from '@/lib/platform';
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { getDisplayModelName } from '@/lib/quota/model-families';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import type { QuotaProviderId, UsageWindow } from '@/types';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionMessages } from '@/sync/sync-context';
const IPAD_METADATA_POPOVER_WIDTH = 380;
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
if (!limit || typeof limit !== 'object') return undefined;
const value = (limit as Partial<Record<'context' | 'output', unknown>>)[key];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
};
const getTokenCount = (value: unknown): number => (
typeof value === 'number' && Number.isFinite(value) ? value : 0
);
const formatTokens = (value: number): string => {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return String(value);
};
type MobileUsageLimitRow = {
key: string;
label: string;
subtitle?: string;
window: UsageWindow;
};
type MobileUsageProviderGroup = {
providerId: QuotaProviderId;
providerName: string;
rows: MobileUsageLimitRow[];
status: string | null;
};
type ContextDisplay = {
percentage: number;
tokens: string;
colorClass: string;
} | null;
const getWindowValueClass = (window: UsageWindow): string => {
const usedPercent = window.usedPercent;
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground';
if (usedPercent >= 80) return 'text-[var(--status-error)]';
if (usedPercent >= 50) return 'text-[var(--status-warning)]';
return 'text-foreground';
};
const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => {
const progressPct = clampPercent(percentage) ?? 0;
const tone = resolveUsageTone(percentage);
const progressColor = tone === 'critical'
? 'var(--status-error)'
: tone === 'warn'
? 'var(--status-warning)'
: 'var(--status-success)';
const size = 18;
const stroke = 3;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
return (
<svg
viewBox={`0 0 ${size} ${size}`}
className="size-[18px] -rotate-90"
role="progressbar"
aria-valuenow={Math.round(progressPct)}
aria-valuemin={0}
aria-valuemax={100}
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--interactive-border)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={progressColor}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progressPct / 100)}
className="transition-[stroke-dashoffset,stroke] duration-300"
/>
</svg>
);
};
const MetadataRow: React.FC<{
icon?: IconName;
iconNode?: React.ReactNode;
label: string;
children: React.ReactNode;
}> = ({ icon, iconNode, label, children }) => (
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{iconNode ?? (icon ? <Icon name={icon} className="size-[18px]" /> : null)}
</span>
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
{children}
</span>
</div>
);
const SessionMetadataOverlay: React.FC<{
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
contextDisplay: ContextDisplay;
usageGroups: MobileUsageProviderGroup[];
usageDisplayMode: 'usage' | 'remaining';
isUsageLoading: boolean;
timeFormatPreference: TimeFormatPreference;
}> = ({ open, onClose, anchorRef, contextDisplay, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => {
const { t } = useI18n();
const panelRef = React.useRef<HTMLDivElement>(null);
const [shouldRender, setShouldRender] = React.useState(open);
const [isExiting, setIsExiting] = React.useState(false);
// iPad: a phone-width sheet stretched across the whole chat column looks
// broken — render a popover anchored to the metadata button instead.
const isIPad = React.useMemo(() => isIPadApp(), []);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
// The shell has transformed ancestors, so the fixed wrapper's containing
// block is the chat column, NOT the viewport. Anchor the popover in the
// wrapper's own coordinate space — viewport-based lefts would double-count
// the sidebar offset.
React.useLayoutEffect(() => {
if (!open || !isIPad || !shouldRender) return;
const compute = () => {
const anchorRect = anchorRef.current?.getBoundingClientRect();
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
if (!anchorRect || !wrapperRect) {
setIpadAnchorLeft(null);
return;
}
const relativeLeft = anchorRect.left - wrapperRect.left;
const left = Math.min(
Math.max(relativeLeft, 8),
Math.max(8, wrapperRect.width - IPAD_METADATA_POPOVER_WIDTH - 8),
);
setIpadAnchorLeft(left);
};
compute();
// Re-anchor if the chat column shifts while the popover is open (sidebar
// toggle/resize, orientation change) — the header buttons move with it.
const wrapper = wrapperRef.current;
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
return () => observer.disconnect();
}, [anchorRef, isIPad, open, shouldRender]);
const ipadPopover = isIPad && ipadAnchorLeft !== null;
React.useEffect(() => {
if (open) {
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 | WheelEvent) => {
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);
document.addEventListener('wheel', closeIfOutside, true);
return () => {
document.removeEventListener('pointerdown', closeIfOutside, true);
document.removeEventListener('wheel', closeIfOutside, true);
};
}, [anchorRef, onClose, open]);
if (!shouldRender) return null;
return (
<div ref={wrapperRef} 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('mobile.header.openMetadataAria')}
className={cn(
'overflow-y-auto overscroll-contain 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',
ipadPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
)}
style={{
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-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))',
...(ipadPopover
? {
top: 8,
left: ipadAnchorLeft ?? 8,
width: `min(${IPAD_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
}
: null),
}}
>
<div className="space-y-1">
{contextDisplay ? (
<MetadataRow
iconNode={<ContextProgressIcon percentage={contextDisplay.percentage} />}
label={t('mobile.header.metadata.context')}
>
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
</span>
</MetadataRow>
) : null}
<MobileUsageLimits
groups={usageGroups}
displayMode={usageDisplayMode}
isLoading={isUsageLoading}
timeFormatPreference={timeFormatPreference}
/>
</div>
</div>
<style>{`
@keyframes session-metadata-in {
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes session-metadata-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
}
`}</style>
</div>
);
};
const MobileUsageLimits: React.FC<{
groups: MobileUsageProviderGroup[];
displayMode: 'usage' | 'remaining';
isLoading: boolean;
timeFormatPreference: TimeFormatPreference;
}> = ({ groups, displayMode, isLoading, timeFormatPreference }) => {
const { t } = useI18n();
const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used');
// First open often races the quota fetch (~2s) — show an explicit loading
// row instead of collapsing to an empty overlay.
if (groups.length === 0) {
if (!isLoading) return null;
return (
<div className="flex items-center justify-center gap-2 px-2.5 py-6 text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" aria-hidden />
<span className="typography-ui-label">{t('common.loading')}</span>
</div>
);
}
return (
<div className="pt-2.5">
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="timer" className="size-[18px]" />
</span>
<span className="shrink-0 typography-ui-label text-muted-foreground">
{t('mobile.header.metadata.usage')}
</span>
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
<span className="truncate">{modeLabel}</span>
</span>
</div>
<div className="space-y-1.5">
{groups.map((group) => (
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
{group.providerName}
</span>
{group.status && group.rows.length === 0 ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground">
{group.status}
</span>
) : null}
</div>
{group.rows.length > 0 ? (
<div className="mt-1.5 space-y-1">
{group.rows.map((row) => {
const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent;
const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent);
const resetLabel = formatQuotaResetLabel(
row.window.resetAt,
row.window.resetAfterFormatted ?? row.window.resetAtFormatted,
timeFormatPreference,
);
return (
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate typography-ui-label text-muted-foreground">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
) : null}
</span>
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
);
})}
</div>
) : null}
{group.status && group.rows.length > 0 ? (
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
) : null}
</div>
))}
</div>
</div>
);
};
export const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({
open,
onOpenChange,
currentSessionId,
effectiveDirectory,
isNewSessionDraftOpen,
}: {
open: boolean;
onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void;
currentSessionId: string | null;
effectiveDirectory: string | null;
isNewSessionDraftOpen: boolean;
}) {
const { t } = useI18n();
const metadataTriggerRef = React.useRef<HTMLButtonElement>(null);
const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined);
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
useConfigStore((state) => state.modelsMetadata.size);
const savedSessionModel = useSelectionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null),
[currentSessionId],
),
);
const quotaResults = useQuotaStore((state) => state.results);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const selectedQuotaModels = useQuotaStore((state) => state.selectedModels);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
useQuotaAutoRefresh();
React.useEffect(() => {
void loadQuotaSettings();
}, [loadQuotaSettings]);
React.useEffect(() => {
preloadProviderLogos(dropdownProviderIds);
}, [dropdownProviderIds]);
React.useEffect(() => {
if (!open || isQuotaLoading) return;
const missingEnabledProvider = dropdownProviderIds.some((providerId) => (
!quotaResults.some((result) => result.providerId === providerId)
));
if (!missingEnabledProvider) return;
void fetchAllQuotas();
}, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]);
const latestMessageModel = React.useMemo(() => {
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
model?: { providerID?: string; modelID?: string };
};
if (message.role !== 'user') continue;
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
? message.model.providerID
: undefined;
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
? message.model.modelID
: undefined;
if (providerID && modelID) return { providerID, modelID };
}
return null;
}, [activeSessionMessages]);
const modelRef = latestMessageModel
?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null)
?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null);
const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined;
const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID);
const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined;
const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context')
?? metadata?.limit?.context
?? 0;
const totalTokens = React.useMemo(() => {
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
tokens?: {
input?: unknown;
output?: unknown;
reasoning?: unknown;
cache?: { read?: unknown; write?: unknown };
};
};
if (message.role !== 'assistant' || !message.tokens) continue;
const total = getTokenCount(message.tokens.input)
+ getTokenCount(message.tokens.output)
+ getTokenCount(message.tokens.reasoning)
+ getTokenCount(message.tokens.cache?.read)
+ getTokenCount(message.tokens.cache?.write);
if (total > 0) return total;
}
return 0;
}, [activeSessionMessages]);
const contextPercentage =
!isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0
? Math.min((totalTokens / contextLimit) * 100, 999)
: null;
const contextTokens = contextPercentage !== null
? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}`
: null;
const contextColorClass =
contextPercentage === null
? ''
: contextPercentage >= 90
? 'text-[var(--status-error)]'
: contextPercentage >= 75
? 'text-[var(--status-warning)]'
: 'text-[var(--status-success)]';
const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens
? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass }
: null;
const usageGroups = React.useMemo<MobileUsageProviderGroup[]>(() => {
const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result]));
return QUOTA_PROVIDERS
.filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id))
.filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true)
.map((providerMeta) => {
const result = resultsByProvider.get(providerMeta.id)!;
const rows: MobileUsageLimitRow[] = [];
for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) {
rows.push({
key: `window-${label}`,
label: formatWindowLabel(label),
window,
});
}
const modelEntries = Object.entries(result?.usage?.models ?? {});
const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? [];
const visibleModelEntries = providerSelectedModels.length > 0
? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName))
: modelEntries;
for (const [modelName, modelUsage] of visibleModelEntries) {
const entries = Object.entries(modelUsage.windows ?? {});
if (entries.length === 0) continue;
const [label, window] = entries[0];
rows.push({
key: `model-${modelName}-${label}`,
label: formatWindowLabel(label),
subtitle: getDisplayModelName(modelName),
window,
});
}
const status = !result.ok && result.error
? result.error
: rows.length === 0
? t('header.services.noRateLimitsReported')
: null;
return {
providerId: providerMeta.id,
providerName: providerMeta.name,
rows,
status,
};
});
}, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]);
React.useEffect(() => {
if (!open || usageGroups.length === 0) return;
preloadProviderLogos(usageGroups.map((group) => group.providerId));
}, [open, usageGroups]);
return (
<>
<button
ref={metadataTriggerRef}
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.openMetadataAria')}
aria-expanded={open}
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
style={{ touchAction: 'manipulation' }}
>
{/* Live context gauge doubles as the metadata trigger: filled by the
session's context usage, an empty ring on a fresh draft. */}
<ContextProgressIcon percentage={contextDisplay?.percentage ?? 0} />
</button>
<SessionMetadataOverlay
open={open}
onClose={() => onOpenChange(false)}
anchorRef={metadataTriggerRef}
contextDisplay={contextDisplay}
usageGroups={usageGroups}
usageDisplayMode={quotaDisplayMode}
isUsageLoading={isQuotaLoading}
timeFormatPreference={timeFormatPreference}
/>
</>
);
});