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