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,65 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
class TestStorage implements Storage {
readonly values = new Map<string, string>()
get length() { return this.values.size }
clear() { this.values.clear() }
getItem(key: string) { return this.values.get(key) ?? null }
key(index: number) { return [...this.values.keys()][index] ?? null }
removeItem(key: string) { this.values.delete(key) }
setItem(key: string, value: string) { this.values.set(key, value) }
}
let storage: TestStorage
beforeEach(() => {
storage = new TestStorage()
})
describe("last active session persistence", () => {
test("keeps independent entries per runtime", () => {
persistLastActiveSession("runtime-a", { sessionId: "ses-a", directory: "/repo/a" }, storage)
persistLastActiveSession("runtime-b", { sessionId: "ses-b", directory: null }, storage)
expect(readLastActiveSession("runtime-a", storage)).toEqual({ sessionId: "ses-a", directory: "/repo/a" })
expect(readLastActiveSession("runtime-b", storage)).toEqual({ sessionId: "ses-b", directory: null })
})
test("overwrites the entry for the same runtime", () => {
persistLastActiveSession("runtime-a", { sessionId: "ses-1", directory: "/repo" }, storage)
persistLastActiveSession("runtime-a", { sessionId: "ses-2", directory: null }, storage)
expect(readLastActiveSession("runtime-a", storage)).toEqual({ sessionId: "ses-2", directory: null })
})
test("clear removes only the targeted runtime", () => {
persistLastActiveSession("runtime-a", { sessionId: "ses-a", directory: null }, storage)
persistLastActiveSession("runtime-b", { sessionId: "ses-b", directory: null }, storage)
clearLastActiveSession("runtime-a", storage)
expect(readLastActiveSession("runtime-a", storage)).toBeNull()
expect(readLastActiveSession("runtime-b", storage)).toEqual({ sessionId: "ses-b", directory: null })
})
test("malformed persisted payload reads as empty, not a crash", () => {
storage.setItem("oc.lastSession.v1", "{not json")
expect(readLastActiveSession("runtime-a", storage)).toBeNull()
storage.setItem("oc.lastSession.v1", JSON.stringify({ version: 99, runtimes: { "runtime-a": { sessionId: "x" } } }))
expect(readLastActiveSession("runtime-a", storage)).toBeNull()
})
test("bounds retained runtime namespaces", () => {
for (let index = 0; index < 10; index += 1) {
persistLastActiveSession(`runtime-${index}`, { sessionId: `ses-${index}`, directory: null }, storage)
}
const retained = Array.from({ length: 10 }, (_, index) => readLastActiveSession(`runtime-${index}`, storage))
.filter(Boolean)
expect(retained.length).toBe(8)
// Newest entries survive.
expect(readLastActiveSession("runtime-9", storage)).not.toBeNull()
expect(readLastActiveSession("runtime-0", storage)).toBeNull()
})
})
@@ -0,0 +1,91 @@
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
// Persisted "last active session" per runtime (server instance), so a cold
// app launch can reopen the session the user had open the last time this
// instance was connected. This is startup-continuity context ONLY — callers
// must confirm the session still exists against an authoritative snapshot
// before opening it (see the MobileApp restore effect).
const STORAGE_KEY = "oc.lastSession.v1"
const MAX_RUNTIME_ENTRIES = 8
export type PersistedLastSession = {
sessionId: string
directory: string | null
}
type PersistedEntry = PersistedLastSession & { updatedAt: number }
type PersistedEnvelope = {
version: 1
runtimes: Record<string, PersistedEntry>
}
const emptyEnvelope = (): PersistedEnvelope => ({ version: 1, runtimes: {} })
const readEnvelope = (storage: Storage): PersistedEnvelope => {
try {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) return emptyEnvelope()
const parsed = JSON.parse(raw) as Partial<PersistedEnvelope>
if (parsed.version !== 1 || !parsed.runtimes || typeof parsed.runtimes !== "object") return emptyEnvelope()
const runtimes: Record<string, PersistedEntry> = {}
for (const [runtimeKey, entry] of Object.entries(parsed.runtimes)) {
if (!runtimeKey || !entry || typeof entry.sessionId !== "string" || entry.sessionId.length === 0) continue
runtimes[runtimeKey] = {
sessionId: entry.sessionId,
directory: typeof entry.directory === "string" && entry.directory.length > 0 ? entry.directory : null,
updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : 0,
}
}
return { version: 1, runtimes }
} catch {
// Malformed persisted data is a read failure, not empty success — but for
// a pure convenience cache the correct recovery is the same: start fresh.
return emptyEnvelope()
}
}
const writeEnvelope = (storage: Storage, envelope: PersistedEnvelope): void => {
const retained = Object.entries(envelope.runtimes)
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
.slice(0, MAX_RUNTIME_ENTRIES)
try {
storage.setItem(STORAGE_KEY, JSON.stringify({ ...envelope, runtimes: Object.fromEntries(retained) }))
} catch {
// Best-effort cache — a full/blocked storage must never break session switching.
}
}
export function persistLastActiveSession(
runtimeKey: string,
entry: PersistedLastSession,
storage: Storage = getDeferredSafeStorage(),
): void {
if (!runtimeKey || !entry.sessionId) return
const envelope = readEnvelope(storage)
// Monotonic vs the stored entries: same-millisecond writes must not tie,
// or retention trimming would evict an arbitrary runtime.
const maxExisting = Object.values(envelope.runtimes).reduce((max, existing) => Math.max(max, existing.updatedAt), 0)
envelope.runtimes[runtimeKey] = { ...entry, updatedAt: Math.max(Date.now(), maxExisting + 1) }
writeEnvelope(storage, envelope)
}
export function readLastActiveSession(
runtimeKey: string,
storage: Storage = getDeferredSafeStorage(),
): PersistedLastSession | null {
if (!runtimeKey) return null
const entry = readEnvelope(storage).runtimes[runtimeKey]
return entry ? { sessionId: entry.sessionId, directory: entry.directory } : null
}
export function clearLastActiveSession(
runtimeKey: string,
storage: Storage = getDeferredSafeStorage(),
): void {
if (!runtimeKey) return
const envelope = readEnvelope(storage)
if (!envelope.runtimes[runtimeKey]) return
delete envelope.runtimes[runtimeKey]
writeEnvelope(storage, envelope)
}
@@ -8,6 +8,7 @@ import {
removeSessionOrdering,
resetSessionOrdering,
useSessionOrderingStore,
raiseSessionOrderingBaselines,
} from './session-ordering';
const session = (
@@ -135,4 +136,25 @@ describe('session lifecycle ordering', () => {
'active-child',
]);
});
test('authoritative snapshot raises frozen baselines without live ranks', () => {
const older = session('older', 10);
const newer = session('newer', 20);
// Freeze both baselines at their first-seen timestamps.
expect(compareSessionsByLifecycleOrder(older, newer, new Set(), new Map())).toBeGreaterThan(0);
// A metadata-only live update must NOT reorder (frozen baseline)...
const liveBump = session('older', 30);
expect(compareSessionsByLifecycleOrder(liveBump, newer, new Set(), new Map())).toBeGreaterThan(0);
// ...but an authoritative snapshot with the newer stamp raises the baseline.
raiseSessionOrderingBaselines([liveBump, newer]);
expect(compareSessionsByLifecycleOrder(liveBump, newer, new Set(), new Map())).toBeLessThan(0);
});
test('store-held stale live rank is raised by an authoritative snapshot', () => {
useSessionOrderingStore.setState({ rankById: new Map([['stale', 15]]) });
raiseSessionOrderingBaselines([session('stale', 40)]);
expect(useSessionOrderingStore.getState().rankById.get('stale')).toBe(40);
});
});
+42
View File
@@ -120,6 +120,48 @@ const baselineRank = (session: Session, pinned: boolean): number => {
return rank;
};
/**
* Raise cached baselines to the sessions' current authoritative timestamps.
*
* The frozen baseline keeps live metadata churn from reordering an open list,
* but a client that slept through a session's whole active→settled cycle never
* saw the transition that would have promoted its live rank — so its stale
* baseline pins it in place forever. Call this when an authoritative session
* SNAPSHOT arrives (global refresh); monotonic, so it can never demote.
*/
export const raiseSessionOrderingBaselines = (sessions: Iterable<Session>): void => {
const currentRanks = useSessionOrderingStore.getState().rankById;
let nextRanks: Map<string, number> | null = null;
let baselinesChanged = false;
for (const session of sessions) {
const fresh = updatedAt(session);
const liveRank = currentRanks.get(session.id);
if (liveRank !== undefined) {
// A live rank frozen BEFORE this newer authoritative stamp is stale —
// the session was active again while this client wasn't watching (its
// transition events never arrived, e.g. другий пристрій + сон). Ranks
// share the epoch-ms scale with `updated`, so raising is well-ordered.
if (fresh > liveRank) {
nextRanks = nextRanks ?? new Map(currentRanks);
nextRanks.set(session.id, fresh);
}
continue;
}
const existing = baselineRankById.get(session.id);
if (existing?.updated !== undefined && existing.updated >= fresh) continue;
baselineRankById.set(session.id, { ...existing, updated: fresh });
baselinesChanged = true;
}
if (nextRanks) {
useSessionOrderingStore.setState({ rankById: nextRanks });
} else if (baselinesChanged) {
// Baselines live outside the store; nudge subscribers so open lists re-sort.
useSessionOrderingStore.setState((state) => ({ rankById: new Map(state.rankById) }));
}
};
export const getSessionLifecycleOrderValue = (
session: Session,
rankById: ReadonlyMap<string, number>,
+17 -1
View File
@@ -68,6 +68,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
@@ -284,7 +285,7 @@ export type SessionUIState = {
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void
closeNewSessionDraft: () => void
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void
@@ -612,6 +613,12 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
// Keep the last NON-null session per runtime across app restarts (cold
// mobile launches reopen it after the instance reconnects). Going back to
// a draft intentionally does not erase it.
if (id) {
persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null })
}
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
@@ -710,6 +717,15 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// openNewSessionDraft
// ---------------------------------------------------------------------------
openNewSessionDraft: (options) => {
// A USER-initiated draft open is a navigation choice: the next cold launch
// should land on the draft, not re-open the session left behind — drop the
// persisted last-session pointer for this runtime. `automatic: true` marks
// programmatic fallback opens (e.g. ChatContainer's "no session active"
// auto-draft at boot), which must NOT consume the pointer — the cold-launch
// restore races exactly that auto-open.
if (!options?.automatic) {
clearLastActiveSession(runtimeMemoryKey())
}
const projectsState = useProjectsStore.getState()
const projects = projectsState.projects
const availableWorktreesByProject = get().availableWorktreesByProject